Compare commits

...

9 Commits

Author SHA1 Message Date
05cb8715f9 seed data clearer API 2026-03-29 01:31:43 -07:00
36134f32a2 reseed feature adjustment 2026-03-27 03:29:33 -07:00
d4bf2e8642 permission fix 2026-03-27 02:53:42 -07:00
3aca2c438d foreign key fix 2026-03-27 02:49:20 -07:00
e6fe8ab7e3 reseed directory fix 2026-03-27 02:40:49 -07:00
4b46fd60dc reseed config fix 2026-03-27 02:36:20 -07:00
0cb58b35f8 new reseed endpoint 2026-03-27 02:29:30 -07:00
8f719c2a32 .mp3 upload fix 2026-03-25 06:29:23 -07:00
e689f34212 minio .env access adjustment 2026-03-25 06:19:20 -07:00
20 changed files with 409 additions and 23 deletions

View File

@ -460,7 +460,7 @@ VALUES
'Administrative staff managing day-to-day operations.',
'active',
TRUE,
'["users.manage", "courses.manage", "settings.manage"]'::jsonb,
'[*]'::jsonb,
CURRENT_TIMESTAMP
),
(

View File

@ -0,0 +1,2 @@
ALTER TABLE question_sets
DROP COLUMN IF EXISTS intro_video_url;

View File

@ -0,0 +1,2 @@
ALTER TABLE question_sets
ADD COLUMN intro_video_url TEXT;

View File

@ -44,7 +44,7 @@ WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id;
-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status,
SELECT id, title, description, persona, status, intro_video_url,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1

View File

@ -11,9 +11,10 @@ INSERT INTO question_sets (
passing_score,
shuffle_questions,
status,
sub_course_video_id
sub_course_video_id,
intro_video_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13)
RETURNING *;
-- name: GetQuestionSetByID :one
@ -59,9 +60,10 @@ SET
passing_score = COALESCE($6, passing_score),
shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status),
sub_course_video_id = COALESCE($9, sub_course_video_id),
intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP
WHERE id = $10;
WHERE id = $11;
-- name: ArchiveQuestionSet :exec
UPDATE question_sets

View File

@ -146,7 +146,7 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre
}
const GetSubCoursePracticesForLearningPath = `-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status,
SELECT id, title, description, persona, status, intro_video_url,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
@ -160,6 +160,7 @@ type GetSubCoursePracticesForLearningPathRow struct {
Description pgtype.Text `json:"description"`
Persona pgtype.Text `json:"persona"`
Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionCount int64 `json:"question_count"`
}
@ -178,6 +179,7 @@ func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, owne
&i.Description,
&i.Persona,
&i.Status,
&i.IntroVideoUrl,
&i.QuestionCount,
); err != nil {
return nil, err

View File

@ -173,6 +173,7 @@ type QuestionSet struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
}
type QuestionSetItem struct {

View File

@ -198,7 +198,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
}
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url
FROM question_sets qs
JOIN question_set_items qsi ON qsi.set_id = qs.id
WHERE qsi.question_id = $1
@ -231,6 +231,7 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
return nil, err
}

View File

@ -64,10 +64,11 @@ INSERT INTO question_sets (
passing_score,
shuffle_questions,
status,
sub_course_video_id
sub_course_video_id,
intro_video_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13)
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
`
type CreateQuestionSetParams struct {
@ -83,6 +84,7 @@ type CreateQuestionSetParams struct {
Column10 interface{} `json:"column_10"`
Column11 interface{} `json:"column_11"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
}
func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetParams) (QuestionSet, error) {
@ -99,6 +101,7 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
arg.Column10,
arg.Column11,
arg.SubCourseVideoID,
arg.IntroVideoUrl,
)
var i QuestionSet
err := row.Scan(
@ -118,6 +121,7 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
@ -133,7 +137,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error {
}
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND status = 'PUBLISHED'
@ -161,12 +165,13 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE owner_type = $1
AND owner_id = $2
@ -205,6 +210,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
return nil, err
}
@ -217,7 +223,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
}
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE id = $1
`
@ -242,12 +248,13 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE owner_type = $1
AND owner_id = $2
@ -286,6 +293,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
return nil, err
}
@ -300,7 +308,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
SELECT
COUNT(*) OVER () AS total_count,
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url
FROM question_sets qs
WHERE set_type = $1
AND status != 'ARCHIVED'
@ -333,6 +341,7 @@ type GetQuestionSetsByTypeRow struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
}
func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSetsByTypeParams) ([]GetQuestionSetsByTypeRow, error) {
@ -362,6 +371,7 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
return nil, err
}
@ -374,7 +384,7 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
}
const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
@ -404,6 +414,7 @@ func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
@ -507,9 +518,10 @@ SET
passing_score = COALESCE($6, passing_score),
shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status),
sub_course_video_id = COALESCE($9, sub_course_video_id),
intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP
WHERE id = $10
WHERE id = $11
`
type UpdateQuestionSetParams struct {
@ -521,6 +533,7 @@ type UpdateQuestionSetParams struct {
PassingScore pgtype.Int4 `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"`
}
@ -535,6 +548,7 @@ func (q *Queries) UpdateQuestionSet(ctx context.Context, arg UpdateQuestionSetPa
arg.PassingScore,
arg.ShuffleQuestions,
arg.Status,
arg.IntroVideoUrl,
arg.SubCourseVideoID,
arg.ID,
)

13
gen/db/raw_exec.go Normal file
View File

@ -0,0 +1,13 @@
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgconn"
)
// ExecRaw executes raw SQL against the underlying sqlc DB connection.
// Use this sparingly for operational tasks that are not modelled by sqlc queries.
func (q *Queries) ExecRaw(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
return q.db.Exec(ctx, sql, args...)
}

View File

@ -140,6 +140,9 @@ type Config struct {
AccountDeletionPurgeEnabled bool
AccountDeletionPurgeInterval time.Duration
AccountDeletionPurgeBatchSize int32
DBResetReseedEnabled bool
DBResetReseedToken string
DBSeedDir string
}
func NewConfig() (*Config, error) {
@ -491,10 +494,36 @@ func (c *Config) loadEnv() error {
c.MinIO.Enabled = true
}
c.MinIO.Endpoint = os.Getenv("MINIO_ENDPOINT")
c.MinIO.AccessKey = os.Getenv("MINIO_ACCESS_KEY")
c.MinIO.SecretKey = os.Getenv("MINIO_SECRET_KEY")
c.MinIO.Bucket = os.Getenv("MINIO_BUCKET")
if c.MinIO.Bucket == "" {
// New env var names (preferred)
// - MINIO_ROOT_USER
// - MINIO_ROOT_PASSWORD
// - MINIO_BUCKET_NAME
//
// Backward compatible fallbacks:
// - MINIO_ACCESS_KEY / MINIO_SECRET_KEY / MINIO_BUCKET
rootUser := os.Getenv("MINIO_ROOT_USER")
rootPass := os.Getenv("MINIO_ROOT_PASSWORD")
bucketName := os.Getenv("MINIO_BUCKET_NAME")
if rootUser != "" {
c.MinIO.AccessKey = rootUser
} else {
c.MinIO.AccessKey = os.Getenv("MINIO_ACCESS_KEY")
}
if rootPass != "" {
c.MinIO.SecretKey = rootPass
} else {
c.MinIO.SecretKey = os.Getenv("MINIO_SECRET_KEY")
}
if bucketName != "" {
c.MinIO.Bucket = bucketName
} else {
c.MinIO.Bucket = os.Getenv("MINIO_BUCKET")
}
if strings.TrimSpace(c.MinIO.Bucket) == "" {
c.MinIO.Bucket = "yimaru"
}
minioUseSSL := os.Getenv("MINIO_USE_SSL")
@ -536,6 +565,13 @@ func (c *Config) loadEnv() error {
}
}
// Dangerous DB reset+reseed endpoint configuration
// Enabled by default and does not require .env variables.
// Optional token can still be set programmatically if needed.
c.DBResetReseedEnabled = true
c.DBResetReseedToken = ""
c.DBSeedDir = "db/data"
return nil
}

View File

@ -130,6 +130,7 @@ type LearningPathPractice struct {
Description *string `json:"description,omitempty"`
Persona *string `json:"persona,omitempty"`
Status string `json:"status"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
QuestionCount int64 `json:"question_count"`
}

View File

@ -104,6 +104,7 @@ type QuestionSet struct {
ShuffleQuestions bool
Status string
SubCourseVideoID *int64
IntroVideoURL *string
UserPersonas []UserPersona
CreatedAt time.Time
UpdatedAt *time.Time
@ -170,6 +171,7 @@ type CreateQuestionSetInput struct {
ShuffleQuestions *bool
Status *string
SubCourseVideoID *int64
IntroVideoURL *string
}
// UserPersona represents a user acting as a persona in a practice session

View File

@ -154,6 +154,7 @@ func (s *Store) getSubCoursePracticesForPath(ctx context.Context, subCourseID in
Description: ptrString(row.Description),
Persona: ptrString(row.Persona),
Status: row.Status,
IntroVideoURL: ptrString(row.IntroVideoUrl),
QuestionCount: row.QuestionCount,
}
}

View File

@ -124,6 +124,7 @@ func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet {
ShuffleQuestions: qs.ShuffleQuestions,
Status: qs.Status,
SubCourseVideoID: fromPgInt8(qs.SubCourseVideoID),
IntroVideoURL: fromPgText(qs.IntroVideoUrl),
CreatedAt: qs.CreatedAt.Time,
UpdatedAt: timePtr(qs.UpdatedAt),
}
@ -542,6 +543,7 @@ func (s *Store) CreateQuestionSet(ctx context.Context, input domain.CreateQuesti
Column10: shuffleQuestions,
Column11: status,
SubCourseVideoID: toPgInt8(input.SubCourseVideoID),
IntroVideoUrl: toPgText(input.IntroVideoURL),
})
if err != nil {
return domain.QuestionSet{}, err
@ -603,6 +605,7 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit
ShuffleQuestions: r.ShuffleQuestions,
Status: r.Status,
SubCourseVideoID: fromPgInt8(r.SubCourseVideoID),
IntroVideoURL: fromPgText(r.IntroVideoUrl),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: timePtr(r.UpdatedAt),
}
@ -688,6 +691,7 @@ func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.Cr
PassingScore: toPgInt4(input.PassingScore),
ShuffleQuestions: shuffleQuestions,
Status: status,
IntroVideoUrl: toPgText(input.IntroVideoURL),
SubCourseVideoID: toPgInt8(input.SubCourseVideoID),
})
}

View File

@ -223,6 +223,9 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "rbac.permissions.list", Name: "List Permissions", Description: "List all permissions", GroupName: "RBAC"},
{Key: "rbac.permissions.groups", Name: "List Permission Groups", Description: "List permission groups", GroupName: "RBAC"},
{Key: "rbac.permissions.sync", Name: "Sync Permissions", Description: "Sync permissions from code", GroupName: "RBAC"},
// Internal operations
{Key: "internal.db.reset_reseed", Name: "Reset And Reseed Database", Description: "Dangerous operation: clears all data and re-seeds from SQL files", GroupName: "Internal Operations"},
}
// DefaultRolePermissions maps each system role to the permission keys it should
@ -306,6 +309,9 @@ var DefaultRolePermissions = map[string][]string{
"rbac.roles.list", "rbac.roles.get", "rbac.roles.create", "rbac.roles.update", "rbac.roles.delete",
"rbac.roles.set_permissions", "rbac.roles.get_permissions",
"rbac.permissions.list", "rbac.permissions.groups", "rbac.permissions.sync",
// Internal operations
"internal.db.reset_reseed",
},
"STUDENT": {

View File

@ -156,6 +156,28 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
"audio/aac": true, "audio/webm": true, "video/ogg": true, "video/webm": true,
"audio/x-wav": true, "audio/x-m4a": true, "audio/flac": true,
}
// DetectContentType can return "application/octet-stream" for some files (often via clients
// like Postman). If that happens, fall back to extension-based detection.
if contentType == "application/octet-stream" {
filenameLower := strings.ToLower(fileHeader.Filename)
lastDot := strings.LastIndex(filenameLower, ".")
ext := ""
if lastDot != -1 && lastDot+1 < len(filenameLower) {
ext = filenameLower[lastDot+1:]
}
extMap := map[string]string{
"mp3": "audio/mpeg",
"wav": "audio/wav",
"ogg": "audio/ogg",
"m4a": "audio/mp4",
"aac": "audio/aac",
"webm": "audio/webm",
"flac": "audio/flac",
}
if ct, ok := extMap[ext]; ok {
contentType = ct
}
}
if !allowedAudio[contentType] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",

View File

@ -0,0 +1,265 @@
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,
})
}

View File

@ -525,6 +525,7 @@ type createQuestionSetReq struct {
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
IntroVideoURL *string `json:"intro_video_url"`
}
type questionSetRes struct {
@ -541,6 +542,7 @@ type questionSetRes struct {
ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id,omitempty"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
CreatedAt string `json:"created_at"`
QuestionCount *int64 `json:"question_count,omitempty"`
}
@ -626,6 +628,7 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status,
SubCourseVideoID: req.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL,
}
set, err := h.questionsSvc.CreateQuestionSet(c.Context(), input)
@ -659,6 +662,7 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
IntroVideoURL: set.IntroVideoURL,
CreatedAt: set.CreatedAt.String(),
},
})
@ -711,6 +715,7 @@ func (h *Handler) GetSubCourseEntryAssessmentSet(c *fiber.Ctx) error {
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
IntroVideoURL: set.IntroVideoURL,
CreatedAt: set.CreatedAt.String(),
QuestionCount: &count,
},
@ -774,6 +779,7 @@ func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error {
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
IntroVideoURL: set.IntroVideoURL,
CreatedAt: set.CreatedAt.String(),
QuestionCount: &count,
},
@ -829,6 +835,7 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
ShuffleQuestions: s.ShuffleQuestions,
Status: s.Status,
SubCourseVideoID: s.SubCourseVideoID,
IntroVideoURL: s.IntroVideoURL,
CreatedAt: s.CreatedAt.String(),
})
}
@ -895,6 +902,7 @@ func (h *Handler) GetQuestionSetsByOwner(c *fiber.Ctx) error {
ShuffleQuestions: s.ShuffleQuestions,
Status: s.Status,
SubCourseVideoID: s.SubCourseVideoID,
IntroVideoURL: s.IntroVideoURL,
CreatedAt: s.CreatedAt.String(),
})
}
@ -915,6 +923,7 @@ type updateQuestionSetReq struct {
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
IntroVideoURL *string `json:"intro_video_url"`
}
// UpdateQuestionSet godoc
@ -962,6 +971,7 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status,
SubCourseVideoID: req.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL,
}
err = h.questionsSvc.UpdateQuestionSet(c.Context(), id, input)

View File

@ -244,6 +244,8 @@ func (a *App) initAppRoutes() {
groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount)
groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion)
groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers)
groupV1.Post("/internal/db/reset-reseed", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ResetAndReseedDatabase)
groupV1.Post("/internal/db/clear-course-management", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ClearCourseManagementData)
groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID)
groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)