package handlers import ( "Yimaru-Backend/internal/domain" "crypto/subtle" "fmt" "os" "path/filepath" "regexp" "strings" "github.com/gofiber/fiber/v2" ) type resetAndReseedReq struct { Confirm string `json:"confirm"` } type clearCourseManagementReq struct { Confirm string `json:"confirm"` } func extractInsertStatement(sqlContent string, tableName string) (string, bool) { pattern := fmt.Sprintf(`(?is)INSERT\s+INTO\s+%s\b.*?;`, regexp.QuoteMeta(tableName)) re := regexp.MustCompile(pattern) statement := strings.TrimSpace(re.FindString(sqlContent)) if statement == "" { return "", false } return statement, true } 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 Truncates course_categories, courses, and sub_courses. If seed SQL contains INSERTs for those tables (e.g. 007_course_management_seed.sql), they are replayed; otherwise tables are left empty after truncate. // @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(), }) } seedCandidates := []string{ filepath.Join(seedDir, "007_course_management_seed.sql"), filepath.Join(seedDir, "001_initial_seed_data.sql"), } tableNames := []string{"course_categories", "courses", "sub_courses"} statements := map[string]string{} statementSource := map[string]string{} for _, file := range seedCandidates { contentBytes, readErr := os.ReadFile(file) if readErr != nil { continue } content := string(contentBytes) for _, tableName := range tableNames { if _, exists := statements[tableName]; exists { continue } if stmt, ok := extractInsertStatement(content, tableName); ok { statements[tableName] = stmt statementSource[tableName] = file } } } missing := 0 for _, tableName := range tableNames { if _, ok := statements[tableName]; !ok { missing++ } } if missing != 0 && missing != len(tableNames) { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Incomplete course seed SQL", Error: "seed files must define INSERT for all of course_categories, courses, and sub_courses, or none of them (truncate-only)", }) } var sqlBuilder strings.Builder sqlBuilder.WriteString("BEGIN;\n") sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n") if missing == 0 { for _, tableName := range tableNames { sqlBuilder.WriteString("\n-- ") sqlBuilder.WriteString(tableName) sqlBuilder.WriteString(" from ") sqlBuilder.WriteString(statementSource[tableName]) sqlBuilder.WriteString("\n") sqlBuilder.WriteString(statements[tableName]) 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(), }) } msg := "Course management hierarchy reset and reseed completed successfully" if missing == len(tableNames) { msg = "Course management hierarchy truncated successfully (no INSERT seed configured; tables empty)" } return c.JSON(domain.Response{ Message: msg, Data: map[string]interface{}{ "seed_dir": seedDir, "tables": tableNames, "sources": statementSource, "reseeded": missing == 0, "truncate_only": missing == len(tableNames), }, Success: true, StatusCode: fiber.StatusOK, }) } // ClearCourseManagementData godoc // @Summary Clear course management hierarchy data only // @Description Truncates course_categories, courses, and sub_courses (same scope as reset-reseed) without re-inserting seed SQL. // @Tags internal // @Accept json // @Produce json // @Param X-Seed-Reset-Token header string false "Optional token when DB_RESET_RESEED_TOKEN is set" // @Param body body clearCourseManagementReq 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/clear-course-management [post] func (h *Handler) ClearCourseManagementData(c *fiber.Ctx) error { if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "Operation is disabled", Error: "internal course management maintenance is disabled", }) } var req clearCourseManagementReq 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) != "CLEAR_COURSE_MANAGEMENT" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Confirmation required", Error: `set confirm to "CLEAR_COURSE_MANAGEMENT"`, }) } 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", }) } } tableNames := []string{"course_categories", "courses", "sub_courses"} sql := `BEGIN; TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE; COMMIT;` if _, err := h.analyticsDB.ExecRaw(c.Context(), sql); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to clear course management data", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Course management hierarchy cleared successfully (no re-seed)", Data: map[string]interface{}{ "tables": tableNames, }, Success: true, StatusCode: fiber.StatusOK, }) }