refactor(go): split roadmap_handlers, academy/store, extract cmd/server/main to internal/app
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>
This commit is contained in:
343
ai-compliance-sdk/internal/academy/store_enrollments.go
Normal file
343
ai-compliance-sdk/internal/academy/store_enrollments.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package academy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateEnrollment creates a new enrollment
|
||||
func (s *Store) CreateEnrollment(ctx context.Context, enrollment *Enrollment) error {
|
||||
enrollment.ID = uuid.New()
|
||||
enrollment.CreatedAt = time.Now().UTC()
|
||||
enrollment.UpdatedAt = enrollment.CreatedAt
|
||||
if enrollment.Status == "" {
|
||||
enrollment.Status = EnrollmentStatusNotStarted
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO academy_enrollments (
|
||||
id, tenant_id, course_id, user_id, user_name, user_email,
|
||||
status, progress_percent, current_lesson_index,
|
||||
started_at, completed_at, deadline,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9,
|
||||
$10, $11, $12,
|
||||
$13, $14
|
||||
)
|
||||
`,
|
||||
enrollment.ID, enrollment.TenantID, enrollment.CourseID, enrollment.UserID, enrollment.UserName, enrollment.UserEmail,
|
||||
string(enrollment.Status), enrollment.ProgressPercent, enrollment.CurrentLessonIndex,
|
||||
enrollment.StartedAt, enrollment.CompletedAt, enrollment.Deadline,
|
||||
enrollment.CreatedAt, enrollment.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetEnrollment retrieves an enrollment by ID
|
||||
func (s *Store) GetEnrollment(ctx context.Context, id uuid.UUID) (*Enrollment, error) {
|
||||
var enrollment Enrollment
|
||||
var status string
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, tenant_id, course_id, user_id, user_name, user_email,
|
||||
status, progress_percent, current_lesson_index,
|
||||
started_at, completed_at, deadline,
|
||||
created_at, updated_at
|
||||
FROM academy_enrollments WHERE id = $1
|
||||
`, id).Scan(
|
||||
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
|
||||
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
|
||||
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
|
||||
&enrollment.CreatedAt, &enrollment.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enrollment.Status = EnrollmentStatus(status)
|
||||
return &enrollment, nil
|
||||
}
|
||||
|
||||
// ListEnrollments lists enrollments for a tenant with optional filters
|
||||
func (s *Store) ListEnrollments(ctx context.Context, tenantID uuid.UUID, filters *EnrollmentFilters) ([]Enrollment, int, error) {
|
||||
// Count query
|
||||
countQuery := "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1"
|
||||
countArgs := []interface{}{tenantID}
|
||||
countArgIdx := 2
|
||||
|
||||
// List query
|
||||
query := `
|
||||
SELECT
|
||||
id, tenant_id, course_id, user_id, user_name, user_email,
|
||||
status, progress_percent, current_lesson_index,
|
||||
started_at, completed_at, deadline,
|
||||
created_at, updated_at
|
||||
FROM academy_enrollments WHERE tenant_id = $1`
|
||||
|
||||
args := []interface{}{tenantID}
|
||||
argIdx := 2
|
||||
|
||||
if filters != nil {
|
||||
if filters.CourseID != nil {
|
||||
query += fmt.Sprintf(" AND course_id = $%d", argIdx)
|
||||
args = append(args, *filters.CourseID)
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND course_id = $%d", countArgIdx)
|
||||
countArgs = append(countArgs, *filters.CourseID)
|
||||
countArgIdx++
|
||||
}
|
||||
if filters.UserID != nil {
|
||||
query += fmt.Sprintf(" AND user_id = $%d", argIdx)
|
||||
args = append(args, *filters.UserID)
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx)
|
||||
countArgs = append(countArgs, *filters.UserID)
|
||||
countArgIdx++
|
||||
}
|
||||
if filters.Status != "" {
|
||||
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, string(filters.Status))
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
|
||||
countArgs = append(countArgs, string(filters.Status))
|
||||
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 enrollments []Enrollment
|
||||
for rows.Next() {
|
||||
var enrollment Enrollment
|
||||
var status string
|
||||
|
||||
err := rows.Scan(
|
||||
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
|
||||
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
|
||||
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
|
||||
&enrollment.CreatedAt, &enrollment.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
enrollment.Status = EnrollmentStatus(status)
|
||||
enrollments = append(enrollments, enrollment)
|
||||
}
|
||||
|
||||
if enrollments == nil {
|
||||
enrollments = []Enrollment{}
|
||||
}
|
||||
|
||||
return enrollments, total, nil
|
||||
}
|
||||
|
||||
// UpdateEnrollmentProgress updates the progress for an enrollment
|
||||
func (s *Store) UpdateEnrollmentProgress(ctx context.Context, id uuid.UUID, progress int, currentLesson int) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE academy_enrollments SET
|
||||
progress_percent = $2,
|
||||
current_lesson_index = $3,
|
||||
status = CASE
|
||||
WHEN $2 >= 100 THEN 'completed'
|
||||
WHEN $2 > 0 THEN 'in_progress'
|
||||
ELSE status
|
||||
END,
|
||||
started_at = CASE
|
||||
WHEN started_at IS NULL AND $2 > 0 THEN $4
|
||||
ELSE started_at
|
||||
END,
|
||||
completed_at = CASE
|
||||
WHEN $2 >= 100 THEN $4
|
||||
ELSE completed_at
|
||||
END,
|
||||
updated_at = $4
|
||||
WHERE id = $1
|
||||
`, id, progress, currentLesson, now)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteEnrollment marks an enrollment as completed
|
||||
func (s *Store) CompleteEnrollment(ctx context.Context, id uuid.UUID) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE academy_enrollments SET
|
||||
status = 'completed',
|
||||
progress_percent = 100,
|
||||
completed_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $1
|
||||
`, id, now)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Operations
|
||||
// ============================================================================
|
||||
|
||||
// GetCertificate retrieves a certificate by ID
|
||||
func (s *Store) GetCertificate(ctx context.Context, id uuid.UUID) (*Certificate, error) {
|
||||
var cert Certificate
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, enrollment_id, user_name, course_title,
|
||||
issued_at, valid_until, pdf_url
|
||||
FROM academy_certificates WHERE id = $1
|
||||
`, id).Scan(
|
||||
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
|
||||
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// GetCertificateByEnrollment retrieves a certificate by enrollment ID
|
||||
func (s *Store) GetCertificateByEnrollment(ctx context.Context, enrollmentID uuid.UUID) (*Certificate, error) {
|
||||
var cert Certificate
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, enrollment_id, user_name, course_title,
|
||||
issued_at, valid_until, pdf_url
|
||||
FROM academy_certificates WHERE enrollment_id = $1
|
||||
`, enrollmentID).Scan(
|
||||
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
|
||||
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// CreateCertificate creates a new certificate
|
||||
func (s *Store) CreateCertificate(ctx context.Context, cert *Certificate) error {
|
||||
cert.ID = uuid.New()
|
||||
cert.IssuedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO academy_certificates (
|
||||
id, enrollment_id, user_name, course_title,
|
||||
issued_at, valid_until, pdf_url
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7
|
||||
)
|
||||
`,
|
||||
cert.ID, cert.EnrollmentID, cert.UserName, cert.CourseTitle,
|
||||
cert.IssuedAt, cert.ValidUntil, cert.PDFURL,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns aggregated academy statistics for a tenant
|
||||
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*AcademyStatistics, error) {
|
||||
stats := &AcademyStatistics{}
|
||||
|
||||
// Total active courses
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1 AND is_active = true",
|
||||
tenantID).Scan(&stats.TotalCourses)
|
||||
|
||||
// Total enrollments
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1",
|
||||
tenantID).Scan(&stats.TotalEnrollments)
|
||||
|
||||
// Completion rate
|
||||
if stats.TotalEnrollments > 0 {
|
||||
var completed int
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed'",
|
||||
tenantID).Scan(&completed)
|
||||
stats.CompletionRate = float64(completed) / float64(stats.TotalEnrollments) * 100
|
||||
}
|
||||
|
||||
// Overdue count (past deadline, not completed)
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM academy_enrollments
|
||||
WHERE tenant_id = $1
|
||||
AND status NOT IN ('completed', 'expired')
|
||||
AND deadline IS NOT NULL
|
||||
AND deadline < NOW()`,
|
||||
tenantID).Scan(&stats.OverdueCount)
|
||||
|
||||
// Average completion days
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0)
|
||||
FROM academy_enrollments
|
||||
WHERE tenant_id = $1
|
||||
AND status = 'completed'
|
||||
AND started_at IS NOT NULL
|
||||
AND completed_at IS NOT NULL`,
|
||||
tenantID).Scan(&stats.AvgCompletionDays)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
Reference in New Issue
Block a user