roadmap_handlers.go (740 LOC) → roadmap_handlers.go, roadmap_item_handlers.go, roadmap_import_handlers.go academy/store.go (683 LOC) → store_courses.go, store_enrollments.go cmd/server/main.go (681 LOC) → internal/app/app.go (Run+buildRouter) + internal/app/routes.go (registerXxx helpers) main.go reduced to 7 LOC thin entrypoint calling app.Run() All files under 410 LOC. Zero behavior changes, same package declarations. go vet passes on all directly-split packages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
350 lines
9.0 KiB
Go
350 lines
9.0 KiB
Go
package academy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Store handles academy data persistence
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewStore creates a new academy store
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Course CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateCourse creates a new course
|
|
func (s *Store) CreateCourse(ctx context.Context, course *Course) error {
|
|
course.ID = uuid.New()
|
|
course.CreatedAt = time.Now().UTC()
|
|
course.UpdatedAt = course.CreatedAt
|
|
if !course.IsActive {
|
|
course.IsActive = true
|
|
}
|
|
|
|
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO academy_courses (
|
|
id, tenant_id, title, description, category,
|
|
duration_minutes, required_for_roles, is_active,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8,
|
|
$9, $10
|
|
)
|
|
`,
|
|
course.ID, course.TenantID, course.Title, course.Description, string(course.Category),
|
|
course.DurationMinutes, requiredForRoles, course.IsActive,
|
|
course.CreatedAt, course.UpdatedAt,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetCourse retrieves a course by ID
|
|
func (s *Store) GetCourse(ctx context.Context, id uuid.UUID) (*Course, error) {
|
|
var course Course
|
|
var category string
|
|
var requiredForRoles []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, tenant_id, title, description, category,
|
|
duration_minutes, required_for_roles, is_active,
|
|
created_at, updated_at
|
|
FROM academy_courses WHERE id = $1
|
|
`, id).Scan(
|
|
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
|
|
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
|
|
&course.CreatedAt, &course.UpdatedAt,
|
|
)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
course.Category = CourseCategory(category)
|
|
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
|
|
if course.RequiredForRoles == nil {
|
|
course.RequiredForRoles = []string{}
|
|
}
|
|
|
|
// Load lessons for this course
|
|
lessons, err := s.ListLessons(ctx, course.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
course.Lessons = lessons
|
|
|
|
return &course, nil
|
|
}
|
|
|
|
// ListCourses lists courses for a tenant with optional filters
|
|
func (s *Store) ListCourses(ctx context.Context, tenantID uuid.UUID, filters *CourseFilters) ([]Course, int, error) {
|
|
// Count query
|
|
countQuery := "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1"
|
|
countArgs := []interface{}{tenantID}
|
|
countArgIdx := 2
|
|
|
|
// List query
|
|
query := `
|
|
SELECT
|
|
id, tenant_id, title, description, category,
|
|
duration_minutes, required_for_roles, is_active,
|
|
created_at, updated_at
|
|
FROM academy_courses WHERE tenant_id = $1`
|
|
|
|
args := []interface{}{tenantID}
|
|
argIdx := 2
|
|
|
|
if filters != nil {
|
|
if filters.Category != "" {
|
|
query += fmt.Sprintf(" AND category = $%d", argIdx)
|
|
args = append(args, string(filters.Category))
|
|
argIdx++
|
|
|
|
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
|
|
countArgs = append(countArgs, string(filters.Category))
|
|
countArgIdx++
|
|
}
|
|
if filters.IsActive != nil {
|
|
query += fmt.Sprintf(" AND is_active = $%d", argIdx)
|
|
args = append(args, *filters.IsActive)
|
|
argIdx++
|
|
|
|
countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx)
|
|
countArgs = append(countArgs, *filters.IsActive)
|
|
countArgIdx++
|
|
}
|
|
if filters.Search != "" {
|
|
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
|
|
args = append(args, "%"+filters.Search+"%")
|
|
argIdx++
|
|
|
|
countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", countArgIdx, countArgIdx)
|
|
countArgs = append(countArgs, "%"+filters.Search+"%")
|
|
countArgIdx++
|
|
}
|
|
}
|
|
|
|
// Get total count
|
|
var total int
|
|
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
if filters != nil && filters.Limit > 0 {
|
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
args = append(args, filters.Limit)
|
|
argIdx++
|
|
|
|
if filters.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
args = append(args, filters.Offset)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var courses []Course
|
|
for rows.Next() {
|
|
var course Course
|
|
var category string
|
|
var requiredForRoles []byte
|
|
|
|
err := rows.Scan(
|
|
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
|
|
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
|
|
&course.CreatedAt, &course.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
course.Category = CourseCategory(category)
|
|
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
|
|
if course.RequiredForRoles == nil {
|
|
course.RequiredForRoles = []string{}
|
|
}
|
|
|
|
courses = append(courses, course)
|
|
}
|
|
|
|
if courses == nil {
|
|
courses = []Course{}
|
|
}
|
|
|
|
return courses, total, nil
|
|
}
|
|
|
|
// UpdateCourse updates a course
|
|
func (s *Store) UpdateCourse(ctx context.Context, course *Course) error {
|
|
course.UpdatedAt = time.Now().UTC()
|
|
|
|
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE academy_courses SET
|
|
title = $2, description = $3, category = $4,
|
|
duration_minutes = $5, required_for_roles = $6, is_active = $7,
|
|
updated_at = $8
|
|
WHERE id = $1
|
|
`,
|
|
course.ID, course.Title, course.Description, string(course.Category),
|
|
course.DurationMinutes, requiredForRoles, course.IsActive,
|
|
course.UpdatedAt,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// DeleteCourse deletes a course and its related data (via CASCADE)
|
|
func (s *Store) DeleteCourse(ctx context.Context, id uuid.UUID) error {
|
|
_, err := s.pool.Exec(ctx, "DELETE FROM academy_courses WHERE id = $1", id)
|
|
return err
|
|
}
|
|
|
|
// ============================================================================
|
|
// Lesson Operations
|
|
// ============================================================================
|
|
|
|
// CreateLesson creates a new lesson
|
|
func (s *Store) CreateLesson(ctx context.Context, lesson *Lesson) error {
|
|
lesson.ID = uuid.New()
|
|
|
|
quizQuestions, _ := json.Marshal(lesson.QuizQuestions)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO academy_lessons (
|
|
id, course_id, title, description, lesson_type,
|
|
content_url, duration_minutes, order_index, quiz_questions
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $9
|
|
)
|
|
`,
|
|
lesson.ID, lesson.CourseID, lesson.Title, lesson.Description, string(lesson.LessonType),
|
|
lesson.ContentURL, lesson.DurationMinutes, lesson.OrderIndex, quizQuestions,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// ListLessons lists lessons for a course ordered by order_index
|
|
func (s *Store) ListLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, course_id, title, description, lesson_type,
|
|
content_url, duration_minutes, order_index, quiz_questions
|
|
FROM academy_lessons WHERE course_id = $1
|
|
ORDER BY order_index ASC
|
|
`, courseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var lessons []Lesson
|
|
for rows.Next() {
|
|
var lesson Lesson
|
|
var lessonType string
|
|
var quizQuestions []byte
|
|
|
|
err := rows.Scan(
|
|
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
|
|
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lesson.LessonType = LessonType(lessonType)
|
|
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
|
|
if lesson.QuizQuestions == nil {
|
|
lesson.QuizQuestions = []QuizQuestion{}
|
|
}
|
|
|
|
lessons = append(lessons, lesson)
|
|
}
|
|
|
|
if lessons == nil {
|
|
lessons = []Lesson{}
|
|
}
|
|
|
|
return lessons, nil
|
|
}
|
|
|
|
// GetLesson retrieves a single lesson by ID
|
|
func (s *Store) GetLesson(ctx context.Context, id uuid.UUID) (*Lesson, error) {
|
|
var lesson Lesson
|
|
var lessonType string
|
|
var quizQuestions []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, course_id, title, description, lesson_type,
|
|
content_url, duration_minutes, order_index, quiz_questions
|
|
FROM academy_lessons WHERE id = $1
|
|
`, id).Scan(
|
|
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
|
|
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
|
|
)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lesson.LessonType = LessonType(lessonType)
|
|
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
|
|
if lesson.QuizQuestions == nil {
|
|
lesson.QuizQuestions = []QuizQuestion{}
|
|
}
|
|
|
|
return &lesson, nil
|
|
}
|
|
|
|
// UpdateLesson updates a lesson's content, title, and quiz questions
|
|
func (s *Store) UpdateLesson(ctx context.Context, lesson *Lesson) error {
|
|
quizQuestions, _ := json.Marshal(lesson.QuizQuestions)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE academy_lessons SET
|
|
title = $2, description = $3, content_url = $4,
|
|
duration_minutes = $5, quiz_questions = $6
|
|
WHERE id = $1
|
|
`,
|
|
lesson.ID, lesson.Title, lesson.Description,
|
|
lesson.ContentURL, lesson.DurationMinutes, quizQuestions,
|
|
)
|
|
|
|
return err
|
|
}
|