266 lines
8.2 KiB
Go
266 lines
8.2 KiB
Go
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 Dangerous operation: clears and reseeds only course_categories, courses, and sub_courses from seed 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(),
|
|
})
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, tableName := range tableNames {
|
|
if _, ok := statements[tableName]; !ok {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Missing required seed statement",
|
|
Error: fmt.Sprintf("could not find INSERT INTO %s in seed files", tableName),
|
|
})
|
|
}
|
|
}
|
|
|
|
var sqlBuilder strings.Builder
|
|
sqlBuilder.WriteString("BEGIN;\n")
|
|
sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n")
|
|
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(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Course management hierarchy reset and reseed completed successfully",
|
|
Data: map[string]interface{}{
|
|
"seed_dir": seedDir,
|
|
"tables": tableNames,
|
|
"sources": statementSource,
|
|
},
|
|
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,
|
|
})
|
|
}
|