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")
|
||||
ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid")
|
||||
|
||||
ErrMissingResendApiKey = errors.New("missing Resend Api key")
|
||||
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
|
||||
|
||||
ErrMissingResendApiKey = errors.New("missing Resend Api key")
|
||||
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
|
||||
)
|
||||
|
||||
type AFROSMSConfig struct {
|
||||
|
|
@ -90,9 +89,9 @@ type Config struct {
|
|||
GoogleOAuthClientID string
|
||||
GoogleOAuthClientSecret string
|
||||
GoogleOAuthRedirectURL string
|
||||
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
|
||||
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
|
||||
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
|
||||
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
|
||||
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
|
||||
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
|
||||
APP_VERSION string
|
||||
FIXER_API_KEY string
|
||||
FIXER_BASE_URL string
|
||||
|
|
@ -128,6 +127,9 @@ type Config struct {
|
|||
RedisAddr string
|
||||
KafkaBrokers []string
|
||||
FCMServiceAccountKey string
|
||||
AccountDeletionPurgeEnabled bool
|
||||
AccountDeletionPurgeInterval time.Duration
|
||||
AccountDeletionPurgeBatchSize int32
|
||||
}
|
||||
|
||||
func NewConfig() (*Config, error) {
|
||||
|
|
@ -473,6 +475,40 @@ func (c *Config) loadEnv() error {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,43 @@ type UserFilter struct {
|
|||
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 {
|
||||
Email string `json:"email"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ type UserStore interface {
|
|||
createdBefore, createdAfter *time.Time,
|
||||
limit, offset int32,
|
||||
) ([]domain.User, int64, error)
|
||||
ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error)
|
||||
GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
||||
GetUserSummary(ctx context.Context) (domain.UserSummary, error)
|
||||
SearchUserByNameOrPhone(
|
||||
|
|
@ -66,6 +67,9 @@ type UserStore interface {
|
|||
) ([]domain.User, error)
|
||||
UpdateUser(ctx context.Context, req domain.UpdateUserReq) 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)
|
||||
GetUserByEmailPhone(
|
||||
ctx context.Context,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -536,6 +538,172 @@ func (s *Store) GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
|||
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
|
||||
func (s *Store) SearchUserByNameOrPhone(
|
||||
ctx context.Context,
|
||||
|
|
@ -669,6 +837,90 @@ func (s *Store) DeleteUser(ctx context.Context, userID int64) error {
|
|||
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
|
||||
func (s *Store) CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error) {
|
||||
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_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_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.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"},
|
||||
|
|
@ -251,7 +254,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// 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",
|
||||
|
||||
// Admin management
|
||||
|
|
@ -326,7 +329,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// 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.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
|
|
@ -374,7 +377,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// 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.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
|
|
@ -416,7 +419,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"question_set_personas.list",
|
||||
|
||||
// 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",
|
||||
|
||||
// Notifications (own)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,18 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error {
|
|||
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(
|
||||
ctx context.Context,
|
||||
filter domain.UserFilter,
|
||||
|
|
@ -95,6 +107,10 @@ func (s *Service) GetUserSummary(ctx context.Context) (domain.UserSummary, error
|
|||
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 {
|
||||
return s.userStore.UpdateUserStatus(ctx, req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import (
|
|||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/config"
|
||||
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/assessment"
|
||||
"Yimaru-Backend/internal/services/authentication"
|
||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||
"Yimaru-Backend/internal/services/course_management"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"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/subscriptions"
|
||||
"Yimaru-Backend/internal/services/team"
|
||||
|
|
@ -24,8 +24,10 @@ import (
|
|||
"Yimaru-Backend/internal/services/user"
|
||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||
customvalidator "Yimaru-Backend/internal/web_server/validator"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ type App struct {
|
|||
mongoLoggerSvc *zap.Logger
|
||||
analyticsDB *dbgen.Queries
|
||||
rbacSvc *rbacservice.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
}
|
||||
|
||||
func NewApp(
|
||||
|
|
@ -108,13 +111,13 @@ func NewApp(
|
|||
app.Static("/static", "./static")
|
||||
|
||||
s := &App{
|
||||
assessmentSvc: assessmentSvc,
|
||||
courseSvc: courseSvc,
|
||||
questionsSvc: questionsSvc,
|
||||
subscriptionsSvc: subscriptionsSvc,
|
||||
arifpaySvc: arifpaySvc,
|
||||
vimeoSvc: vimeoSvc,
|
||||
teamSvc: teamSvc,
|
||||
assessmentSvc: assessmentSvc,
|
||||
courseSvc: courseSvc,
|
||||
questionsSvc: questionsSvc,
|
||||
subscriptionsSvc: subscriptionsSvc,
|
||||
arifpaySvc: arifpaySvc,
|
||||
vimeoSvc: vimeoSvc,
|
||||
teamSvc: teamSvc,
|
||||
activityLogSvc: activityLogSvc,
|
||||
cloudConvertSvc: cloudConvertSvc,
|
||||
ratingSvc: ratingSvc,
|
||||
|
|
@ -143,5 +146,69 @@ func NewApp(
|
|||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
a.startAccountDeletionPurgeWorker()
|
||||
defer a.stopAccountDeletionPurgeWorker()
|
||||
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{
|
||||
Message: "Profile completion status fetched successfully",
|
||||
Data: map[string]interface{}{
|
||||
"is_profile_completed": status.IsCompleted,
|
||||
"profile_completion_percentage": status.Percentage,
|
||||
"is_profile_completed": status.IsCompleted,
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// @Summary Update user status
|
||||
// @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)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSelfDeleteGracePeriod = 15 * 24 * time.Hour
|
||||
defaultDeletePurgeBatchSize = int32(100)
|
||||
)
|
||||
|
||||
// DeleteMyUserAccount godoc
|
||||
// @Summary Delete my user account
|
||||
// @Description Deletes the authenticated learner's own account
|
||||
// @Summary Request deletion of my account
|
||||
// @Description Starts account deletion with grace period before permanent purge
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @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")
|
||||
}
|
||||
|
||||
if err := h.userSvc.DeleteUser(c.Context(), userID); err != nil {
|
||||
h.mongoLoggerSvc.Error("Failed to self-delete user account",
|
||||
scheduledAt, err := h.userSvc.RequestUserDeletion(c.Context(), userID, defaultSelfDeleteGracePeriod)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("Failed to request 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 delete account:"+err.Error())
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to request account deletion: "+err.Error())
|
||||
}
|
||||
|
||||
actorRole := string(role)
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{"deleted_user_id": userID, "self_delete": true})
|
||||
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)
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"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 {
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ func (a *App) initAppRoutes() {
|
|||
// User Routes
|
||||
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("/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.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
|
||||
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/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.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.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
|
||||
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user