package handlers import ( "Yimaru-Backend/internal/domain" "crypto/subtle" "fmt" "os" "path/filepath" "sort" "strings" "github.com/gofiber/fiber/v2" ) type resetAndReseedReq struct { Confirm string `json:"confirm"` } func resolveSeedDir(seedDir string) (string, error) { cleanSeedDir := strings.TrimSpace(seedDir) if cleanSeedDir == "" { cleanSeedDir = "db/data" } // If absolute, use directly. if filepath.IsAbs(cleanSeedDir) { info, err := os.Stat(cleanSeedDir) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("seed dir is not a directory: %s", cleanSeedDir) } return cleanSeedDir, nil } candidates := make([]string, 0, 5) // 1) Relative to current working directory. candidates = append(candidates, filepath.Clean(cleanSeedDir)) // 2) Relative to executable directory (and parents). if exePath, err := os.Executable(); err == nil { exeDir := filepath.Dir(exePath) candidates = append(candidates, filepath.Join(exeDir, cleanSeedDir), filepath.Join(exeDir, "..", cleanSeedDir), filepath.Join(exeDir, "..", "..", cleanSeedDir), ) } for _, candidate := range candidates { info, err := os.Stat(candidate) if err != nil { continue } if info.IsDir() { return candidate, nil } } return "", fmt.Errorf("seed directory not found (tried: %s)", strings.Join(candidates, ", ")) } // ResetAndReseedDatabase godoc // @Summary Reset and reseed database // @Description Dangerous operation: truncates all public tables (except schema_migrations) and reseeds from db/data SQL files. // @Tags internal // @Accept json // @Produce json // @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN" // @Param body body resetAndReseedReq true "Confirmation payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 403 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/internal/db/reset-reseed [post] func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error { if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "Operation is disabled", Error: "DB_RESET_RESEED_ENABLED must be set to true", }) } var req resetAndReseedReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if strings.TrimSpace(req.Confirm) != "RESET_AND_RESEED" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Confirmation required", Error: `set confirm to "RESET_AND_RESEED"`, }) } expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken) if expectedToken != "" { providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token")) if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "Invalid reset token", Error: "missing or invalid X-Seed-Reset-Token", }) } } seedDir, err := resolveSeedDir(h.Cfg.DBSeedDir) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to resolve seed directory", Error: err.Error(), }) } seedFiles, err := filepath.Glob(filepath.Join(seedDir, "*.sql")) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to resolve seed files", Error: err.Error(), }) } if len(seedFiles) == 0 { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "No seed files found", Error: fmt.Sprintf("no .sql files found in %s", seedDir), }) } sort.Strings(seedFiles) var sqlBuilder strings.Builder sqlBuilder.WriteString("BEGIN;\n") // Ensure we never attempt to insert NULL set_id into question_set_items. // Some deployments may already have the buggy function from migration 000024, // so we patch it at runtime to make reseed safe. sqlBuilder.WriteString(` CREATE OR REPLACE FUNCTION clone_default_initial_assessment_items(target_set_id BIGINT) RETURNS VOID AS $$ DECLARE template_set_id BIGINT; BEGIN IF target_set_id IS NULL THEN RETURN; END IF; SELECT id INTO template_set_id FROM question_sets WHERE set_type = 'INITIAL_ASSESSMENT' AND owner_type = 'STANDALONE' AND status = 'PUBLISHED' ORDER BY created_at DESC LIMIT 1; IF template_set_id IS NULL THEN RETURN; END IF; INSERT INTO question_set_items (set_id, question_id, display_order) SELECT target_set_id, qsi.question_id, qsi.display_order FROM question_set_items qsi WHERE qsi.set_id = template_set_id AND NOT EXISTS ( SELECT 1 FROM question_set_items existing WHERE existing.set_id = target_set_id AND existing.question_id = qsi.question_id ); END; $$ LANGUAGE plpgsql; `) sqlBuilder.WriteString(` DO $$ DECLARE truncate_stmt text; BEGIN SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ') || ' RESTART IDENTITY CASCADE' INTO truncate_stmt FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'schema_migrations'; IF truncate_stmt IS NOT NULL THEN EXECUTE truncate_stmt; END IF; END $$; `) for _, file := range seedFiles { content, readErr := os.ReadFile(file) if readErr != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to read seed file", Error: fmt.Sprintf("%s: %v", file, readErr), }) } sqlBuilder.WriteString("\n-- seed file: ") sqlBuilder.WriteString(file) sqlBuilder.WriteString("\n") sqlBuilder.Write(content) sqlBuilder.WriteString("\n") } sqlBuilder.WriteString("COMMIT;") if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to reset and reseed database", Error: err.Error(), }) } // Ensure permission definitions and default assignments are in sync after reseed. if err := h.rbacSvc.SeedPermissions(c.Context()); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Database reseeded but RBAC permission sync failed", Error: err.Error(), }) } if err := h.rbacSvc.SeedDefaultRolePermissions(c.Context()); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Database reseeded but RBAC role default sync failed", Error: err.Error(), }) } if err := h.rbacSvc.Reload(c.Context()); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Database reseeded but RBAC cache reload failed", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Database reset and reseed completed successfully", Data: map[string]interface{}{ "seed_dir": seedDir, "seed_files": seedFiles, "seeded_count": len(seedFiles), }, Success: true, StatusCode: fiber.StatusOK, }) }