seed data clearer API

This commit is contained in:
Yared Yemane 2026-03-29 01:31:43 -07:00
parent 36134f32a2
commit 05cb8715f9
15 changed files with 133 additions and 18 deletions

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,
)

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

@ -16,6 +16,10 @@ 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)
@ -191,3 +195,71 @@ func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
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

@ -245,6 +245,7 @@ func (a *App) initAppRoutes() {
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)