Skip to content

Commit bad34de

Browse files
committed
invite and attempt backend services and handlers
1 parent cd4f087 commit bad34de

10 files changed

Lines changed: 1041 additions & 34 deletions

File tree

cmd/server/main.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"github.com/gin-gonic/gin"
99
_ "github.com/jackc/pgx/v5/stdlib"
1010

11+
"CodeSCE/internal/core/repositories/postgres"
12+
"CodeSCE/internal/core/services"
1113
"CodeSCE/internal/db"
1214
"CodeSCE/internal/handlers"
1315
)
@@ -32,6 +34,24 @@ func main() {
3234
log.Fatalf("failed to initialize schema: %v", err)
3335
}
3436

37+
assessmentRepo := postgres.NewAssessmentRepository(database)
38+
questionRepo := postgres.NewQuestionRepository(database)
39+
testCaseRepo := postgres.NewTestCaseRepository(database)
40+
inviteRepo := postgres.NewInviteRepository(database)
41+
attemptRepo := postgres.NewAttemptRepository(database)
42+
answerRepo := postgres.NewAnswerRepository(database)
43+
submissionRepo := postgres.NewSubmissionRepository(database)
44+
45+
handlers.SetAssessmentService(services.NewAssessmentService(assessmentRepo, questionRepo, testCaseRepo))
46+
handlers.SetInviteService(services.NewInviteService(inviteRepo, attemptRepo, assessmentRepo))
47+
handlers.SetAttemptService(services.NewAttemptService(
48+
attemptRepo,
49+
answerRepo,
50+
questionRepo,
51+
submissionRepo,
52+
assessmentRepo,
53+
))
54+
3555
r := gin.Default()
3656
r.GET("/ping", func(c *gin.Context) {
3757
c.JSON(200, gin.H{

internal/core/models/models.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ type AssessmentAttempt struct {
5656
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
5757
}
5858

59+
// AttemptWithCandidateEmail is an attempt row with the invitee email (list/admin views).
60+
type AttemptWithCandidateEmail struct {
61+
AssessmentAttempt
62+
CandidateEmail string `json:"candidateEmail"`
63+
}
64+
5965
type Answer struct {
6066
ID int64 `json:"id" db:"id"`
6167
AttemptID int64 `json:"attemptId" db:"attempt_id"`

internal/core/repositories/postgres/assessment_repo.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,12 @@ func (r *assessmentRepository) GetByID(ctx context.Context, id int64) (*models.A
8787
}
8888
return &t, nil
8989
}
90+
91+
func (r *assessmentRepository) CountQuestionsByAssessmentID(ctx context.Context, assessmentID int64) (int64, error) {
92+
const query = `SELECT COUNT(*) FROM questions WHERE assessment_template_id = $1`
93+
var n int64
94+
if err := r.db.QueryRowContext(ctx, query, assessmentID).Scan(&n); err != nil {
95+
return 0, fmt.Errorf("count questions by assessment: %w", err)
96+
}
97+
return n, nil
98+
}

internal/core/repositories/postgres/attempt_repo.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,90 @@ func (r *attemptRepository) GetByID(ctx context.Context, id int64) (*models.Asse
6666
return &attempt, nil
6767
}
6868

69+
func (r *attemptRepository) GetByInviteID(ctx context.Context, inviteID int64) (*models.AssessmentAttempt, error) {
70+
const query = `
71+
SELECT id, invite_id, started_at, completed_at, status, total_score, created_at, updated_at
72+
FROM assessment_attempts
73+
WHERE invite_id = $1
74+
LIMIT 1
75+
`
76+
var attempt models.AssessmentAttempt
77+
err := r.db.QueryRowContext(ctx, query, inviteID).Scan(
78+
&attempt.ID,
79+
&attempt.InviteID,
80+
&attempt.StartedAt,
81+
&attempt.CompletedAt,
82+
&attempt.Status,
83+
&attempt.TotalScore,
84+
&attempt.CreatedAt,
85+
&attempt.UpdatedAt,
86+
)
87+
if errors.Is(err, sql.ErrNoRows) {
88+
return nil, repositories.ErrNotFound
89+
}
90+
if err != nil {
91+
return nil, fmt.Errorf("get assessment attempt by invite id: %w", err)
92+
}
93+
return &attempt, nil
94+
}
95+
96+
func (r *attemptRepository) GetAssessmentTemplateIDForAttempt(ctx context.Context, attemptID int64) (int64, error) {
97+
const query = `
98+
SELECT i.assessment_template_id
99+
FROM assessment_attempts a
100+
JOIN assessment_invites i ON i.id = a.invite_id
101+
WHERE a.id = $1
102+
`
103+
var assessmentID int64
104+
err := r.db.QueryRowContext(ctx, query, attemptID).Scan(&assessmentID)
105+
if errors.Is(err, sql.ErrNoRows) {
106+
return 0, repositories.ErrNotFound
107+
}
108+
if err != nil {
109+
return 0, fmt.Errorf("get assessment template id for attempt: %w", err)
110+
}
111+
return assessmentID, nil
112+
}
113+
114+
func (r *attemptRepository) FinalizeAttempt(ctx context.Context, attemptID int64, totalScore int, completedAt time.Time, attemptStatus string) error {
115+
tx, err := r.db.BeginTx(ctx, nil)
116+
if err != nil {
117+
return fmt.Errorf("finalize attempt begin tx: %w", err)
118+
}
119+
defer func() { _ = tx.Rollback() }()
120+
121+
res, err := tx.ExecContext(ctx, `
122+
UPDATE assessment_attempts
123+
SET total_score = $2, completed_at = $3, status = $4, updated_at = NOW()
124+
WHERE id = $1 AND status = 'in_progress'
125+
`, attemptID, totalScore, completedAt, attemptStatus)
126+
if err != nil {
127+
return fmt.Errorf("finalize attempt update attempt: %w", err)
128+
}
129+
n, err := res.RowsAffected()
130+
if err != nil {
131+
return fmt.Errorf("finalize attempt rows affected: %w", err)
132+
}
133+
if n == 0 {
134+
return repositories.ErrNotFound
135+
}
136+
137+
_, err = tx.ExecContext(ctx, `
138+
UPDATE assessment_invites i
139+
SET status = 'completed', updated_at = NOW()
140+
FROM assessment_attempts a
141+
WHERE i.id = a.invite_id AND a.id = $1
142+
`, attemptID)
143+
if err != nil {
144+
return fmt.Errorf("finalize attempt update invite: %w", err)
145+
}
146+
147+
if err := tx.Commit(); err != nil {
148+
return fmt.Errorf("finalize attempt commit: %w", err)
149+
}
150+
return nil
151+
}
152+
69153
func (r *attemptRepository) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentAttempt, error) {
70154
// Attempts link to assessments via assessment_invites.assessment_template_id.
71155
const query = `
@@ -105,6 +189,46 @@ func (r *attemptRepository) ListByAssessmentID(ctx context.Context, assessmentID
105189
return attempts, nil
106190
}
107191

192+
func (r *attemptRepository) ListWithCandidateEmailByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AttemptWithCandidateEmail, error) {
193+
const query = `
194+
SELECT a.id, a.invite_id, a.started_at, a.completed_at, a.status,
195+
a.total_score, a.created_at, a.updated_at,
196+
i.candidate_email
197+
FROM assessment_attempts a
198+
JOIN assessment_invites i ON i.id = a.invite_id
199+
WHERE i.assessment_template_id = $1
200+
ORDER BY a.id
201+
`
202+
rows, err := r.db.QueryContext(ctx, query, assessmentID)
203+
if err != nil {
204+
return nil, fmt.Errorf("list assessment attempts with email: %w", err)
205+
}
206+
defer rows.Close()
207+
208+
var out []models.AttemptWithCandidateEmail
209+
for rows.Next() {
210+
var row models.AttemptWithCandidateEmail
211+
if err := rows.Scan(
212+
&row.ID,
213+
&row.InviteID,
214+
&row.StartedAt,
215+
&row.CompletedAt,
216+
&row.Status,
217+
&row.TotalScore,
218+
&row.CreatedAt,
219+
&row.UpdatedAt,
220+
&row.CandidateEmail,
221+
); err != nil {
222+
return nil, fmt.Errorf("scan attempt with email: %w", err)
223+
}
224+
out = append(out, row)
225+
}
226+
if err := rows.Err(); err != nil {
227+
return nil, fmt.Errorf("iterate attempts with email: %w", err)
228+
}
229+
return out, nil
230+
}
231+
108232
func (r *attemptRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
109233
const query = `
110234
UPDATE assessment_attempts

internal/core/repositories/postgres/submission_repo.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,49 @@ func (r *submissionRepository) GetByID(ctx context.Context, id int64) (*models.S
8383
return &s, nil
8484
}
8585

86+
func (r *submissionRepository) ListByAttemptID(ctx context.Context, attemptID int64) ([]models.Submission, error) {
87+
const query = `
88+
SELECT id, attempt_id, question_id, language, source_code, status,
89+
stdout, stderr, execution_ms, memory_bytes, score_awarded,
90+
created_at, updated_at
91+
FROM submissions
92+
WHERE attempt_id = $1
93+
ORDER BY question_id, id DESC
94+
`
95+
rows, err := r.db.QueryContext(ctx, query, attemptID)
96+
if err != nil {
97+
return nil, fmt.Errorf("list submissions by attempt: %w", err)
98+
}
99+
defer rows.Close()
100+
101+
var list []models.Submission
102+
for rows.Next() {
103+
var s models.Submission
104+
if err := rows.Scan(
105+
&s.ID,
106+
&s.AttemptID,
107+
&s.QuestionID,
108+
&s.Language,
109+
&s.SourceCode,
110+
&s.Status,
111+
&s.Stdout,
112+
&s.Stderr,
113+
&s.ExecutionMS,
114+
&s.MemoryBytes,
115+
&s.ScoreAwarded,
116+
&s.CreatedAt,
117+
&s.UpdatedAt,
118+
); err != nil {
119+
return nil, fmt.Errorf("scan submission: %w", err)
120+
}
121+
list = append(list, s)
122+
}
123+
if err := rows.Err(); err != nil {
124+
return nil, fmt.Errorf("iterate submissions: %w", err)
125+
}
126+
return list, nil
127+
}
128+
86129
func (r *submissionRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
87130
const query = `
88131
UPDATE submissions

internal/core/repositories/repositories.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,22 @@ type AssessmentRepository interface {
1919
Create(ctx context.Context, assessment *models.AssessmentTemplate) error
2020
List(ctx context.Context) ([]models.AssessmentTemplate, error)
2121
GetByID(ctx context.Context, id int64) (*models.AssessmentTemplate, error)
22+
CountQuestionsByAssessmentID(ctx context.Context, assessmentID int64) (int64, error)
2223
}
2324

2425
type AttemptRepository interface {
2526
Create(ctx context.Context, attempt *models.AssessmentAttempt) error
2627
GetByID(ctx context.Context, id int64) (*models.AssessmentAttempt, error)
28+
GetByInviteID(ctx context.Context, inviteID int64) (*models.AssessmentAttempt, error)
29+
GetAssessmentTemplateIDForAttempt(ctx context.Context, attemptID int64) (int64, error)
2730
ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentAttempt, error)
31+
// ListWithCandidateEmailByAssessmentID joins attempts to invites for candidate email.
32+
ListWithCandidateEmailByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AttemptWithCandidateEmail, error)
2833
UpdateStatus(ctx context.Context, id int64, status string) error
2934
SetScore(ctx context.Context, id int64, score int) error
3035
SetCompletedAt(ctx context.Context, id int64, completedAt time.Time) error
36+
// FinalizeAttempt sets score, completed_at, attempt status (e.g. graded) and marks the linked invite completed. Requires attempt row status in_progress.
37+
FinalizeAttempt(ctx context.Context, attemptID int64, totalScore int, completedAt time.Time, attemptStatus string) error
3138
}
3239

3340
type InviteRepository interface {
@@ -46,6 +53,7 @@ type QuestionRepository interface {
4653
type SubmissionRepository interface {
4754
Create(ctx context.Context, submission *models.Submission) error
4855
GetByID(ctx context.Context, id int64) (*models.Submission, error)
56+
ListByAttemptID(ctx context.Context, attemptID int64) ([]models.Submission, error)
4957
UpdateStatus(ctx context.Context, id int64, status string) error
5058
UpdateResult(ctx context.Context, id int64, result *models.SubmissionResult) error
5159
}

0 commit comments

Comments
 (0)