Compare commits
3 Commits
d4b25a1167
...
180e63e975
| Author | SHA1 | Date | |
|---|---|---|---|
| 180e63e975 | |||
| 4adbfbbeaa | |||
|
|
a3ff133be8 |
29
db/data/008_account_deletion_requests_seed.sql
Normal file
29
db/data/008_account_deletion_requests_seed.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 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;
|
||||||
7
db/migrations/000027_two_phase_account_deletion.down.sql
Normal file
7
db/migrations/000027_two_phase_account_deletion.down.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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;
|
||||||
12
db/migrations/000027_two_phase_account_deletion.up.sql
Normal file
12
db/migrations/000027_two_phase_account_deletion.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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,9 +25,8 @@ 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 {
|
||||||
|
|
@ -90,9 +89,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
|
||||||
|
|
@ -128,6 +127,9 @@ 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) {
|
||||||
|
|
@ -473,6 +475,40 @@ 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,6 +133,43 @@ 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,6 +57,7 @@ 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(
|
||||||
|
|
@ -66,6 +67,9 @@ 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,6 +8,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
@ -536,6 +538,172 @@ 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,
|
||||||
|
|
@ -669,6 +837,90 @@ 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,7 +105,10 @@ 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: "Delete Own Account", Description: "Delete own user account", GroupName: "Users"},
|
{Key: "users.delete_self", Name: "Request Own Account Deletion", Description: "Request own account deletion with grace period", 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"},
|
||||||
|
|
@ -251,7 +254,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.search",
|
"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.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
|
||||||
|
|
@ -326,7 +329,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.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
"users.update_self", "users.delete_self", "users.cancel_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",
|
||||||
|
|
@ -374,7 +377,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.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
"users.update_self", "users.delete_self", "users.cancel_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",
|
||||||
|
|
@ -416,7 +419,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.profile_completed",
|
"users.list", "users.get", "users.search", "users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed",
|
||||||
"users.upload_profile_picture", "users.user_profile",
|
"users.upload_profile_picture", "users.user_profile",
|
||||||
|
|
||||||
// Notifications (own)
|
// Notifications (own)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,18 @@ 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,
|
||||||
|
|
@ -95,6 +107,10 @@ 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,8 +24,10 @@ 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"
|
||||||
|
|
||||||
|
|
@ -62,6 +64,7 @@ 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(
|
||||||
|
|
@ -108,13 +111,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,
|
||||||
|
|
@ -143,5 +146,69 @@ 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,6 +572,99 @@ 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
|
||||||
|
|
@ -1714,9 +1807,14 @@ 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 Delete my user account
|
// @Summary Request deletion of my account
|
||||||
// @Description Deletes the authenticated learner's own account
|
// @Description Starts account deletion with grace period before permanent purge
|
||||||
// @Tags user
|
// @Tags user
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} response.APIResponse
|
// @Success 200 {object} response.APIResponse
|
||||||
|
|
@ -1739,23 +1837,113 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.userSvc.DeleteUser(c.Context(), userID); err != nil {
|
scheduledAt, err := h.userSvc.RequestUserDeletion(c.Context(), userID, defaultSelfDeleteGracePeriod)
|
||||||
h.mongoLoggerSvc.Error("Failed to self-delete user account",
|
if err != nil {
|
||||||
|
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 delete account:"+err.Error())
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to request account deletion: "+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{}{"deleted_user_id": userID, "self_delete": true})
|
meta, _ := json.Marshal(map[string]interface{}{
|
||||||
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)
|
"user_id": userID,
|
||||||
|
"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 deleted successfully", nil, nil)
|
return response.WriteJSON(c, fiber.StatusOK, "Account deletion requested successfully", map[string]interface{}{
|
||||||
|
"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,6 +219,7 @@ 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)
|
||||||
|
|
@ -234,6 +235,8 @@ 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