account deletion API

This commit is contained in:
Yared Yemane 2026-03-11 06:26:30 -07:00
parent 4adbfbbeaa
commit 180e63e975
15 changed files with 7115 additions and 58 deletions

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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
} }

View File

@ -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"`

View File

@ -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,

View File

@ -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{

View File

@ -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)

View File

@ -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)
} }

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)