From bad34de75284ebc48996e2bda2f6cbbcc4be1d26 Mon Sep 17 00:00:00 2001 From: Wayne Ngo Date: Sun, 3 May 2026 00:46:21 -0700 Subject: [PATCH] invite and attempt backend services and handlers --- cmd/server/main.go | 20 + internal/core/models/models.go | 6 + .../repositories/postgres/assessment_repo.go | 9 + .../repositories/postgres/attempt_repo.go | 124 +++++++ .../repositories/postgres/submission_repo.go | 43 +++ internal/core/repositories/repositories.go | 8 + internal/core/services/attempt_service.go | 348 ++++++++++++++++++ internal/core/services/invite_service.go | 207 +++++++++++ internal/handlers/attempts.go | 157 ++++++-- internal/handlers/invites.go | 153 +++++++- 10 files changed, 1041 insertions(+), 34 deletions(-) create mode 100644 internal/core/services/attempt_service.go create mode 100644 internal/core/services/invite_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index a5c24b3..1e78c5a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,6 +8,8 @@ import ( "github.com/gin-gonic/gin" _ "github.com/jackc/pgx/v5/stdlib" + "CodeSCE/internal/core/repositories/postgres" + "CodeSCE/internal/core/services" "CodeSCE/internal/db" "CodeSCE/internal/handlers" ) @@ -32,6 +34,24 @@ func main() { log.Fatalf("failed to initialize schema: %v", err) } + assessmentRepo := postgres.NewAssessmentRepository(database) + questionRepo := postgres.NewQuestionRepository(database) + testCaseRepo := postgres.NewTestCaseRepository(database) + inviteRepo := postgres.NewInviteRepository(database) + attemptRepo := postgres.NewAttemptRepository(database) + answerRepo := postgres.NewAnswerRepository(database) + submissionRepo := postgres.NewSubmissionRepository(database) + + handlers.SetAssessmentService(services.NewAssessmentService(assessmentRepo, questionRepo, testCaseRepo)) + handlers.SetInviteService(services.NewInviteService(inviteRepo, attemptRepo, assessmentRepo)) + handlers.SetAttemptService(services.NewAttemptService( + attemptRepo, + answerRepo, + questionRepo, + submissionRepo, + assessmentRepo, + )) + r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ diff --git a/internal/core/models/models.go b/internal/core/models/models.go index e4b83b2..293f638 100644 --- a/internal/core/models/models.go +++ b/internal/core/models/models.go @@ -56,6 +56,12 @@ type AssessmentAttempt struct { UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } +// AttemptWithCandidateEmail is an attempt row with the invitee email (list/admin views). +type AttemptWithCandidateEmail struct { + AssessmentAttempt + CandidateEmail string `json:"candidateEmail"` +} + type Answer struct { ID int64 `json:"id" db:"id"` AttemptID int64 `json:"attemptId" db:"attempt_id"` diff --git a/internal/core/repositories/postgres/assessment_repo.go b/internal/core/repositories/postgres/assessment_repo.go index 4c07fce..bbdce15 100644 --- a/internal/core/repositories/postgres/assessment_repo.go +++ b/internal/core/repositories/postgres/assessment_repo.go @@ -87,3 +87,12 @@ func (r *assessmentRepository) GetByID(ctx context.Context, id int64) (*models.A } return &t, nil } + +func (r *assessmentRepository) CountQuestionsByAssessmentID(ctx context.Context, assessmentID int64) (int64, error) { + const query = `SELECT COUNT(*) FROM questions WHERE assessment_template_id = $1` + var n int64 + if err := r.db.QueryRowContext(ctx, query, assessmentID).Scan(&n); err != nil { + return 0, fmt.Errorf("count questions by assessment: %w", err) + } + return n, nil +} diff --git a/internal/core/repositories/postgres/attempt_repo.go b/internal/core/repositories/postgres/attempt_repo.go index 938d95f..a39aea9 100644 --- a/internal/core/repositories/postgres/attempt_repo.go +++ b/internal/core/repositories/postgres/attempt_repo.go @@ -66,6 +66,90 @@ func (r *attemptRepository) GetByID(ctx context.Context, id int64) (*models.Asse return &attempt, nil } +func (r *attemptRepository) GetByInviteID(ctx context.Context, inviteID int64) (*models.AssessmentAttempt, error) { + const query = ` + SELECT id, invite_id, started_at, completed_at, status, total_score, created_at, updated_at + FROM assessment_attempts + WHERE invite_id = $1 + LIMIT 1 + ` + var attempt models.AssessmentAttempt + err := r.db.QueryRowContext(ctx, query, inviteID).Scan( + &attempt.ID, + &attempt.InviteID, + &attempt.StartedAt, + &attempt.CompletedAt, + &attempt.Status, + &attempt.TotalScore, + &attempt.CreatedAt, + &attempt.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, repositories.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get assessment attempt by invite id: %w", err) + } + return &attempt, nil +} + +func (r *attemptRepository) GetAssessmentTemplateIDForAttempt(ctx context.Context, attemptID int64) (int64, error) { + const query = ` + SELECT i.assessment_template_id + FROM assessment_attempts a + JOIN assessment_invites i ON i.id = a.invite_id + WHERE a.id = $1 + ` + var assessmentID int64 + err := r.db.QueryRowContext(ctx, query, attemptID).Scan(&assessmentID) + if errors.Is(err, sql.ErrNoRows) { + return 0, repositories.ErrNotFound + } + if err != nil { + return 0, fmt.Errorf("get assessment template id for attempt: %w", err) + } + return assessmentID, nil +} + +func (r *attemptRepository) FinalizeAttempt(ctx context.Context, attemptID int64, totalScore int, completedAt time.Time, attemptStatus string) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("finalize attempt begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.ExecContext(ctx, ` + UPDATE assessment_attempts + SET total_score = $2, completed_at = $3, status = $4, updated_at = NOW() + WHERE id = $1 AND status = 'in_progress' + `, attemptID, totalScore, completedAt, attemptStatus) + if err != nil { + return fmt.Errorf("finalize attempt update attempt: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("finalize attempt rows affected: %w", err) + } + if n == 0 { + return repositories.ErrNotFound + } + + _, err = tx.ExecContext(ctx, ` + UPDATE assessment_invites i + SET status = 'completed', updated_at = NOW() + FROM assessment_attempts a + WHERE i.id = a.invite_id AND a.id = $1 + `, attemptID) + if err != nil { + return fmt.Errorf("finalize attempt update invite: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("finalize attempt commit: %w", err) + } + return nil +} + func (r *attemptRepository) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentAttempt, error) { // Attempts link to assessments via assessment_invites.assessment_template_id. const query = ` @@ -105,6 +189,46 @@ func (r *attemptRepository) ListByAssessmentID(ctx context.Context, assessmentID return attempts, nil } +func (r *attemptRepository) ListWithCandidateEmailByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AttemptWithCandidateEmail, error) { + const query = ` + SELECT a.id, a.invite_id, a.started_at, a.completed_at, a.status, + a.total_score, a.created_at, a.updated_at, + i.candidate_email + FROM assessment_attempts a + JOIN assessment_invites i ON i.id = a.invite_id + WHERE i.assessment_template_id = $1 + ORDER BY a.id + ` + rows, err := r.db.QueryContext(ctx, query, assessmentID) + if err != nil { + return nil, fmt.Errorf("list assessment attempts with email: %w", err) + } + defer rows.Close() + + var out []models.AttemptWithCandidateEmail + for rows.Next() { + var row models.AttemptWithCandidateEmail + if err := rows.Scan( + &row.ID, + &row.InviteID, + &row.StartedAt, + &row.CompletedAt, + &row.Status, + &row.TotalScore, + &row.CreatedAt, + &row.UpdatedAt, + &row.CandidateEmail, + ); err != nil { + return nil, fmt.Errorf("scan attempt with email: %w", err) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate attempts with email: %w", err) + } + return out, nil +} + func (r *attemptRepository) UpdateStatus(ctx context.Context, id int64, status string) error { const query = ` UPDATE assessment_attempts diff --git a/internal/core/repositories/postgres/submission_repo.go b/internal/core/repositories/postgres/submission_repo.go index 6a8b26e..27b0883 100644 --- a/internal/core/repositories/postgres/submission_repo.go +++ b/internal/core/repositories/postgres/submission_repo.go @@ -83,6 +83,49 @@ func (r *submissionRepository) GetByID(ctx context.Context, id int64) (*models.S return &s, nil } +func (r *submissionRepository) ListByAttemptID(ctx context.Context, attemptID int64) ([]models.Submission, error) { + const query = ` + SELECT id, attempt_id, question_id, language, source_code, status, + stdout, stderr, execution_ms, memory_bytes, score_awarded, + created_at, updated_at + FROM submissions + WHERE attempt_id = $1 + ORDER BY question_id, id DESC + ` + rows, err := r.db.QueryContext(ctx, query, attemptID) + if err != nil { + return nil, fmt.Errorf("list submissions by attempt: %w", err) + } + defer rows.Close() + + var list []models.Submission + for rows.Next() { + var s models.Submission + if err := rows.Scan( + &s.ID, + &s.AttemptID, + &s.QuestionID, + &s.Language, + &s.SourceCode, + &s.Status, + &s.Stdout, + &s.Stderr, + &s.ExecutionMS, + &s.MemoryBytes, + &s.ScoreAwarded, + &s.CreatedAt, + &s.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan submission: %w", err) + } + list = append(list, s) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate submissions: %w", err) + } + return list, nil +} + func (r *submissionRepository) UpdateStatus(ctx context.Context, id int64, status string) error { const query = ` UPDATE submissions diff --git a/internal/core/repositories/repositories.go b/internal/core/repositories/repositories.go index f1a441a..c6b9b8b 100644 --- a/internal/core/repositories/repositories.go +++ b/internal/core/repositories/repositories.go @@ -19,15 +19,22 @@ type AssessmentRepository interface { Create(ctx context.Context, assessment *models.AssessmentTemplate) error List(ctx context.Context) ([]models.AssessmentTemplate, error) GetByID(ctx context.Context, id int64) (*models.AssessmentTemplate, error) + CountQuestionsByAssessmentID(ctx context.Context, assessmentID int64) (int64, error) } type AttemptRepository interface { Create(ctx context.Context, attempt *models.AssessmentAttempt) error GetByID(ctx context.Context, id int64) (*models.AssessmentAttempt, error) + GetByInviteID(ctx context.Context, inviteID int64) (*models.AssessmentAttempt, error) + GetAssessmentTemplateIDForAttempt(ctx context.Context, attemptID int64) (int64, error) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentAttempt, error) + // ListWithCandidateEmailByAssessmentID joins attempts to invites for candidate email. + ListWithCandidateEmailByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AttemptWithCandidateEmail, error) UpdateStatus(ctx context.Context, id int64, status string) error SetScore(ctx context.Context, id int64, score int) error SetCompletedAt(ctx context.Context, id int64, completedAt time.Time) error + // FinalizeAttempt sets score, completed_at, attempt status (e.g. graded) and marks the linked invite completed. Requires attempt row status in_progress. + FinalizeAttempt(ctx context.Context, attemptID int64, totalScore int, completedAt time.Time, attemptStatus string) error } type InviteRepository interface { @@ -46,6 +53,7 @@ type QuestionRepository interface { type SubmissionRepository interface { Create(ctx context.Context, submission *models.Submission) error GetByID(ctx context.Context, id int64) (*models.Submission, error) + ListByAttemptID(ctx context.Context, attemptID int64) ([]models.Submission, error) UpdateStatus(ctx context.Context, id int64, status string) error UpdateResult(ctx context.Context, id int64, result *models.SubmissionResult) error } diff --git a/internal/core/services/attempt_service.go b/internal/core/services/attempt_service.go new file mode 100644 index 0000000..7435743 --- /dev/null +++ b/internal/core/services/attempt_service.go @@ -0,0 +1,348 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "time" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +var ( + ErrAttemptNotFound = errors.New("attempt not found") + ErrAttemptNotInProgress = errors.New("attempt is not in progress") + ErrAttemptExpired = errors.New("attempt time has expired") + ErrAnswerRequiresNonCode = errors.New("use submissions API for code questions") +) + +// AttemptService manages attempts and grading. +type AttemptService struct { + attemptRepo repositories.AttemptRepository + answerRepo repositories.AnswerRepository + questionRepo repositories.QuestionRepository + submissionRepo repositories.SubmissionRepository + assessmentRepo repositories.AssessmentRepository +} + +func NewAttemptService( + attempt repositories.AttemptRepository, + answer repositories.AnswerRepository, + question repositories.QuestionRepository, + submission repositories.SubmissionRepository, + assessment repositories.AssessmentRepository, +) *AttemptService { + return &AttemptService{ + attemptRepo: attempt, + answerRepo: answer, + questionRepo: question, + submissionRepo: submission, + assessmentRepo: assessment, + } +} + +// AttemptView adds timing context from the assessment template. +type AttemptView struct { + Attempt models.AssessmentAttempt `json:"attempt"` + AssessmentTemplateID int64 `json:"assessmentTemplateId"` + DurationMinutes int `json:"durationMinutes"` + // RemainingSeconds is seconds until the assessment timer ends (0 if expired); nil if not applicable (e.g. not in progress). + RemainingSeconds *int64 `json:"remainingSeconds,omitempty"` +} + +// CandidateQuestion hides grading fields from candidates. +type CandidateQuestion struct { + models.Question +} + +// SaveAnswerItem is one upsert payload for SaveAnswers (snake_case JSON). +type SaveAnswerItem struct { + QuestionID int64 `json:"question_id"` + Response json.RawMessage `json:"response"` +} + +func (s *AttemptService) ListAttempts(ctx context.Context, assessmentID int64) ([]models.AttemptWithCandidateEmail, error) { + if assessmentID <= 0 { + return nil, FieldError{Field: "assessment_id", Message: "must be greater than 0"} + } + return s.attemptRepo.ListWithCandidateEmailByAssessmentID(ctx, assessmentID) +} + +func (s *AttemptService) GetAttempt(ctx context.Context, attemptID int64) (*AttemptView, error) { + if attemptID <= 0 { + return nil, FieldError{Field: "attempt_id", Message: "invalid"} + } + att, err := s.attemptRepo.GetByID(ctx, attemptID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAttemptNotFound + } + return nil, err + } + assessmentID, err := s.attemptRepo.GetAssessmentTemplateIDForAttempt(ctx, attemptID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAttemptNotFound + } + return nil, err + } + tmpl, err := s.assessmentRepo.GetByID(ctx, assessmentID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAssessmentNotFound + } + return nil, err + } + + view := &AttemptView{ + Attempt: *att, + AssessmentTemplateID: assessmentID, + DurationMinutes: tmpl.DurationMinutes, + } + if att.Status == "in_progress" && att.StartedAt != nil && tmpl.DurationMinutes > 0 { + end := att.StartedAt.Add(time.Duration(tmpl.DurationMinutes) * time.Minute) + sec := int64(time.Until(end).Seconds()) + if sec < 0 { + sec = 0 + } + view.RemainingSeconds = &sec + } + + return view, nil +} + +func (s *AttemptService) GetAttemptQuestions(ctx context.Context, attemptID int64) ([]CandidateQuestion, error) { + detail, err := s.GetAttempt(ctx, attemptID) + if err != nil { + return nil, err + } + questions, err := s.questionRepo.ListByAssessmentID(ctx, detail.AssessmentTemplateID) + if err != nil { + return nil, err + } + + out := make([]CandidateQuestion, 0, len(questions)) + for i := range questions { + q := questions[i] + q.CorrectAnswer = nil + out = append(out, CandidateQuestion{Question: q}) + } + return out, nil +} + +func (s *AttemptService) SaveAnswers(ctx context.Context, attemptID int64, items []SaveAnswerItem) error { + if len(items) == 0 { + return FieldError{Field: "answers", Message: "required"} + } + + att, err := s.attemptRepo.GetByID(ctx, attemptID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return ErrAttemptNotFound + } + return err + } + if att.Status != "in_progress" { + return ErrAttemptNotInProgress + } + if err := s.checkTimer(ctx, att); err != nil { + return err + } + + assessmentID, err := s.attemptRepo.GetAssessmentTemplateIDForAttempt(ctx, attemptID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return ErrAttemptNotFound + } + return err + } + + for _, item := range items { + if item.QuestionID <= 0 { + return FieldError{Field: "question_id", Message: "invalid"} + } + q, err := s.questionRepo.GetByID(ctx, item.QuestionID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return ErrQuestionNotFound + } + return err + } + if q.AssessmentTemplateID != assessmentID { + return ErrWrongAssessment + } + if q.Type == "code" { + return ErrAnswerRequiresNonCode + } + + a := &models.Answer{ + AttemptID: attemptID, + QuestionID: item.QuestionID, + Response: item.Response, + } + if len(a.Response) == 0 { + a.Response = json.RawMessage(`null`) + } + if err := s.answerRepo.Upsert(ctx, a); err != nil { + return err + } + } + return nil +} + +func (s *AttemptService) SubmitAttempt(ctx context.Context, attemptID int64) (*models.AssessmentAttempt, error) { + att, err := s.attemptRepo.GetByID(ctx, attemptID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAttemptNotFound + } + return nil, err + } + if att.Status != "in_progress" { + return nil, ErrAttemptNotInProgress + } + if err := s.checkTimer(ctx, att); err != nil { + return nil, err + } + + assessmentID, err := s.attemptRepo.GetAssessmentTemplateIDForAttempt(ctx, attemptID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAttemptNotFound + } + return nil, err + } + + questions, err := s.questionRepo.ListByAssessmentID(ctx, assessmentID) + if err != nil { + return nil, err + } + answers, err := s.answerRepo.GetByAttemptID(ctx, attemptID) + if err != nil { + return nil, err + } + subs, err := s.submissionRepo.ListByAttemptID(ctx, attemptID) + if err != nil { + return nil, err + } + + answerByQ := make(map[int64]*models.Answer) + for i := range answers { + answerByQ[answers[i].QuestionID] = &answers[i] + } + latestSub := latestSubmissionByQuestion(subs) + + total := 0 + now := time.Now() + + for i := range questions { + q := &questions[i] + switch q.Type { + case "code": + sub := latestSub[q.ID] + points := 0 + if sub != nil && sub.ScoreAwarded != nil { + points = *sub.ScoreAwarded + } + total += points + default: + var resp json.RawMessage + if a := answerByQ[q.ID]; a != nil { + resp = a.Response + } + points := scoreNonCodeAnswer(q, resp) + total += points + + up := &models.Answer{ + AttemptID: attemptID, + QuestionID: q.ID, + Response: resp, + ScoreAwarded: intPtr(points), + } + if len(up.Response) == 0 { + up.Response = json.RawMessage(`null`) + } + if err := s.answerRepo.Upsert(ctx, up); err != nil { + return nil, err + } + } + } + + if err := s.attemptRepo.FinalizeAttempt(ctx, attemptID, total, now, "submitted"); err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAttemptNotInProgress + } + return nil, err + } + + return s.attemptRepo.GetByID(ctx, attemptID) +} + +func (s *AttemptService) checkTimer(ctx context.Context, att *models.AssessmentAttempt) error { + if att.StartedAt == nil { + return nil + } + aid, err := s.attemptRepo.GetAssessmentTemplateIDForAttempt(ctx, att.ID) + if err != nil { + return err + } + tmpl, err := s.assessmentRepo.GetByID(ctx, aid) + if err != nil { + return err + } + if tmpl.DurationMinutes <= 0 { + return nil + } + deadline := att.StartedAt.Add(time.Duration(tmpl.DurationMinutes) * time.Minute) + if time.Now().After(deadline) { + return ErrAttemptExpired + } + return nil +} + +func intPtr(n int) *int { + return &n +} + +func latestSubmissionByQuestion(subs []models.Submission) map[int64]*models.Submission { + m := make(map[int64]*models.Submission) + for i := range subs { + s := &subs[i] + if _, ok := m[s.QuestionID]; ok { + continue + } + m[s.QuestionID] = s + } + return m +} + +func scoreNonCodeAnswer(q *models.Question, response json.RawMessage) int { + if q.CorrectAnswer == nil { + return 0 + } + if !answersEqual(response, *q.CorrectAnswer) { + return 0 + } + return q.Score +} + +func answersEqual(response json.RawMessage, correct json.RawMessage) bool { + if len(response) == 0 && len(correct) == 0 { + return true + } + var a, b interface{} + if err := json.Unmarshal(response, &a); err != nil { + return false + } + if err := json.Unmarshal(correct, &b); err != nil { + return false + } + ab, err1 := json.Marshal(a) + bb, err2 := json.Marshal(b) + if err1 != nil || err2 != nil { + return false + } + return bytes.Equal(ab, bb) +} diff --git a/internal/core/services/invite_service.go b/internal/core/services/invite_service.go new file mode 100644 index 0000000..f5835bd --- /dev/null +++ b/internal/core/services/invite_service.go @@ -0,0 +1,207 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "time" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +var ( + ErrInviteNotFound = errors.New("invite not found") + ErrInviteExpired = errors.New("invite expired") + ErrInviteInvalid = errors.New("invite is no longer valid") + ErrInviteConsumed = errors.New("invite already completed") + ErrInviteAlreadyStarted = errors.New("invite already has an attempt") +) + +// InviteService orchestrates invites and starting attempts (single attempt per invite; rejects duplicate starts). +type InviteService struct { + inviteRepo repositories.InviteRepository + attemptRepo repositories.AttemptRepository + assessmentRepo repositories.AssessmentRepository +} + +func NewInviteService( + ir repositories.InviteRepository, + ar repositories.AttemptRepository, + asr repositories.AssessmentRepository, +) *InviteService { + return &InviteService{ + inviteRepo: ir, + attemptRepo: ar, + assessmentRepo: asr, + } +} + +// ValidateInviteResult is candidate-safe (no token, no correct answers). +type ValidateInviteResult struct { + Assessment models.AssessmentTemplate `json:"assessment"` + CandidateEmail string `json:"candidateEmail"` + ExpiresAt time.Time `json:"expiresAt"` + Status string `json:"status"` + QuestionCount int64 `json:"questionCount"` +} + +func (s *InviteService) CreateInvite(ctx context.Context, assessmentID int64, invite *models.AssessmentInvite) error { + if invite == nil { + return FieldError{Field: "invite", Message: "required"} + } + if assessmentID <= 0 { + return FieldError{Field: "assessment_id", Message: "invalid"} + } + if strings.TrimSpace(invite.CandidateEmail) == "" { + return FieldError{Field: "candidate_email", Message: "required"} + } + if invite.ExpiresAt.IsZero() { + return FieldError{Field: "expires_at", Message: "required"} + } + if !invite.ExpiresAt.After(time.Now()) { + return FieldError{Field: "expires_at", Message: "must be in the future"} + } + + _, err := s.assessmentRepo.GetByID(ctx, assessmentID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return ErrAssessmentNotFound + } + return err + } + + token, err := randomInviteToken() + if err != nil { + return err + } + + invite.AssessmentTemplateID = assessmentID + invite.InviteToken = token + if invite.Status == "" { + invite.Status = "pending" + } + + return s.inviteRepo.Create(ctx, invite) +} + +func (s *InviteService) ListInvites(ctx context.Context, assessmentID int64) ([]models.AssessmentInvite, error) { + if assessmentID <= 0 { + return nil, FieldError{Field: "assessment_id", Message: "must be greater than 0"} + } + return s.inviteRepo.ListByAssessmentID(ctx, assessmentID) +} + +func (s *InviteService) ValidateInvite(ctx context.Context, token string) (*ValidateInviteResult, error) { + if strings.TrimSpace(token) == "" { + return nil, FieldError{Field: "token", Message: "required"} + } + + invite, err := s.inviteRepo.GetByToken(ctx, token) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrInviteNotFound + } + return nil, err + } + + if invite.ExpiresAt.Before(time.Now()) { + _ = s.inviteRepo.UpdateStatus(ctx, invite.ID, "expired") + return nil, ErrInviteExpired + } + + switch invite.Status { + case "cancelled", "expired": + return nil, ErrInviteInvalid + case "completed": + return nil, ErrInviteConsumed + case "pending", "started": + default: + return nil, ErrInviteInvalid + } + + a, err := s.assessmentRepo.GetByID(ctx, invite.AssessmentTemplateID) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrAssessmentNotFound + } + return nil, err + } + + qCount, err := s.assessmentRepo.CountQuestionsByAssessmentID(ctx, invite.AssessmentTemplateID) + if err != nil { + return nil, err + } + + return &ValidateInviteResult{ + Assessment: *a, + CandidateEmail: invite.CandidateEmail, + ExpiresAt: invite.ExpiresAt, + Status: invite.Status, + QuestionCount: qCount, + }, nil +} + +// StartAttempt creates exactly one attempt per invite; rejects if an attempt row already exists for this invite. +func (s *InviteService) StartAttempt(ctx context.Context, token string) (*models.AssessmentAttempt, error) { + if strings.TrimSpace(token) == "" { + return nil, FieldError{Field: "token", Message: "required"} + } + + invite, err := s.inviteRepo.GetByToken(ctx, token) + if err != nil { + if errors.Is(err, repositories.ErrNotFound) { + return nil, ErrInviteNotFound + } + return nil, err + } + + if invite.ExpiresAt.Before(time.Now()) { + _ = s.inviteRepo.UpdateStatus(ctx, invite.ID, "expired") + return nil, ErrInviteExpired + } + + switch invite.Status { + case "cancelled", "expired": + return nil, ErrInviteInvalid + case "completed": + return nil, ErrInviteConsumed + case "started": + return nil, ErrInviteAlreadyStarted + case "pending": + default: + return nil, ErrInviteInvalid + } + + if _, err := s.attemptRepo.GetByInviteID(ctx, invite.ID); err == nil { + return nil, ErrInviteAlreadyStarted + } else if !errors.Is(err, repositories.ErrNotFound) { + return nil, err + } + + now := time.Now() + attempt := &models.AssessmentAttempt{ + InviteID: invite.ID, + StartedAt: &now, + Status: "in_progress", + } + + if err := s.attemptRepo.Create(ctx, attempt); err != nil { + return nil, err + } + if err := s.inviteRepo.UpdateStatus(ctx, invite.ID, "started"); err != nil { + return nil, err + } + + return attempt, nil +} + +func randomInviteToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/handlers/attempts.go b/internal/handlers/attempts.go index b6fc65d..d36d735 100644 --- a/internal/handlers/attempts.go +++ b/internal/handlers/attempts.go @@ -1,49 +1,162 @@ package handlers import ( + "errors" "net/http" + "strconv" + + "CodeSCE/internal/core/services" + "github.com/gin-gonic/gin" ) +var attemptService *services.AttemptService + +func SetAttemptService(s *services.AttemptService) { + attemptService = s +} + func ListAttempts(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "list attempts route wired", - "assessmentId": c.Query("assessmentId"), - }) + if attemptService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "attempt service not configured"}) + return + } + + raw := c.Query("assessmentId") + assessmentID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || assessmentID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "assessmentId: required and must be positive"}) + return + } + + attempts, err := attemptService.ListAttempts(c.Request.Context(), assessmentID) + if err != nil { + handleAttemptError(c, err) + return + } + + c.JSON(http.StatusOK, attempts) } func GetAttempt(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "get attempt route wired", - "id": c.Param("id"), - }) + if attemptService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "attempt service not configured"}) + return + } + + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + view, err := attemptService.GetAttempt(c.Request.Context(), id) + if err != nil { + handleAttemptError(c, err) + return + } + + c.JSON(http.StatusOK, view) } func GetAttemptQuestions(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "get attempt questions route wired", - "id": c.Param("id"), - }) + if attemptService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "attempt service not configured"}) + return + } + + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + questions, err := attemptService.GetAttemptQuestions(c.Request.Context(), id) + if err != nil { + handleAttemptError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"questions": questions}) } func SaveAnswers(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "save answers route wired", - "id": c.Param("id"), - }) + if attemptService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "attempt service not configured"}) + return + } + + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var req struct { + Answers []services.SaveAnswerItem `json:"answers"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if err := attemptService.SaveAnswers(c.Request.Context(), id, req.Answers); err != nil { + handleAttemptError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) } func SubmitAttempt(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "submit attempt route wired", - "id": c.Param("id"), - }) + if attemptService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "attempt service not configured"}) + return + } + + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + attempt, err := attemptService.SubmitAttempt(c.Request.Context(), id) + if err != nil { + handleAttemptError(c, err) + return + } + + c.JSON(http.StatusOK, attempt) } func RunCode(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "attempt_id": c.Param("id"), - "question_id": c.Param("qid"), + "attempt_id": c.Param("id"), + "question_id": c.Param("qid"), "passed_test_cases": []int{}, }) } + +func handleAttemptError(c *gin.Context, err error) { + var fieldErr services.FieldError + if errors.As(err, &fieldErr) { + c.JSON(http.StatusBadRequest, gin.H{"error": fieldErr.Error()}) + return + } + + switch { + case errors.Is(err, services.ErrAttemptNotFound), errors.Is(err, services.ErrQuestionNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrAssessmentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrAttemptNotInProgress): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrAttemptExpired): + c.JSON(http.StatusGone, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrWrongAssessment), + errors.Is(err, services.ErrAnswerRequiresNonCode): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } +} diff --git a/internal/handlers/invites.go b/internal/handlers/invites.go index 9cfa5dd..82b83f3 100644 --- a/internal/handlers/invites.go +++ b/internal/handlers/invites.go @@ -1,31 +1,160 @@ package handlers import ( + "errors" "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/services" + "github.com/gin-gonic/gin" ) +var inviteService *services.InviteService + +func SetInviteService(s *services.InviteService) { + inviteService = s +} + func CreateInvite(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "create invite route wired", - "id": c.Param("id"), + if inviteService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invite service not configured"}) + return + } + + assessmentID, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var req struct { + CandidateEmail string `json:"candidate_email"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + invite := &models.AssessmentInvite{ + CandidateEmail: req.CandidateEmail, + ExpiresAt: req.ExpiresAt, + } + + if err := inviteService.CreateInvite(c.Request.Context(), assessmentID, invite); err != nil { + handleInviteError(c, err) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": invite.ID, + "inviteToken": invite.InviteToken, + "inviteLink": inviteCopyableLink(c, invite.InviteToken), + "candidateEmail": invite.CandidateEmail, + "expiresAt": invite.ExpiresAt, + "status": invite.Status, + "createdAt": invite.CreatedAt, + "updatedAt": invite.UpdatedAt, }) } +func inviteCopyableLink(c *gin.Context, token string) string { + path := "/api/invites/" + url.PathEscape(token) + if base := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); base != "" { + return strings.TrimSuffix(base, "/") + path + } + scheme := "http" + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := c.Request.Host + if host == "" { + return path + } + return scheme + "://" + host + path +} + func ListInvites(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "list invites route wired"}) + if inviteService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invite service not configured"}) + return + } + + raw := c.Query("assessmentId") + assessmentID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || assessmentID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "assessmentId: required and must be positive"}) + return + } + + invites, err := inviteService.ListInvites(c.Request.Context(), assessmentID) + if err != nil { + handleInviteError(c, err) + return + } + + c.JSON(http.StatusOK, invites) } func ValidateInvite(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "validate invite route wired", - "token": c.Param("token"), - }) + if inviteService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invite service not configured"}) + return + } + + token := c.Param("token") + result, err := inviteService.ValidateInvite(c.Request.Context(), token) + if err != nil { + handleInviteError(c, err) + return + } + + c.JSON(http.StatusOK, result) } func StartAttempt(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "start attempt route wired", - "token": c.Param("token"), - }) + if inviteService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invite service not configured"}) + return + } + + token := c.Param("token") + attempt, err := inviteService.StartAttempt(c.Request.Context(), token) + if err != nil { + handleInviteError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"attemptId": attempt.ID}) +} + +func handleInviteError(c *gin.Context, err error) { + var fieldErr services.FieldError + if errors.As(err, &fieldErr) { + c.JSON(http.StatusBadRequest, gin.H{"error": fieldErr.Error()}) + return + } + + switch { + case errors.Is(err, services.ErrInviteNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrInviteExpired): + c.JSON(http.StatusGone, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrInviteInvalid): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrInviteConsumed): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrInviteAlreadyStarted): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrAssessmentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } }