Compare commits
No commits in common. "180e63e975a19e7f0047c358f0906bc2f23675c4" and "d4b25a11677598a814e146d8e5029b6a6412c1ae" have entirely different histories.
180e63e975
...
d4b25a1167
|
|
@ -1,29 +0,0 @@
|
||||||
-- Seed account deletion request states for admin panel tracking
|
|
||||||
-- Users referenced here are seeded in 001_initial_seed_data.sql (IDs: 10, 11, 12).
|
|
||||||
|
|
||||||
-- Pending deletion request (within grace period)
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
deletion_requested_at = now() - interval '2 days',
|
|
||||||
deletion_scheduled_at = now() + interval '13 days',
|
|
||||||
deletion_cancelled_at = NULL,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = 10;
|
|
||||||
|
|
||||||
-- Due deletion request (grace period elapsed, awaiting purge worker)
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
deletion_requested_at = now() - interval '20 days',
|
|
||||||
deletion_scheduled_at = now() - interval '5 days',
|
|
||||||
deletion_cancelled_at = NULL,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = 11;
|
|
||||||
|
|
||||||
-- Cancelled deletion request (request made then cancelled)
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
deletion_requested_at = now() - interval '10 days',
|
|
||||||
deletion_scheduled_at = now() + interval '5 days',
|
|
||||||
deletion_cancelled_at = now() - interval '3 days',
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = 12;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
DROP INDEX IF EXISTS idx_users_deletion_due;
|
|
||||||
DROP INDEX IF EXISTS idx_users_deletion_scheduled_at;
|
|
||||||
|
|
||||||
ALTER TABLE users
|
|
||||||
DROP COLUMN IF EXISTS deletion_cancelled_at,
|
|
||||||
DROP COLUMN IF EXISTS deletion_scheduled_at,
|
|
||||||
DROP COLUMN IF EXISTS deletion_requested_at;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMPTZ,
|
|
||||||
ADD COLUMN IF NOT EXISTS deletion_scheduled_at TIMESTAMPTZ,
|
|
||||||
ADD COLUMN IF NOT EXISTS deletion_cancelled_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_deletion_scheduled_at
|
|
||||||
ON users (deletion_scheduled_at)
|
|
||||||
WHERE deletion_scheduled_at IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_deletion_due
|
|
||||||
ON users (deletion_scheduled_at, id)
|
|
||||||
WHERE deletion_scheduled_at IS NOT NULL;
|
|
||||||
2432
docs/docs.go
2432
docs/docs.go
File diff suppressed because it is too large
Load Diff
2432
docs/swagger.json
2432
docs/swagger.json
File diff suppressed because it is too large
Load Diff
1591
docs/swagger.yaml
1591
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -25,8 +25,9 @@ var (
|
||||||
ErrInvalidReportExportPath = errors.New("report export path is invalid")
|
ErrInvalidReportExportPath = errors.New("report export path is invalid")
|
||||||
ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid")
|
ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid")
|
||||||
|
|
||||||
ErrMissingResendApiKey = errors.New("missing Resend Api key")
|
ErrMissingResendApiKey = errors.New("missing Resend Api key")
|
||||||
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
|
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AFROSMSConfig struct {
|
type AFROSMSConfig struct {
|
||||||
|
|
@ -89,9 +90,9 @@ type Config struct {
|
||||||
GoogleOAuthClientID string
|
GoogleOAuthClientID string
|
||||||
GoogleOAuthClientSecret string
|
GoogleOAuthClientSecret string
|
||||||
GoogleOAuthRedirectURL string
|
GoogleOAuthRedirectURL string
|
||||||
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
|
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
|
||||||
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
|
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
|
||||||
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
|
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
|
||||||
APP_VERSION string
|
APP_VERSION string
|
||||||
FIXER_API_KEY string
|
FIXER_API_KEY string
|
||||||
FIXER_BASE_URL string
|
FIXER_BASE_URL string
|
||||||
|
|
@ -127,9 +128,6 @@ type Config struct {
|
||||||
RedisAddr string
|
RedisAddr string
|
||||||
KafkaBrokers []string
|
KafkaBrokers []string
|
||||||
FCMServiceAccountKey string
|
FCMServiceAccountKey string
|
||||||
AccountDeletionPurgeEnabled bool
|
|
||||||
AccountDeletionPurgeInterval time.Duration
|
|
||||||
AccountDeletionPurgeBatchSize int32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfig() (*Config, error) {
|
func NewConfig() (*Config, error) {
|
||||||
|
|
@ -475,40 +473,6 @@ func (c *Config) loadEnv() error {
|
||||||
}
|
}
|
||||||
c.CloudConvert.APIKey = os.Getenv("CLOUDCONVERT_API_KEY")
|
c.CloudConvert.APIKey = os.Getenv("CLOUDCONVERT_API_KEY")
|
||||||
|
|
||||||
// Two-phase account deletion purge worker configuration
|
|
||||||
accountDeletionPurgeEnabled := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_ENABLED"))
|
|
||||||
if accountDeletionPurgeEnabled == "" {
|
|
||||||
c.AccountDeletionPurgeEnabled = true
|
|
||||||
} else {
|
|
||||||
c.AccountDeletionPurgeEnabled = accountDeletionPurgeEnabled == "true" || accountDeletionPurgeEnabled == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
accountDeletionPurgeInterval := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_INTERVAL"))
|
|
||||||
if accountDeletionPurgeInterval == "" {
|
|
||||||
c.AccountDeletionPurgeInterval = time.Hour
|
|
||||||
} else {
|
|
||||||
interval, err := time.ParseDuration(accountDeletionPurgeInterval)
|
|
||||||
if err != nil || interval <= 0 {
|
|
||||||
c.AccountDeletionPurgeInterval = time.Hour
|
|
||||||
} else {
|
|
||||||
c.AccountDeletionPurgeInterval = interval
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
accountDeletionPurgeBatchSize := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_BATCH_SIZE"))
|
|
||||||
if accountDeletionPurgeBatchSize == "" {
|
|
||||||
c.AccountDeletionPurgeBatchSize = 100
|
|
||||||
} else {
|
|
||||||
batchSize, err := strconv.Atoi(accountDeletionPurgeBatchSize)
|
|
||||||
if err != nil || batchSize <= 0 {
|
|
||||||
c.AccountDeletionPurgeBatchSize = 100
|
|
||||||
} else if batchSize > 1000 {
|
|
||||||
c.AccountDeletionPurgeBatchSize = 1000
|
|
||||||
} else {
|
|
||||||
c.AccountDeletionPurgeBatchSize = int32(batchSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,43 +133,6 @@ type UserFilter struct {
|
||||||
CreatedAfter ValidTime
|
CreatedAfter ValidTime
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountDeletionState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AccountDeletionStatePending AccountDeletionState = "PENDING"
|
|
||||||
AccountDeletionStateDue AccountDeletionState = "DUE"
|
|
||||||
AccountDeletionStateCancelled AccountDeletionState = "CANCELLED"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AccountDeletionRequestFilter struct {
|
|
||||||
Query string
|
|
||||||
Role string
|
|
||||||
Status string
|
|
||||||
State string
|
|
||||||
|
|
||||||
RequestedBefore ValidTime
|
|
||||||
RequestedAfter ValidTime
|
|
||||||
ScheduledBefore ValidTime
|
|
||||||
ScheduledAfter ValidTime
|
|
||||||
|
|
||||||
Page int64
|
|
||||||
PageSize int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountDeletionRequest struct {
|
|
||||||
UserID int64
|
|
||||||
FirstName string
|
|
||||||
LastName string
|
|
||||||
Email string
|
|
||||||
PhoneNumber string
|
|
||||||
Role Role
|
|
||||||
Status UserStatus
|
|
||||||
DeletionRequestedAt *time.Time
|
|
||||||
DeletionScheduledAt *time.Time
|
|
||||||
DeletionCancelledAt *time.Time
|
|
||||||
DeletionState AccountDeletionState
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisterUserReq struct {
|
type RegisterUserReq struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PhoneNumber string `json:"phone_number"`
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ type UserStore interface {
|
||||||
createdBefore, createdAfter *time.Time,
|
createdBefore, createdAfter *time.Time,
|
||||||
limit, offset int32,
|
limit, offset int32,
|
||||||
) ([]domain.User, int64, error)
|
) ([]domain.User, int64, error)
|
||||||
ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error)
|
|
||||||
GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
||||||
GetUserSummary(ctx context.Context) (domain.UserSummary, error)
|
GetUserSummary(ctx context.Context) (domain.UserSummary, error)
|
||||||
SearchUserByNameOrPhone(
|
SearchUserByNameOrPhone(
|
||||||
|
|
@ -67,9 +66,6 @@ type UserStore interface {
|
||||||
) ([]domain.User, error)
|
) ([]domain.User, error)
|
||||||
UpdateUser(ctx context.Context, req domain.UpdateUserReq) error
|
UpdateUser(ctx context.Context, req domain.UpdateUserReq) error
|
||||||
DeleteUser(ctx context.Context, userID int64) error
|
DeleteUser(ctx context.Context, userID int64) error
|
||||||
RequestUserDeletion(ctx context.Context, userID int64, gracePeriod time.Duration) (time.Time, error)
|
|
||||||
CancelUserDeletion(ctx context.Context, userID int64) error
|
|
||||||
PurgeDueUserDeletions(ctx context.Context, limit int32) (int64, error)
|
|
||||||
CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error)
|
CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error)
|
||||||
GetUserByEmailPhone(
|
GetUserByEmailPhone(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
@ -538,172 +536,6 @@ func (s *Store) GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error) {
|
|
||||||
page := filter.Page
|
|
||||||
if page < 0 {
|
|
||||||
page = 0
|
|
||||||
}
|
|
||||||
pageSize := filter.PageSize
|
|
||||||
if pageSize <= 0 {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
if pageSize > 100 {
|
|
||||||
pageSize = 100
|
|
||||||
}
|
|
||||||
offset := page * pageSize
|
|
||||||
|
|
||||||
baseQuery := `
|
|
||||||
FROM users u
|
|
||||||
WHERE (
|
|
||||||
u.deletion_requested_at IS NOT NULL
|
|
||||||
OR u.deletion_cancelled_at IS NOT NULL
|
|
||||||
)
|
|
||||||
`
|
|
||||||
args := make([]interface{}, 0, 16)
|
|
||||||
argPos := 1
|
|
||||||
|
|
||||||
if filter.Query != "" {
|
|
||||||
baseQuery += fmt.Sprintf(`
|
|
||||||
AND (
|
|
||||||
COALESCE(u.first_name, '') ILIKE $%d
|
|
||||||
OR COALESCE(u.last_name, '') ILIKE $%d
|
|
||||||
OR COALESCE(u.email, '') ILIKE $%d
|
|
||||||
OR COALESCE(u.phone_number, '') ILIKE $%d
|
|
||||||
)
|
|
||||||
`, argPos, argPos, argPos, argPos)
|
|
||||||
args = append(args, "%"+filter.Query+"%")
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.Role != "" {
|
|
||||||
baseQuery += fmt.Sprintf(" AND u.role = $%d", argPos)
|
|
||||||
args = append(args, filter.Role)
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
if filter.Status != "" {
|
|
||||||
baseQuery += fmt.Sprintf(" AND u.status = $%d", argPos)
|
|
||||||
args = append(args, filter.Status)
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
if filter.RequestedAfter.Valid {
|
|
||||||
baseQuery += fmt.Sprintf(" AND u.deletion_requested_at >= $%d", argPos)
|
|
||||||
args = append(args, filter.RequestedAfter.Value)
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
if filter.RequestedBefore.Valid {
|
|
||||||
baseQuery += fmt.Sprintf(" AND u.deletion_requested_at <= $%d", argPos)
|
|
||||||
args = append(args, filter.RequestedBefore.Value)
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
if filter.ScheduledAfter.Valid {
|
|
||||||
baseQuery += fmt.Sprintf(" AND u.deletion_scheduled_at >= $%d", argPos)
|
|
||||||
args = append(args, filter.ScheduledAfter.Value)
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
if filter.ScheduledBefore.Valid {
|
|
||||||
baseQuery += fmt.Sprintf(" AND u.deletion_scheduled_at <= $%d", argPos)
|
|
||||||
args = append(args, filter.ScheduledBefore.Value)
|
|
||||||
argPos++
|
|
||||||
}
|
|
||||||
|
|
||||||
state := strings.ToUpper(strings.TrimSpace(filter.State))
|
|
||||||
switch state {
|
|
||||||
case string(domain.AccountDeletionStatePending):
|
|
||||||
baseQuery += " AND u.deletion_scheduled_at > CURRENT_TIMESTAMP AND u.deletion_cancelled_at IS NULL"
|
|
||||||
case string(domain.AccountDeletionStateDue):
|
|
||||||
baseQuery += " AND u.deletion_scheduled_at <= CURRENT_TIMESTAMP AND u.deletion_cancelled_at IS NULL"
|
|
||||||
case string(domain.AccountDeletionStateCancelled):
|
|
||||||
baseQuery += " AND u.deletion_cancelled_at IS NOT NULL"
|
|
||||||
}
|
|
||||||
|
|
||||||
countQuery := "SELECT COUNT(*) " + baseQuery
|
|
||||||
var total int64
|
|
||||||
if err := s.conn.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
selectQuery := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
COALESCE(u.first_name, '') AS first_name,
|
|
||||||
COALESCE(u.last_name, '') AS last_name,
|
|
||||||
COALESCE(u.email, '') AS email,
|
|
||||||
COALESCE(u.phone_number, '') AS phone_number,
|
|
||||||
u.role,
|
|
||||||
u.status,
|
|
||||||
u.deletion_requested_at,
|
|
||||||
u.deletion_scheduled_at,
|
|
||||||
u.deletion_cancelled_at,
|
|
||||||
CASE
|
|
||||||
WHEN u.deletion_cancelled_at IS NOT NULL THEN 'CANCELLED'
|
|
||||||
WHEN u.deletion_scheduled_at <= CURRENT_TIMESTAMP THEN 'DUE'
|
|
||||||
ELSE 'PENDING'
|
|
||||||
END AS deletion_state
|
|
||||||
%s
|
|
||||||
ORDER BY u.deletion_requested_at DESC, u.id DESC
|
|
||||||
LIMIT $%d OFFSET $%d
|
|
||||||
`, baseQuery, argPos, argPos+1)
|
|
||||||
|
|
||||||
queryArgs := append(args, pageSize, offset)
|
|
||||||
rows, err := s.conn.Query(ctx, selectQuery, queryArgs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
out := make([]domain.AccountDeletionRequest, 0, pageSize)
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
item domain.AccountDeletionRequest
|
|
||||||
role string
|
|
||||||
status string
|
|
||||||
stateVal string
|
|
||||||
requestedAt pgtype.Timestamptz
|
|
||||||
scheduledAt pgtype.Timestamptz
|
|
||||||
cancelledAt pgtype.Timestamptz
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&item.UserID,
|
|
||||||
&item.FirstName,
|
|
||||||
&item.LastName,
|
|
||||||
&item.Email,
|
|
||||||
&item.PhoneNumber,
|
|
||||||
&role,
|
|
||||||
&status,
|
|
||||||
&requestedAt,
|
|
||||||
&scheduledAt,
|
|
||||||
&cancelledAt,
|
|
||||||
&stateVal,
|
|
||||||
); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Role = domain.Role(role)
|
|
||||||
item.Status = domain.UserStatus(status)
|
|
||||||
item.DeletionState = domain.AccountDeletionState(stateVal)
|
|
||||||
if requestedAt.Valid {
|
|
||||||
t := requestedAt.Time
|
|
||||||
item.DeletionRequestedAt = &t
|
|
||||||
}
|
|
||||||
if scheduledAt.Valid {
|
|
||||||
t := scheduledAt.Time
|
|
||||||
item.DeletionScheduledAt = &t
|
|
||||||
}
|
|
||||||
if cancelledAt.Valid {
|
|
||||||
t := cancelledAt.Time
|
|
||||||
item.DeletionCancelledAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, item)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchUserByNameOrPhone searches users by name or phone
|
// SearchUserByNameOrPhone searches users by name or phone
|
||||||
func (s *Store) SearchUserByNameOrPhone(
|
func (s *Store) SearchUserByNameOrPhone(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -837,90 +669,6 @@ func (s *Store) DeleteUser(ctx context.Context, userID int64) error {
|
||||||
return s.queries.DeleteUser(ctx, userID)
|
return s.queries.DeleteUser(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RequestUserDeletion(ctx context.Context, userID int64, gracePeriod time.Duration) (time.Time, error) {
|
|
||||||
scheduledAt := time.Now().UTC().Add(gracePeriod)
|
|
||||||
const query = `
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
deletion_requested_at = CURRENT_TIMESTAMP,
|
|
||||||
deletion_scheduled_at = $1,
|
|
||||||
deletion_cancelled_at = NULL,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $2;
|
|
||||||
`
|
|
||||||
tag, err := s.conn.Exec(ctx, query, scheduledAt, userID)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, err
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return time.Time{}, domain.ErrUserNotFound
|
|
||||||
}
|
|
||||||
return scheduledAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) CancelUserDeletion(ctx context.Context, userID int64) error {
|
|
||||||
const query = `
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
deletion_requested_at = NULL,
|
|
||||||
deletion_scheduled_at = NULL,
|
|
||||||
deletion_cancelled_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1;
|
|
||||||
`
|
|
||||||
tag, err := s.conn.Exec(ctx, query, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrUserNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) PurgeDueUserDeletions(ctx context.Context, limit int32) (int64, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
if limit > 1000 {
|
|
||||||
limit = 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.conn.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
WITH due AS (
|
|
||||||
SELECT id
|
|
||||||
FROM users
|
|
||||||
WHERE deletion_scheduled_at IS NOT NULL
|
|
||||||
AND deletion_scheduled_at <= CURRENT_TIMESTAMP
|
|
||||||
ORDER BY deletion_scheduled_at ASC
|
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
LIMIT $1
|
|
||||||
),
|
|
||||||
deleted AS (
|
|
||||||
DELETE FROM users u
|
|
||||||
USING due
|
|
||||||
WHERE u.id = due.id
|
|
||||||
RETURNING u.id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*)::BIGINT
|
|
||||||
FROM deleted;
|
|
||||||
`
|
|
||||||
var deletedCount int64
|
|
||||||
if err := tx.QueryRow(ctx, query, limit).Scan(&deletedCount); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return deletedCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckPhoneEmailExist checks if phone or email exists in an organization
|
// CheckPhoneEmailExist checks if phone or email exists in an organization
|
||||||
func (s *Store) CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error) {
|
func (s *Store) CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error) {
|
||||||
res, err := s.queries.CheckPhoneEmailExist(ctx, dbgen.CheckPhoneEmailExistParams{
|
res, err := s.queries.CheckPhoneEmailExist(ctx, dbgen.CheckPhoneEmailExistParams{
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,7 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "users.update_self", Name: "Update Own Profile", Description: "Update own user profile", GroupName: "Users"},
|
{Key: "users.update_self", Name: "Update Own Profile", Description: "Update own user profile", GroupName: "Users"},
|
||||||
{Key: "users.update_status", Name: "Update User Status", Description: "Activate/deactivate users", GroupName: "Users"},
|
{Key: "users.update_status", Name: "Update User Status", Description: "Activate/deactivate users", GroupName: "Users"},
|
||||||
{Key: "users.delete", Name: "Delete User", Description: "Delete a user", GroupName: "Users"},
|
{Key: "users.delete", Name: "Delete User", Description: "Delete a user", GroupName: "Users"},
|
||||||
{Key: "users.delete_self", Name: "Request Own Account Deletion", Description: "Request own account deletion with grace period", GroupName: "Users"},
|
{Key: "users.delete_self", Name: "Delete Own Account", Description: "Delete own user account", GroupName: "Users"},
|
||||||
{Key: "users.cancel_delete_self", Name: "Cancel Own Account Deletion", Description: "Cancel own pending account deletion request", GroupName: "Users"},
|
|
||||||
{Key: "users.purge_due_deletions", Name: "Purge Due Account Deletions", Description: "Purge users whose deletion grace period has elapsed", GroupName: "Users"},
|
|
||||||
{Key: "users.deletion_requests.list", Name: "List Account Deletion Requests", Description: "List account deletion requests for admin tracking", GroupName: "Users"},
|
|
||||||
{Key: "users.search", Name: "Search Users", Description: "Search users by name or phone", GroupName: "Users"},
|
{Key: "users.search", Name: "Search Users", Description: "Search users by name or phone", GroupName: "Users"},
|
||||||
{Key: "users.profile_completed", Name: "Check Profile Completed", Description: "Check if user profile is completed", GroupName: "Users"},
|
{Key: "users.profile_completed", Name: "Check Profile Completed", Description: "Check if user profile is completed", GroupName: "Users"},
|
||||||
{Key: "users.upload_profile_picture", Name: "Upload Profile Picture", Description: "Upload user profile picture", GroupName: "Users"},
|
{Key: "users.upload_profile_picture", Name: "Upload Profile Picture", Description: "Upload user profile picture", GroupName: "Users"},
|
||||||
|
|
@ -254,7 +251,7 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||||
|
|
||||||
// Users (full access)
|
// Users (full access)
|
||||||
"users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.cancel_delete_self", "users.purge_due_deletions", "users.deletion_requests.list", "users.search",
|
"users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.search",
|
||||||
"users.profile_completed", "users.upload_profile_picture", "users.admin_profile", "users.user_profile",
|
"users.profile_completed", "users.upload_profile_picture", "users.admin_profile", "users.user_profile",
|
||||||
|
|
||||||
// Admin management
|
// Admin management
|
||||||
|
|
@ -329,7 +326,7 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||||
|
|
||||||
// User (self-service)
|
// User (self-service)
|
||||||
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
"users.update_self", "users.delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
||||||
|
|
||||||
// Notifications (own)
|
// Notifications (own)
|
||||||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||||
|
|
@ -377,7 +374,7 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||||
|
|
||||||
// User (self-service)
|
// User (self-service)
|
||||||
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
"users.update_self", "users.delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
||||||
|
|
||||||
// Notifications (own)
|
// Notifications (own)
|
||||||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||||
|
|
@ -419,7 +416,7 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
"question_set_personas.list",
|
"question_set_personas.list",
|
||||||
|
|
||||||
// Users (view + search for support)
|
// Users (view + search for support)
|
||||||
"users.list", "users.get", "users.search", "users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed",
|
"users.list", "users.get", "users.search", "users.update_self", "users.delete_self", "users.profile_completed",
|
||||||
"users.upload_profile_picture", "users.user_profile",
|
"users.upload_profile_picture", "users.user_profile",
|
||||||
|
|
||||||
// Notifications (own)
|
// Notifications (own)
|
||||||
|
|
|
||||||
|
|
@ -47,18 +47,6 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error {
|
||||||
return s.userStore.DeleteUser(ctx, id)
|
return s.userStore.DeleteUser(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RequestUserDeletion(ctx context.Context, userID int64, gracePeriod time.Duration) (time.Time, error) {
|
|
||||||
return s.userStore.RequestUserDeletion(ctx, userID, gracePeriod)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CancelUserDeletion(ctx context.Context, userID int64) error {
|
|
||||||
return s.userStore.CancelUserDeletion(ctx, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) PurgeDueUserDeletions(ctx context.Context, limit int32) (int64, error) {
|
|
||||||
return s.userStore.PurgeDueUserDeletions(ctx, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetAllUsers(
|
func (s *Service) GetAllUsers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
filter domain.UserFilter,
|
filter domain.UserFilter,
|
||||||
|
|
@ -107,10 +95,6 @@ func (s *Service) GetUserSummary(ctx context.Context) (domain.UserSummary, error
|
||||||
return s.userStore.GetUserSummary(ctx)
|
return s.userStore.GetUserSummary(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error) {
|
|
||||||
return s.userStore.ListAccountDeletionRequests(ctx, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error {
|
func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error {
|
||||||
return s.userStore.UpdateUserStatus(ctx, req)
|
return s.userStore.UpdateUserStatus(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ import (
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/config"
|
"Yimaru-Backend/internal/config"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
|
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||||
|
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
|
||||||
"Yimaru-Backend/internal/services/course_management"
|
"Yimaru-Backend/internal/services/course_management"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/questions"
|
"Yimaru-Backend/internal/services/questions"
|
||||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
|
||||||
rbacservice "Yimaru-Backend/internal/services/rbac"
|
|
||||||
"Yimaru-Backend/internal/services/recommendation"
|
"Yimaru-Backend/internal/services/recommendation"
|
||||||
"Yimaru-Backend/internal/services/subscriptions"
|
"Yimaru-Backend/internal/services/subscriptions"
|
||||||
"Yimaru-Backend/internal/services/team"
|
"Yimaru-Backend/internal/services/team"
|
||||||
|
|
@ -24,10 +24,8 @@ import (
|
||||||
"Yimaru-Backend/internal/services/user"
|
"Yimaru-Backend/internal/services/user"
|
||||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||||
customvalidator "Yimaru-Backend/internal/web_server/validator"
|
customvalidator "Yimaru-Backend/internal/web_server/validator"
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
|
@ -64,7 +62,6 @@ type App struct {
|
||||||
mongoLoggerSvc *zap.Logger
|
mongoLoggerSvc *zap.Logger
|
||||||
analyticsDB *dbgen.Queries
|
analyticsDB *dbgen.Queries
|
||||||
rbacSvc *rbacservice.Service
|
rbacSvc *rbacservice.Service
|
||||||
stopPurgeWorker context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(
|
func NewApp(
|
||||||
|
|
@ -111,13 +108,13 @@ func NewApp(
|
||||||
app.Static("/static", "./static")
|
app.Static("/static", "./static")
|
||||||
|
|
||||||
s := &App{
|
s := &App{
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
courseSvc: courseSvc,
|
courseSvc: courseSvc,
|
||||||
questionsSvc: questionsSvc,
|
questionsSvc: questionsSvc,
|
||||||
subscriptionsSvc: subscriptionsSvc,
|
subscriptionsSvc: subscriptionsSvc,
|
||||||
arifpaySvc: arifpaySvc,
|
arifpaySvc: arifpaySvc,
|
||||||
vimeoSvc: vimeoSvc,
|
vimeoSvc: vimeoSvc,
|
||||||
teamSvc: teamSvc,
|
teamSvc: teamSvc,
|
||||||
activityLogSvc: activityLogSvc,
|
activityLogSvc: activityLogSvc,
|
||||||
cloudConvertSvc: cloudConvertSvc,
|
cloudConvertSvc: cloudConvertSvc,
|
||||||
ratingSvc: ratingSvc,
|
ratingSvc: ratingSvc,
|
||||||
|
|
@ -146,69 +143,5 @@ func NewApp(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
a.startAccountDeletionPurgeWorker()
|
|
||||||
defer a.stopAccountDeletionPurgeWorker()
|
|
||||||
return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
|
return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startAccountDeletionPurgeWorker() {
|
|
||||||
if a.cfg == nil || !a.cfg.AccountDeletionPurgeEnabled {
|
|
||||||
a.logger.Info("account deletion purge worker disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := a.cfg.AccountDeletionPurgeInterval
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = time.Hour
|
|
||||||
}
|
|
||||||
|
|
||||||
batchSize := a.cfg.AccountDeletionPurgeBatchSize
|
|
||||||
if batchSize <= 0 {
|
|
||||||
batchSize = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
a.stopPurgeWorker = cancel
|
|
||||||
|
|
||||||
a.logger.Info(
|
|
||||||
"starting account deletion purge worker",
|
|
||||||
"interval", interval.String(),
|
|
||||||
"batch_size", batchSize,
|
|
||||||
)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
// Run once on startup so stale due rows are cleaned quickly.
|
|
||||||
a.runAccountDeletionPurgeOnce(ctx, batchSize)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
a.logger.Info("account deletion purge worker stopped")
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
a.runAccountDeletionPurgeOnce(ctx, batchSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) stopAccountDeletionPurgeWorker() {
|
|
||||||
if a.stopPurgeWorker != nil {
|
|
||||||
a.stopPurgeWorker()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32) {
|
|
||||||
deletedCount, err := a.userSvc.PurgeDueUserDeletions(ctx, batchSize)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Error("account deletion purge run failed", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if deletedCount > 0 {
|
|
||||||
a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,8 @@ func (h *Handler) CheckProfileCompleted(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
Message: "Profile completion status fetched successfully",
|
Message: "Profile completion status fetched successfully",
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"is_profile_completed": status.IsCompleted,
|
"is_profile_completed": status.IsCompleted,
|
||||||
"profile_completion_percentage": status.Percentage,
|
"profile_completion_percentage": status.Percentage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -572,99 +572,6 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
||||||
return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": mapped, "total": total}, nil)
|
return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": mapped, "total": total}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAccountDeletionRequests godoc
|
|
||||||
// @Summary List account deletion requests
|
|
||||||
// @Description Returns account deletion requests for admin panel tracking with filtering and pagination
|
|
||||||
// @Tags user
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param query query string false "Search in first_name, last_name, email, phone_number"
|
|
||||||
// @Param role query string false "Role filter"
|
|
||||||
// @Param status query string false "User status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)"
|
|
||||||
// @Param state query string false "Deletion state filter (PENDING, DUE, CANCELLED)"
|
|
||||||
// @Param requested_before query string false "Requested before (RFC3339)"
|
|
||||||
// @Param requested_after query string false "Requested after (RFC3339)"
|
|
||||||
// @Param scheduled_before query string false "Scheduled before (RFC3339)"
|
|
||||||
// @Param scheduled_after query string false "Scheduled after (RFC3339)"
|
|
||||||
// @Param page query int false "Page number (default 1)"
|
|
||||||
// @Param page_size query int false "Page size (default 10, max 100)"
|
|
||||||
// @Success 200 {object} response.APIResponse
|
|
||||||
// @Failure 400 {object} response.APIResponse
|
|
||||||
// @Failure 500 {object} response.APIResponse
|
|
||||||
// @Router /api/v1/admin/users/deletion-requests [get]
|
|
||||||
func (h *Handler) ListAccountDeletionRequests(c *fiber.Ctx) error {
|
|
||||||
parseRFC3339 := func(value string) (domain.ValidTime, error) {
|
|
||||||
if value == "" {
|
|
||||||
return domain.ValidTime{}, nil
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ValidTime{}, err
|
|
||||||
}
|
|
||||||
return domain.ValidTime{Value: parsed, Valid: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
requestedBefore, err := parseRFC3339(c.Query("requested_before"))
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid requested_before format, expected RFC3339")
|
|
||||||
}
|
|
||||||
requestedAfter, err := parseRFC3339(c.Query("requested_after"))
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid requested_after format, expected RFC3339")
|
|
||||||
}
|
|
||||||
scheduledBefore, err := parseRFC3339(c.Query("scheduled_before"))
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid scheduled_before format, expected RFC3339")
|
|
||||||
}
|
|
||||||
scheduledAfter, err := parseRFC3339(c.Query("scheduled_after"))
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid scheduled_after format, expected RFC3339")
|
|
||||||
}
|
|
||||||
|
|
||||||
page := int64(c.QueryInt("page", 1))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
pageSize := int64(c.QueryInt("page_size", 10))
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
if pageSize > 100 {
|
|
||||||
pageSize = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := domain.AccountDeletionRequestFilter{
|
|
||||||
Query: c.Query("query"),
|
|
||||||
Role: c.Query("role"),
|
|
||||||
Status: c.Query("status"),
|
|
||||||
State: c.Query("state"),
|
|
||||||
RequestedBefore: requestedBefore,
|
|
||||||
RequestedAfter: requestedAfter,
|
|
||||||
ScheduledBefore: scheduledBefore,
|
|
||||||
ScheduledAfter: scheduledAfter,
|
|
||||||
Page: page - 1,
|
|
||||||
PageSize: pageSize,
|
|
||||||
}
|
|
||||||
|
|
||||||
requests, total, err := h.userSvc.ListAccountDeletionRequests(c.Context(), filter)
|
|
||||||
if err != nil {
|
|
||||||
h.mongoLoggerSvc.Error("failed to list account deletion requests",
|
|
||||||
zap.Any("filter", filter),
|
|
||||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
||||||
zap.Error(err),
|
|
||||||
zap.Time("timestamp", time.Now()),
|
|
||||||
)
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to list account deletion requests: "+err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.WriteJSON(c, fiber.StatusOK, "Account deletion requests fetched successfully", map[string]interface{}{
|
|
||||||
"items": requests,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": pageSize,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserStatus godoc
|
// UpdateUserStatus godoc
|
||||||
// @Summary Update user status
|
// @Summary Update user status
|
||||||
// @Description Activates, deactivates, or suspends a user account
|
// @Description Activates, deactivates, or suspends a user account
|
||||||
|
|
@ -1807,14 +1714,9 @@ func (h *Handler) DeleteUser(c *fiber.Ctx) error {
|
||||||
return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil)
|
return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
defaultSelfDeleteGracePeriod = 15 * 24 * time.Hour
|
|
||||||
defaultDeletePurgeBatchSize = int32(100)
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeleteMyUserAccount godoc
|
// DeleteMyUserAccount godoc
|
||||||
// @Summary Request deletion of my account
|
// @Summary Delete my user account
|
||||||
// @Description Starts account deletion with grace period before permanent purge
|
// @Description Deletes the authenticated learner's own account
|
||||||
// @Tags user
|
// @Tags user
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} response.APIResponse
|
// @Success 200 {object} response.APIResponse
|
||||||
|
|
@ -1837,113 +1739,23 @@ func (h *Handler) DeleteMyUserAccount(c *fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint")
|
return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint")
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduledAt, err := h.userSvc.RequestUserDeletion(c.Context(), userID, defaultSelfDeleteGracePeriod)
|
if err := h.userSvc.DeleteUser(c.Context(), userID); err != nil {
|
||||||
if err != nil {
|
h.mongoLoggerSvc.Error("Failed to self-delete user account",
|
||||||
h.mongoLoggerSvc.Error("Failed to request self-deletion for user account",
|
|
||||||
zap.Int64("userID", userID),
|
zap.Int64("userID", userID),
|
||||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
zap.Time("timestamp", time.Now()),
|
zap.Time("timestamp", time.Now()),
|
||||||
)
|
)
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to request account deletion: "+err.Error())
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete account:"+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
actorRole := string(role)
|
actorRole := string(role)
|
||||||
ip := c.IP()
|
ip := c.IP()
|
||||||
ua := c.Get("User-Agent")
|
ua := c.Get("User-Agent")
|
||||||
meta, _ := json.Marshal(map[string]interface{}{
|
meta, _ := json.Marshal(map[string]interface{}{"deleted_user_id": userID, "self_delete": true})
|
||||||
"user_id": userID,
|
go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserDeleted, domain.ResourceUser, &userID, fmt.Sprintf("Self-deleted user account ID: %d", userID), meta, &ip, &ua)
|
||||||
"self_delete_requested": true,
|
|
||||||
"scheduled_purge_at": scheduledAt,
|
|
||||||
})
|
|
||||||
go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &userID, fmt.Sprintf("Self-delete requested for user account ID: %d", userID), meta, &ip, &ua)
|
|
||||||
|
|
||||||
return response.WriteJSON(c, fiber.StatusOK, "Account deletion requested successfully", map[string]interface{}{
|
return response.WriteJSON(c, fiber.StatusOK, "Account deleted successfully", nil, nil)
|
||||||
"grace_period_days": 15,
|
|
||||||
"scheduled_purge_at": scheduledAt,
|
|
||||||
"can_cancel_until_at": scheduledAt,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelMyUserAccountDeletion godoc
|
|
||||||
// @Summary Cancel my account deletion request
|
|
||||||
// @Description Cancels a pending self-deletion request during grace period
|
|
||||||
// @Tags user
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} response.APIResponse
|
|
||||||
// @Failure 401 {object} response.APIResponse
|
|
||||||
// @Failure 403 {object} response.APIResponse
|
|
||||||
// @Failure 500 {object} response.APIResponse
|
|
||||||
// @Security Bearer
|
|
||||||
// @Router /api/v1/user/me/deletion/cancel [post]
|
|
||||||
func (h *Handler) CancelMyUserAccountDeletion(c *fiber.Ctx) error {
|
|
||||||
userID, ok := c.Locals("user_id").(int64)
|
|
||||||
if !ok || userID <= 0 {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated user")
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := c.Locals("role").(domain.Role)
|
|
||||||
if !ok {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
|
|
||||||
}
|
|
||||||
if role != domain.RoleStudent {
|
|
||||||
return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.userSvc.CancelUserDeletion(c.Context(), userID); err != nil {
|
|
||||||
h.mongoLoggerSvc.Error("Failed to cancel self-deletion for user account",
|
|
||||||
zap.Int64("userID", userID),
|
|
||||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
||||||
zap.Error(err),
|
|
||||||
zap.Time("timestamp", time.Now()),
|
|
||||||
)
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to cancel account deletion: "+err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
actorRole := string(role)
|
|
||||||
ip := c.IP()
|
|
||||||
ua := c.Get("User-Agent")
|
|
||||||
meta, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"user_id": userID,
|
|
||||||
"self_delete_cancelled": true,
|
|
||||||
})
|
|
||||||
go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &userID, fmt.Sprintf("Self-delete cancelled for user account ID: %d", userID), meta, &ip, &ua)
|
|
||||||
|
|
||||||
return response.WriteJSON(c, fiber.StatusOK, "Account deletion cancelled successfully", nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurgeDueDeletedUsers godoc
|
|
||||||
// @Summary Purge due account deletions
|
|
||||||
// @Description Worker-safe purge for due self-deletion requests
|
|
||||||
// @Tags user
|
|
||||||
// @Produce json
|
|
||||||
// @Param limit query int false "Max users to purge in one run (default 100, max 1000)"
|
|
||||||
// @Success 200 {object} response.APIResponse
|
|
||||||
// @Failure 400 {object} response.APIResponse
|
|
||||||
// @Failure 500 {object} response.APIResponse
|
|
||||||
// @Security Bearer
|
|
||||||
// @Router /api/v1/internal/users/purge-due-deletions [post]
|
|
||||||
func (h *Handler) PurgeDueDeletedUsers(c *fiber.Ctx) error {
|
|
||||||
limit := int32(c.QueryInt("limit", int(defaultDeletePurgeBatchSize)))
|
|
||||||
if limit <= 0 {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "limit must be greater than zero")
|
|
||||||
}
|
|
||||||
|
|
||||||
deletedCount, err := h.userSvc.PurgeDueUserDeletions(c.Context(), limit)
|
|
||||||
if err != nil {
|
|
||||||
h.mongoLoggerSvc.Error("Failed to purge due user deletions",
|
|
||||||
zap.Int32("limit", limit),
|
|
||||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
||||||
zap.Error(err),
|
|
||||||
zap.Time("timestamp", time.Now()),
|
|
||||||
)
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to purge due user deletions: "+err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.WriteJSON(c, fiber.StatusOK, "Due user deletions purged successfully", map[string]interface{}{
|
|
||||||
"deleted_count": deletedCount,
|
|
||||||
"batch_limit": limit,
|
|
||||||
}, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserSuspendReq struct {
|
type UpdateUserSuspendReq struct {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
groupV1 := a.fiber.Group("/api/v1")
|
groupV1 := a.fiber.Group("/api/v1")
|
||||||
|
|
||||||
// Serve static files (profile pictures, etc.)
|
// Serve static files (profile pictures, etc.)
|
||||||
a.fiber.Static("/static", "./static")
|
a.fiber.Static("/static", "./static")
|
||||||
|
|
||||||
|
|
@ -219,7 +219,6 @@ func (a *App) initAppRoutes() {
|
||||||
// User Routes
|
// User Routes
|
||||||
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
|
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
|
||||||
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
|
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
|
||||||
groupV1.Get("/admin/users/deletion-requests", a.authMiddleware, a.RequirePermission("users.deletion_requests.list"), h.ListAccountDeletionRequests)
|
|
||||||
groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
|
groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
|
||||||
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
|
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
|
||||||
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
|
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
|
||||||
|
|
@ -235,8 +234,6 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/user/admin-profile", a.authMiddleware, a.RequirePermission("users.admin_profile"), h.AdminProfile)
|
groupV1.Get("/user/admin-profile", a.authMiddleware, a.RequirePermission("users.admin_profile"), h.AdminProfile)
|
||||||
groupV1.Get("/user/user-profile", a.authMiddleware, a.RequirePermission("users.user_profile"), h.GetUserProfile)
|
groupV1.Get("/user/user-profile", a.authMiddleware, a.RequirePermission("users.user_profile"), h.GetUserProfile)
|
||||||
groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount)
|
groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount)
|
||||||
groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion)
|
|
||||||
groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers)
|
|
||||||
groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID)
|
groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID)
|
||||||
groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
|
groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
|
||||||
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)
|
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user