12de5a230a
- 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>
737 lines
27 KiB
Go
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)),
|
|
})
|
|
}
|