Files
honeyDueAPI/internal/admin/handlers/settings_handler.go
T
Trey T 12de5a230a
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
i18n: backend-localized lookups, suggestions, and static data (10 languages)
- suggestion_service: fix scorer (stringList unmarshal accepts scalar|array;
  anchor scoring on base universal score so bool matches no longer tie); add
  localizeReasons for human-readable, Accept-Language-localized match reasons
- lookup_i18n: localize lookup display names, home-profile options, document
  types/categories via internal/i18n
- static_data_handler: per-locale seeded-data response (display_name, home
  profile options, document types/categories) with per-locale cache + ETag
- settings_handler: invalidate per-locale seeded-data cache on lookup change
  instead of pre-warming a single non-localized blob
- cache_service: per-locale seeded-data keys + ETag
- DTOs: add DisplayName fields (task/residence/contractor)
- translations: add suggestion.reason.* and lookup.* keys across all 10 langs
- cmd/api: extract startup helpers + tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:54:54 -05:00

737 lines
27 KiB
Go

package handlers
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/services"
)
// AdminSettingsHandler handles system settings management
type AdminSettingsHandler struct {
db *gorm.DB
cache *services.CacheService
}
// NewAdminSettingsHandler creates a new handler. The cache may be nil; the
// handler falls through to direct DB reads in that case.
func NewAdminSettingsHandler(db *gorm.DB, cache *services.CacheService) *AdminSettingsHandler {
return &AdminSettingsHandler{db: db, cache: cache}
}
// SettingsResponse represents the settings response
type SettingsResponse struct {
EnableLimitations bool `json:"enable_limitations"`
EnableMonitoring bool `json:"enable_monitoring"`
TrialEnabled bool `json:"trial_enabled"`
TrialDurationDays int `json:"trial_duration_days"`
}
// GetSettings handles GET /api/admin/settings.
//
// Reads through Redis (30-min TTL) before hitting Postgres so the same
// row that's checked on every authed request and every monitoring poll
// stays hot. Cache miss / first boot creates and caches the default row.
func (h *AdminSettingsHandler) GetSettings(c echo.Context) error {
ctx := c.Request().Context()
// Try cache first.
if h.cache != nil {
var cached models.SubscriptionSettings
if err := h.cache.GetCachedSubscriptionSettings(ctx, &cached); err == nil {
return c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: cached.EnableLimitations,
EnableMonitoring: cached.EnableMonitoring,
TrialEnabled: cached.TrialEnabled,
TrialDurationDays: cached.TrialDurationDays,
})
}
}
var settings models.SubscriptionSettings
if err := h.db.WithContext(ctx).First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create default settings
settings = models.SubscriptionSettings{
ID: 1,
EnableLimitations: false,
EnableMonitoring: true,
TrialEnabled: true,
TrialDurationDays: 14,
}
if err := h.db.WithContext(ctx).Create(&settings).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create default settings"})
}
} else {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch settings"})
}
}
if h.cache != nil {
_ = h.cache.CacheSubscriptionSettings(ctx, &settings)
}
return c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
EnableMonitoring: settings.EnableMonitoring,
TrialEnabled: settings.TrialEnabled,
TrialDurationDays: settings.TrialDurationDays,
})
}
// UpdateSettingsRequest represents the update request
type UpdateSettingsRequest struct {
EnableLimitations *bool `json:"enable_limitations"`
EnableMonitoring *bool `json:"enable_monitoring"`
TrialEnabled *bool `json:"trial_enabled"`
TrialDurationDays *int `json:"trial_duration_days"`
}
// UpdateSettings handles PUT /api/admin/settings
func (h *AdminSettingsHandler) UpdateSettings(c echo.Context) error {
var req UpdateSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
var settings models.SubscriptionSettings
if err := h.db.WithContext(c.Request().Context()).First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
settings = models.SubscriptionSettings{
ID: 1,
EnableMonitoring: true,
TrialEnabled: true,
TrialDurationDays: 14,
}
} else {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch settings"})
}
}
if req.EnableLimitations != nil {
settings.EnableLimitations = *req.EnableLimitations
}
if req.EnableMonitoring != nil {
settings.EnableMonitoring = *req.EnableMonitoring
}
if req.TrialEnabled != nil {
settings.TrialEnabled = *req.TrialEnabled
}
if req.TrialDurationDays != nil {
settings.TrialDurationDays = *req.TrialDurationDays
}
if err := h.db.WithContext(c.Request().Context()).Save(&settings).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update settings"})
}
// Invalidate the cache so all pods pick up the new value on their
// next read (instead of waiting for the 30-min TTL).
if h.cache != nil {
_ = h.cache.InvalidateSubscriptionSettings(c.Request().Context())
}
return c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
EnableMonitoring: settings.EnableMonitoring,
TrialEnabled: settings.TrialEnabled,
TrialDurationDays: settings.TrialDurationDays,
})
}
// SeedLookups handles POST /api/admin/settings/seed-lookups
// Seeds both lookup tables AND task templates, then caches all lookups in Redis
func (h *AdminSettingsHandler) SeedLookups(c echo.Context) error {
// First seed lookup tables
if err := h.runSeedFile("001_lookups.sql"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed lookups"})
}
// Then seed task templates
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed task templates"})
}
// Cache all lookups in Redis
cached, cacheErr := h.cacheAllLookups(c.Request().Context())
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache lookups in Redis, but seed was successful")
}
response := map[string]interface{}{
"message": "Lookup data and task templates seeded successfully",
"redis_cached": cached,
}
return c.JSON(http.StatusOK, response)
}
// cacheAllLookups fetches all lookup data from the database and caches it in Redis
func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error) {
cache := services.GetCache()
if cache == nil {
return false, fmt.Errorf("Redis cache not available")
}
// Fetch and cache task categories
var categories []models.TaskCategory
if err := h.db.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
return false, fmt.Errorf("failed to fetch categories: %w", err)
}
if err := cache.CacheCategories(ctx, categories); err != nil {
return false, fmt.Errorf("failed to cache categories: %w", err)
}
log.Debug().Int("count", len(categories)).Msg("Cached task categories")
// Fetch and cache task priorities
var priorities []models.TaskPriority
if err := h.db.Order("display_order ASC, level ASC").Find(&priorities).Error; err != nil {
return false, fmt.Errorf("failed to fetch priorities: %w", err)
}
if err := cache.CachePriorities(ctx, priorities); err != nil {
return false, fmt.Errorf("failed to cache priorities: %w", err)
}
log.Debug().Int("count", len(priorities)).Msg("Cached task priorities")
// Fetch and cache task frequencies
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
return false, fmt.Errorf("failed to fetch frequencies: %w", err)
}
if err := cache.CacheFrequencies(ctx, frequencies); err != nil {
return false, fmt.Errorf("failed to cache frequencies: %w", err)
}
log.Debug().Int("count", len(frequencies)).Msg("Cached task frequencies")
// Fetch and cache residence types
var residenceTypes []models.ResidenceType
if err := h.db.Order("name ASC").Find(&residenceTypes).Error; err != nil {
return false, fmt.Errorf("failed to fetch residence types: %w", err)
}
if err := cache.CacheResidenceTypes(ctx, residenceTypes); err != nil {
return false, fmt.Errorf("failed to cache residence types: %w", err)
}
log.Debug().Int("count", len(residenceTypes)).Msg("Cached residence types")
// Fetch and cache contractor specialties
var specialties []models.ContractorSpecialty
if err := h.db.Order("display_order ASC, name ASC").Find(&specialties).Error; err != nil {
return false, fmt.Errorf("failed to fetch specialties: %w", err)
}
if err := cache.CacheSpecialties(ctx, specialties); err != nil {
return false, fmt.Errorf("failed to cache specialties: %w", err)
}
log.Debug().Int("count", len(specialties)).Msg("Cached contractor specialties")
// Fetch and cache task templates (only active ones)
var taskTemplates []models.TaskTemplate
if err := h.db.Preload("Category").Preload("Frequency").
Where("is_active = ?", true).
Order("display_order ASC, title ASC").
Find(&taskTemplates).Error; err != nil {
return false, fmt.Errorf("failed to fetch task templates: %w", err)
}
if err := cache.CacheTaskTemplates(ctx, taskTemplates); err != nil {
return false, fmt.Errorf("failed to cache task templates: %w", err)
}
log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates")
// Invalidate the unified seeded-data cache for every locale. The combined
// response is localized (lookup display_name + home-profile options) and is
// rebuilt per-locale on demand by the static_data handler, so the correct
// action after a lookup change is to clear all language variants rather than
// pre-warm a single (non-localized) blob.
if err := cache.InvalidateSeededData(ctx); err != nil {
return false, fmt.Errorf("failed to invalidate seeded data: %w", err)
}
log.Debug().Msg("Invalidated per-locale seeded data cache")
log.Info().Msg("All lookup data cached in Redis successfully")
return true, nil
}
// SeedTestData handles POST /api/admin/settings/seed-test-data
func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error {
if err := h.runSeedFile("002_test_data.sql"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed test data"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Test data seeded successfully"})
}
// SeedTaskTemplates handles POST /api/admin/settings/seed-task-templates
func (h *AdminSettingsHandler) SeedTaskTemplates(c echo.Context) error {
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed task templates"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Task templates seeded successfully"})
}
// runSeedFile executes a seed SQL file
func (h *AdminSettingsHandler) runSeedFile(filename string) error {
// Check multiple possible locations
possiblePaths := []string{
filepath.Join("seeds", filename),
filepath.Join("./seeds", filename),
filepath.Join("/app/seeds", filename),
}
var sqlContent []byte
var err error
for _, path := range possiblePaths {
sqlContent, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return err
}
// Get the underlying *sql.DB to execute raw SQL without prepared statements
sqlDB, err := h.db.DB()
if err != nil {
return err
}
// Split SQL into individual statements and execute each one
// This is needed because GORM/PostgreSQL prepared statements don't support multiple commands
statements := splitSQLStatements(string(sqlContent))
for i, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
// Use the raw sql.DB to avoid GORM's prepared statement handling
if _, err := sqlDB.Exec(stmt); err != nil {
// Include statement number and first 100 chars for debugging
preview := stmt
if len(preview) > 100 {
preview = preview[:100] + "..."
}
return fmt.Errorf("statement %d failed: %v\nStatement: %s", i+1, err, preview)
}
}
return nil
}
// splitSQLStatements splits SQL content into individual statements
func splitSQLStatements(sql string) []string {
var statements []string
var current strings.Builder
inString := false
stringChar := byte(0)
for i := 0; i < len(sql); i++ {
c := sql[i]
// Handle string literals (don't split on semicolons inside strings)
if (c == '\'' || c == '"') && (i == 0 || sql[i-1] != '\\') {
if !inString {
inString = true
stringChar = c
} else if c == stringChar {
// Check for escaped quote ('')
if c == '\'' && i+1 < len(sql) && sql[i+1] == '\'' {
current.WriteByte(c)
i++
current.WriteByte(sql[i])
continue
}
inString = false
}
}
// Split on semicolon if not in string
if c == ';' && !inString {
current.WriteByte(c)
stmt := strings.TrimSpace(current.String())
if stmt != "" && !isCommentOnly(stmt) {
statements = append(statements, stmt)
}
current.Reset()
continue
}
current.WriteByte(c)
}
// Don't forget the last statement if it doesn't end with semicolon
if stmt := strings.TrimSpace(current.String()); stmt != "" && !isCommentOnly(stmt) {
statements = append(statements, stmt)
}
return statements
}
// isCommentOnly checks if the statement is only comments
func isCommentOnly(stmt string) bool {
lines := strings.Split(stmt, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "--") {
return false
}
}
return true
}
// ClearAllDataResponse represents the response after clearing data
type ClearAllDataResponse struct {
Message string `json:"message"`
UsersDeleted int64 `json:"users_deleted"`
PreservedUsers int64 `json:"preserved_users"`
}
// ClearStuckJobsResponse represents the response after clearing stuck Redis jobs
type ClearStuckJobsResponse struct {
Message string `json:"message"`
KeysDeleted int `json:"keys_deleted"`
DeletedKeys []string `json:"deleted_keys"`
}
// ClearStuckJobs handles POST /api/admin/settings/clear-stuck-jobs
// This clears stuck/failed asynq worker jobs from Redis
func (h *AdminSettingsHandler) ClearStuckJobs(c echo.Context) error {
cache := services.GetCache()
if cache == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]interface{}{"error": "Redis cache not available"})
}
ctx := c.Request().Context()
client := cache.Client()
var deletedKeys []string
// Patterns for asynq job keys that can get stuck
patterns := []string{
"asynq:{default}:retry", // Retry queue
"asynq:{default}:archived", // Archived/dead jobs
"asynq:{default}:t:*", // Individual task metadata
}
for _, pattern := range patterns {
if strings.Contains(pattern, "*") {
// Use SCAN for patterns with wildcards
iter := client.Scan(ctx, 0, pattern, 0).Iterator()
for iter.Next(ctx) {
key := iter.Val()
if err := client.Del(ctx, key).Err(); err != nil {
log.Warn().Err(err).Str("key", key).Msg("Failed to delete key")
continue
}
deletedKeys = append(deletedKeys, key)
}
if err := iter.Err(); err != nil {
log.Warn().Err(err).Str("pattern", pattern).Msg("Error scanning keys")
}
} else {
// Direct key deletion
exists, err := client.Exists(ctx, pattern).Result()
if err != nil {
log.Warn().Err(err).Str("key", pattern).Msg("Failed to check key existence")
continue
}
if exists > 0 {
if err := client.Del(ctx, pattern).Err(); err != nil {
log.Warn().Err(err).Str("key", pattern).Msg("Failed to delete key")
continue
}
deletedKeys = append(deletedKeys, pattern)
}
}
}
log.Info().Int("count", len(deletedKeys)).Strs("keys", deletedKeys).Msg("Cleared stuck Redis jobs")
return c.JSON(http.StatusOK, ClearStuckJobsResponse{
Message: "Stuck jobs cleared successfully",
KeysDeleted: len(deletedKeys),
DeletedKeys: deletedKeys,
})
}
// ClearAllData handles POST /api/admin/settings/clear-all-data
// This clears all data except super admin accounts and lookup tables
func (h *AdminSettingsHandler) ClearAllData(c echo.Context) error {
// Start a transaction
tx := h.db.Begin()
if tx.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to start transaction"})
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Get IDs of users to preserve (superusers)
var preservedUserIDs []uint
if err := tx.Model(&models.User{}).
Where("is_superuser = ?", true).
Pluck("id", &preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to get superuser IDs"})
}
// Count users that will be deleted
var usersToDelete int64
if err := tx.Model(&models.User{}).
Where("is_superuser = ?", false).
Count(&usersToDelete).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to count users"})
}
// Delete in order to respect foreign key constraints
// Order matters: delete from child tables first
// 1. Delete task completion images
if err := tx.Exec("DELETE FROM task_taskcompletionimage").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete task completion images"})
}
// 2. Delete task completions
if err := tx.Exec("DELETE FROM task_taskcompletion").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete task completions"})
}
// 3. Delete notifications (must be before tasks since notifications have task_id FK)
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM notifications_notification WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notifications"})
}
} else {
if err := tx.Exec("DELETE FROM notifications_notification").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notifications"})
}
}
// 4. Delete document images
if err := tx.Exec("DELETE FROM task_documentimage").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete document images"})
}
// 5. Delete documents
if err := tx.Exec("DELETE FROM task_document").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete documents"})
}
// 6. Delete task reminder logs (must be before tasks since reminder logs have task_id FK)
// Check if table exists first to avoid aborting transaction
var tableExists bool
tx.Raw("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'task_reminderlog')").Scan(&tableExists)
if tableExists {
if err := tx.Exec("DELETE FROM task_reminderlog").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete task reminder logs"})
}
}
// 7. Delete tasks (must be before contractors since tasks reference contractors)
if err := tx.Exec("DELETE FROM task_task").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete tasks"})
}
// 8. Delete contractor specialties (many-to-many)
if err := tx.Exec("DELETE FROM task_contractor_specialties").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete contractor specialties"})
}
// 9. Delete contractors
if err := tx.Exec("DELETE FROM task_contractor").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete contractors"})
}
// 10. Delete residence_users (many-to-many for shared residences)
if err := tx.Exec("DELETE FROM residence_residence_users").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residence users"})
}
// 11. Delete residence share codes (must be before residences since share codes have residence_id FK)
if err := tx.Exec("DELETE FROM residence_residencesharecode").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residence share codes"})
}
// 12. Delete residences
if err := tx.Exec("DELETE FROM residence_residence").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residences"})
}
// 13. Delete push devices for non-superusers (both APNS and GCM)
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM push_notifications_apnsdevice WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete APNS devices"})
}
if err := tx.Exec("DELETE FROM push_notifications_gcmdevice WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete GCM devices"})
}
} else {
if err := tx.Exec("DELETE FROM push_notifications_apnsdevice").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete APNS devices"})
}
if err := tx.Exec("DELETE FROM push_notifications_gcmdevice").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete GCM devices"})
}
}
// 14. Delete notification preferences for non-superusers
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM notifications_notificationpreference WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notification preferences"})
}
} else {
if err := tx.Exec("DELETE FROM notifications_notificationpreference").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notification preferences"})
}
}
// 15. Delete user subscriptions for non-superusers
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM subscription_usersubscription WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete subscriptions"})
}
} else {
if err := tx.Exec("DELETE FROM subscription_usersubscription").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete subscriptions"})
}
}
// 16. Delete password reset codes for non-superusers
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_passwordresetcode WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes"})
}
} else {
if err := tx.Exec("DELETE FROM user_passwordresetcode").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes"})
}
}
// 17. Delete confirmation codes for non-superusers
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_confirmationcode WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes"})
}
} else {
if err := tx.Exec("DELETE FROM user_confirmationcode").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes"})
}
}
// 18. Delete auth tokens for non-superusers
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_authtoken WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete auth tokens"})
}
} else {
if err := tx.Exec("DELETE FROM user_authtoken").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete auth tokens"})
}
}
// 19. Delete Apple social auth for non-superusers (Sign in with Apple)
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_applesocialauth WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth"})
}
} else {
if err := tx.Exec("DELETE FROM user_applesocialauth").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth"})
}
}
// 20. Delete user profiles for non-superusers
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_userprofile WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user profiles"})
}
} else {
if err := tx.Exec("DELETE FROM user_userprofile").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user profiles"})
}
}
// 21. Delete onboarding emails for non-superusers
var onboardingEmailsExists bool
tx.Raw("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'onboarding_emails')").Scan(&onboardingEmailsExists)
if onboardingEmailsExists {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM onboarding_emails WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete onboarding emails"})
}
} else {
if err := tx.Exec("DELETE FROM onboarding_emails").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete onboarding emails"})
}
}
}
// 22. Finally, delete non-superuser users
// Always filter by is_superuser to be safe, regardless of preservedUserIDs
if err := tx.Exec("DELETE FROM auth_user WHERE is_superuser = false").Error; err != nil {
tx.Rollback()
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete users"})
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to commit transaction"})
}
return c.JSON(http.StatusOK, ClearAllDataResponse{
Message: "All data cleared successfully (superadmin accounts preserved)",
UsersDeleted: usersToDelete,
PreservedUsers: int64(len(preservedUserIDs)),
})
}