sessions
import "github.com/Ask-Atlas/AskAtlas/api/internal/sessions"
Package sessions hosts the domain types, params, mappers, and service logic for the practice-sessions surface (ASK-128 onward). Mirrors the layering of internal/quizzes -- repository interface + sqlc-backed impl, service that owns transactions + the stale-cleanup / resume / create flow, pointer-free domain types that the handler projects onto the generated wire schema.
Index
- Constants
- func EncodeSessionsCursor(c SessionsListCursor) (string, error)
- type AbandonSessionParams
- type AnswerSummary
- type CompleteSessionParams
- type CompletedSessionDetail
- type GetSessionParams
- type ListSessionsParams
- type ListSessionsResult
- type Repository
- type Service
- func NewService(repo Repository) *Service
- func (s *Service) AbandonSession(ctx context.Context, p AbandonSessionParams) error
- func (s *Service) CompleteSession(ctx context.Context, p CompleteSessionParams) (CompletedSessionDetail, error)
- func (s *Service) GetSession(ctx context.Context, p GetSessionParams) (SessionDetail, error)
- func (s *Service) ListSessions(ctx context.Context, p ListSessionsParams) (ListSessionsResult, error)
- func (s *Service) StartSession(ctx context.Context, p StartSessionParams) (StartSessionResult, error)
- func (s *Service) SubmitAnswer(ctx context.Context, p SubmitAnswerParams) (AnswerSummary, error)
- type SessionDetail
- type SessionSummary
- type SessionsListCursor
- type StartSessionParams
- type StartSessionResult
- type SubmitAnswerParams
Constants
True/false canonical wire labels accepted on SubmitAnswer (lowercase per spec; "True"/"False" capitalised are 400s).
const (
TrueFalseAnswerTrue = "true"
TrueFalseAnswerFalse = "false"
)
SessionStatus filter values accepted by ListSessions (ASK-149). Match the lowercase enum values in the openapi schema verbatim so the handler can string-cast its way from api.ListPracticeSessionsParamsStatus to this domain type.
const (
SessionStatusActive = "active"
SessionStatusCompleted = "completed"
)
StaleSessionAge is the cutoff for an in-progress practice session to be considered abandoned and eligible for hard-delete on the next StartSession call (ASK-128 spec AC6: "stale incomplete session (started_at > 7 days ago) -> cleaned up + fresh 201").
This is the Single Source of Truth for the threshold. The DeleteStaleIncompleteSessions sqlc query takes the value in seconds via stale_threshold_seconds and multiplies by `interval '1 second'` server-side -- so changing this constant is the only edit required to update the policy.
const StaleSessionAge = 7 * 24 * time.Hour
func EncodeSessionsCursor
func EncodeSessionsCursor(c SessionsListCursor) (string, error)
EncodeSessionsCursor serializes a SessionsListCursor into a base64-URL-encoded opaque token. JSON wraps the timestamp + uuid so the token is self-describing and forward-compatible with future field additions.
type AbandonSessionParams
AbandonSessionParams is the input to Service.AbandonSession (ASK-144). UserID is taken from the JWT in the handler -- the spec forbids accepting a user id from the request. SessionID is the path parameter. Identical shape to CompleteSessionParams because the auth + ownership checks have identical structure; kept as a separate type so future divergence (e.g. an explicit "force" flag) doesn't ripple through the complete path.
type AbandonSessionParams struct {
SessionID uuid.UUID
UserID uuid.UUID
}
type AnswerSummary
AnswerSummary is the per-answer payload embedded in SessionDetail. Models the practice_answers row -- QuestionID, UserAnswer, and IsCorrect are pointers because the underlying columns are nullable:
- QuestionID becomes nil if the underlying quiz_questions row is hard-deleted after the answer was submitted (ON DELETE SET NULL on practice_answers.question_id).
- UserAnswer / IsCorrect track the schema's nullable columns. The submit-answer endpoint (ASK-137, future) always writes non-null values in practice; the pointers preserve the ability to read historical rows that pre-dated stricter server-side validation.
Verified is true for server-validated answer types (multiple-choice, true-false) and false for freeform answers (string-match only). The submit endpoint sets it; this package only reads it.
type AnswerSummary struct {
QuestionID *uuid.UUID
UserAnswer *string
IsCorrect *bool
Verified bool
AnsweredAt time.Time
}
type CompleteSessionParams
CompleteSessionParams is the input to Service.CompleteSession (ASK-140). UserID is taken from the JWT in the handler -- the spec forbids accepting a user id from the body. SessionID is the path parameter.
type CompleteSessionParams struct {
SessionID uuid.UUID
UserID uuid.UUID
}
type CompletedSessionDetail
CompletedSessionDetail is the domain payload returned by Service.CompleteSession (ASK-140). Distinct from SessionDetail because the wire shape is also distinct: no Answers slice (callers fetch them separately via GET /api/sessions/{id}), CompletedAt is non-nullable (the endpoint always sets it), and ScorePercentage is a server-computed derived field.
type CompletedSessionDetail struct {
ID uuid.UUID
QuizID uuid.UUID
StartedAt time.Time
CompletedAt time.Time
TotalQuestions int32
CorrectAnswers int32
ScorePercentage int32
}
type GetSessionParams
GetSessionParams is the input to Service.GetSession (ASK-152). UserID is taken from the JWT in the handler -- the spec forbids accepting a user id from the request. SessionID is the path parameter.
type GetSessionParams struct {
SessionID uuid.UUID
UserID uuid.UUID
}
type ListSessionsParams
ListSessionsParams is the validated input to Service.ListSessions (ASK-149). UserID is taken from the JWT in the handler -- users cannot list each other's sessions even if they spoof the quiz_id path param. QuizID is the path parameter; the service gates on its live status before running the list query (so a soft-deleted parent surfaces as 404, not as an empty list).
Status is optional: nil means "both active + completed". A non-nil pointer to a value other than the SessionStatus* constants is rejected by the handler at parse time.
PageLimit is the caller-requested page size (1-50 inclusive, default 10 -- enforced by the handler). The service passes PageLimit + 1 to the underlying sqlc query so it can detect has_more without a separate COUNT.
Cursor is the opaque base64 token decoded by the handler. nil means "first page".
type ListSessionsParams struct {
UserID uuid.UUID
QuizID uuid.UUID
Status *string
PageLimit int
Cursor *SessionsListCursor
}
type ListSessionsResult
ListSessionsResult bundles a page of SessionSummary rows with the optional next-page cursor produced by the service. NextCursor is nil when the caller is on the last page (no more rows beyond what Sessions carries). The handler converts (NextCursor != nil) into the wire `has_more` boolean and forwards the *string verbatim.
type ListSessionsResult struct {
Sessions []SessionSummary
NextCursor *string
}
type Repository
Repository is the data-access surface required by Service. The production impl is sqlc-backed (sqlc_repository.go); tests inject a mockery-generated mock. Mirrors the quizzes.Repository / studyguides.Repository pattern.
type Repository interface {
CheckQuizLiveForSession(ctx context.Context, quizID pgtype.UUID) (bool, error)
DeleteStaleIncompleteSessions(ctx context.Context, arg db.DeleteStaleIncompleteSessionsParams) error
FindIncompleteSession(ctx context.Context, arg db.FindIncompleteSessionParams) (db.FindIncompleteSessionRow, error)
InsertPracticeSessionIfAbsent(ctx context.Context, arg db.InsertPracticeSessionIfAbsentParams) (db.InsertPracticeSessionIfAbsentRow, error)
SnapshotQuizQuestionsAndUpdateCount(ctx context.Context, arg db.SnapshotQuizQuestionsAndUpdateCountParams) (int32, error)
ListSessionAnswers(ctx context.Context, sessionID pgtype.UUID) ([]db.ListSessionAnswersRow, error)
// SubmitAnswer-related (ASK-137). GetQuizQuestionByID is reused
// from the quizzes surface (ASK-115); both packages legitimately
// need the per-question type + reference_answer, so duplicating
// the query in queries/quizzes.sql vs adding a new one in
// queries/practice_sessions.sql is a wash -- we prefer reuse.
GetSessionForAnswerSubmission(ctx context.Context, id pgtype.UUID) (db.GetSessionForAnswerSubmissionRow, error)
CheckQuestionInSessionSnapshot(ctx context.Context, arg db.CheckQuestionInSessionSnapshotParams) (bool, error)
GetQuizQuestionByID(ctx context.Context, id pgtype.UUID) (db.GetQuizQuestionByIDRow, error)
GetCorrectOptionText(ctx context.Context, questionID pgtype.UUID) (string, error)
InsertPracticeAnswer(ctx context.Context, arg db.InsertPracticeAnswerParams) (db.InsertPracticeAnswerRow, error)
IncrementSessionCorrectAnswers(ctx context.Context, id pgtype.UUID) error
// CompleteSession-related (ASK-140) and AbandonSession-related
// (ASK-144). LockSessionForCompletion is reused by both flows
// -- both need the same FOR UPDATE row + ownership/completion
// state to dispatch 404 / 403 / 409 / proceed.
LockSessionForCompletion(ctx context.Context, id pgtype.UUID) (db.PracticeSession, error)
MarkSessionCompleted(ctx context.Context, id pgtype.UUID) (pgtype.Timestamptz, error)
DeleteSessionByID(ctx context.Context, id pgtype.UUID) (int64, error)
// GetSession-related (ASK-152). GetSessionByID is a non-locking
// read; the lock-equivalent on the writer side is
// LockSessionForCompletion + GetSessionForAnswerSubmission.
GetSessionByID(ctx context.Context, id pgtype.UUID) (db.PracticeSession, error)
// ListSessions-related (ASK-149). Cursor-paginated keyset
// scan over (started_at DESC, id DESC) scoped to (user_id,
// quiz_id). The service computes score_percentage in Go
// (only completed sessions) and gates on
// CheckQuizLiveForSession before invoking this query.
ListUserSessionsForQuiz(ctx context.Context, arg db.ListUserSessionsForQuizParams) ([]db.ListUserSessionsForQuizRow, error)
// InTx runs fn inside a single Postgres transaction. The
// Repository passed to fn is scoped to the tx via Queries.WithTx,
// so any sqlc call made through it participates in the same tx.
// Used by StartSession for the atomic insert-session +
// snapshot-questions write, and by SubmitAnswer for the locked
// session check + insert-answer + counter-bump.
InTx(ctx context.Context, fn func(Repository) error) error
}
func NewSQLCRepository
func NewSQLCRepository(pool *pgxpool.Pool, queries *db.Queries) Repository
NewSQLCRepository returns a Repository backed by sqlc-generated Postgres queries. Takes the pgxpool.Pool alongside the Queries instance so InTx can begin transactions.
type Service
Service is the business-logic layer for the sessions feature.
type Service struct {
// contains filtered or unexported fields
}
func NewService
func NewService(repo Repository) *Service
NewService creates a new Service backed by the given Repository.
func (*Service) AbandonSession
func (s *Service) AbandonSession(ctx context.Context, p AbandonSessionParams) error
AbandonSession hard-deletes an in-progress practice session owned by the authenticated user (ASK-144). Completed sessions cannot be abandoned -- they are historical analytics data and surface as 409 here.
Order of operations (single transaction):
- LockSessionForCompletion -- locked SELECT returning all session fields. FOR UPDATE serializes against concurrent SubmitAnswer (ASK-137) and CompleteSession (ASK-140) so the spec's race semantics fall out naturally: * answer wins -> our lock waits, then sees the answer row already in place; the CASCADE delete cleans it up alongside the session. * complete wins -> our lock sees completed_at set and we return 409.
- 404 if missing.
- 403 if user_id != viewer.
- 409 if completed_at IS NOT NULL.
- DeleteSessionByID -- blind by-id delete. CASCADE foreign keys on practice_session_questions + practice_answers remove children in the same statement. We assert rowsAffected == 1 as defense-in-depth (the FOR UPDATE lock makes a 0-rows path effectively unreachable, but the assertion is cheap and self-documenting).
Asymmetric to GetSession (ASK-152), which returns historical sessions for soft-deleted parents: AbandonSession refuses to touch a completed session entirely. The user can always view it via GetSession; deletion of historical analytics data is out of scope (per the spec's "Out of Scope" section).
Idempotency: NOT idempotent. A second AbandonSession call on an already-deleted session returns 404 (sql.ErrNoRows from the locked SELECT). Documented on the wire side as "callers that want 'make sure it's gone' must tolerate 404".
func (*Service) CompleteSession
func (s *Service) CompleteSession(ctx context.Context, p CompleteSessionParams) (CompletedSessionDetail, error)
CompleteSession marks an in-progress practice session as completed and returns the finalized payload with a server- computed score (ASK-140). Cannot be called on a session that is already completed -- second calls return 409, not a no-op success.
Order of operations (single transaction):
- LockSessionForCompletion -- locked SELECT returning all session fields. FOR UPDATE serializes against a concurrent SubmitAnswer (ASK-137 also FOR UPDATEs the row).
- 404 if missing.
- 403 if user_id != viewer.
- 409 if completed_at IS NOT NULL.
- MarkSessionCompleted -- blind UPDATE setting completed_at = now(), returning the new timestamp. The locked SELECT in step 1 + the FOR UPDATE row lock guarantee no other writer can change the row between the check and the update; ownership + completed_at have already been verified inside the same tx.
- Compute score_percentage from the captured correct_answers / total_questions (the snapshot captured by step 1 -- correct_answers may have been bumped by SubmitAnswers that committed before our lock, all of which is desired).
func (*Service) GetSession
func (s *Service) GetSession(ctx context.Context, p GetSessionParams) (SessionDetail, error)
GetSession returns the full session payload for the practice player's results view (ASK-152). Auth-only -- the session must belong to the authenticated user (403 otherwise).
Order of operations (no transaction needed -- pure read):
- GetSessionByID -- 404 if missing.
- 403 if user_id != viewer.
- ListSessionAnswers -- chronological list of submitted answers; an empty list is rendered as `[]` on the wire.
- Compute ScorePercentage when completed_at is set; leave nil for in-progress sessions so the wire renders `null`.
No parent quiz / study_guide deletion check (per spec AC6 + technical note): sessions are historical data. A completed session for a quiz the creator later soft-deleted MUST still be readable by its owner -- the user owns their session history, not the quiz.
func (*Service) ListSessions
func (s *Service) ListSessions(ctx context.Context, p ListSessionsParams) (ListSessionsResult, error)
ListSessions returns a cursor-paginated page of the authenticated user's practice sessions for one quiz (ASK-149). Sorted by (started_at DESC, id DESC) so the newest attempt is on top; the id tie-breaker disambiguates the (vanishingly rare) case of two sessions sharing started_at to the microsecond.
Order of operations:
- CheckQuizLiveForSession -- 404 if the quiz is missing, soft-deleted, or under a soft-deleted study guide. Same info-leak rule as StartSession: a non-live parent yields a single 404 the caller cannot disambiguate. This is the OPPOSITE of GetSession (ASK-152), which intentionally returns historical sessions for soft-deleted parents -- a listing scoped to a quiz needs a live quiz to anchor on.
- ListUserSessionsForQuiz -- the keyset query is asked for PageLimit + 1 rows so we can detect has_more without a separate COUNT. If the query returns more than PageLimit rows, we trim the extra row and emit a NextCursor pointing to the LAST row of the trimmed page (NOT the trimmed-off row -- callers ask for "everything strictly older than started_at,id of the last row I saw").
- mapSessionSummary fills in ScorePercentage only for completed sessions; in-progress rows leave it nil so the wire renders `null`.
Empty user (no sessions): returns ListSessionsResult{Sessions: non-nil empty slice, NextCursor: nil}. The handler maps that to `{"sessions": [], "has_more": false, "next_cursor": null}`.
func (*Service) StartSession
func (s *Service) StartSession(ctx context.Context, p StartSessionParams) (StartSessionResult, error)
StartSession starts a new practice session OR resumes the user's existing in-progress session for a quiz (ASK-128). The result's Created flag tells the handler whether to render 201 (created) or 200 (resumed).
Order of operations:
- CheckQuizLiveForSession -- quiz live AND parent guide live. A missing/deleted/parent-deleted quiz all return 404; the caller cannot distinguish (info-leak prevention, same rule as GetQuiz / DeleteQuiz / UpdateQuiz).
- DeleteStaleIncompleteSessions -- hard-delete this user's incomplete session for this quiz when started_at is older than 7 days, so the next step sees a clean slate. Idempotent (no-op when nothing is stale). Per spec AC6: a stale session forces a fresh start (201), not a resume (200).
- FindIncompleteSession -- if found, hydrate the existing row plus its answers and return Created=false (200 resume). The partial unique index guarantees AT MOST one match.
- Inside InTx: a. InsertPracticeSessionIfAbsent -- inserts the session row with total_questions = 0 (column default). ON CONFLICT DO NOTHING against the partial unique index. On race-loss the query returns sql.ErrNoRows; we mark raceLost and exit the tx cleanly. b. SnapshotQuizQuestionsAndUpdateCount -- single CTE statement that bulk-inserts the snapshot rows AND updates the session's total_questions to the actual snapshot count. Single statement = single Postgres snapshot, so count and snapshot are guaranteed consistent even under concurrent quiz edits at READ COMMITTED isolation (gemini + copilot PR feedback). The returned int32 is the new total_questions; we sync it onto inserted in memory so the response carries the correct value without a round-trip.
- If raceLost: re-run FindIncompleteSession, return as 200 resume. The losing request never wrote a snapshot.
- Otherwise: return Created=true (201) with answers=[] (we skip ListSessionAnswers because a fresh session never has answers -- gemini PR feedback).
Why not lock the parent quiz row during the tx: the snapshot captures questions in a single statement, so a concurrent edit either lands entirely before our statement (and is in the snapshot) or entirely after (and is not). The CTE in SnapshotQuizQuestionsAndUpdateCount makes this race-free.
func (*Service) SubmitAnswer
func (s *Service) SubmitAnswer(ctx context.Context, p SubmitAnswerParams) (AnswerSummary, error)
SubmitAnswer records the user's answer to a single question in a practice session (ASK-137). The backend determines is_correct server-side -- the client never sends it -- and the verified flag reflects whether the validation was authoritative (true for MCQ/TF, false for freeform string-match).
Order of operations (single transaction):
- GetSessionForAnswerSubmission -- locked SELECT on the session row. Serializes against a concurrent SessionComplete (ASK-140 future) so the answer either commits before the completion (recorded) or after (rejected with 409). 404 if missing.
- 403 if user_id != viewer (info-leak prevention: 404 wins over 403, but the locked SELECT already returned the row, so we know the session exists).
- 409 if completed_at IS NOT NULL (no submissions on a completed session).
- CheckQuestionInSessionSnapshot -- 400 if the question is not part of this session's frozen snapshot (a question added to the quiz AFTER the session started, or a question whose snapshot row has question_id = NULL after the underlying question was hard-deleted).
- GetQuizQuestionByID -- load type + reference_answer.
- validateAndScoreAnswer -- per-type checks on the user's input AND determines is_correct + verified. Returns 400 on input violations (e.g., TF input not "true"/"false").
- InsertPracticeAnswer. The unique constraint uq_practice_answers_session_question catches duplicate submissions; we map the pgconn 23505 code to a typed 400 so the wire response is consistent with the spec.
- If is_correct, IncrementSessionCorrectAnswers. The same tx that wrote the answer row updates the counter, so the two can never disagree.
No auto-completion: even when this answer is the last unanswered question, the session stays in-progress. The client must explicitly call POST /sessions/{id}/complete (ASK-140 future).
type SessionDetail
SessionDetail is the domain payload returned by Service.StartSession (ASK-128). Mirrors the wire PracticeSessionResponse shape one-for-one so the handler mapper is near-mechanical.
Used for both the new-session (201) and resume (200) paths -- the shape is identical; the handler picks the status code based on StartSessionResult.Created.
type SessionDetail struct {
ID uuid.UUID
QuizID uuid.UUID
StartedAt time.Time
CompletedAt *time.Time
TotalQuestions int32
CorrectAnswers int32
// ScorePercentage is set by GetSession (ASK-152) for completed
// sessions and left nil for in-progress ones. StartSession
// (ASK-128) always leaves it nil because PracticeSessionResponse
// does not carry the field on the wire; the GetSession handler's
// SessionDetailResponse mapper is the only consumer that reads
// it. A pointer (vs int32 + IsValid bool) keeps the JSON wire
// rendering trivial: nil -> null, value -> integer.
ScorePercentage *int32
Answers []AnswerSummary
}
type SessionSummary
SessionSummary is the compact per-session payload returned in the ListSessions listing (ASK-149). Distinct from SessionDetail: no Answers slice (callers fetch them via GET /sessions/{id}) and no QuizID (the listing is already scoped to one quiz, so the wire shape omits it -- see SessionSummaryResponse in the openapi spec).
CompletedAt is a pointer because in-progress sessions have no completion timestamp; ScorePercentage is a pointer for the same reason -- the score is computed only when the session is finalised. Both render as JSON null on the wire when nil.
type SessionSummary struct {
ID uuid.UUID
StartedAt time.Time
CompletedAt *time.Time
TotalQuestions int32
CorrectAnswers int32
ScorePercentage *int32
}
type SessionsListCursor
SessionsListCursor is the keyset-pagination payload encoded into the opaque base64 cursor token. The list query orders by (started_at DESC, id DESC) so the cursor must carry both fields to disambiguate ties on started_at.
Both fields are non-pointer because both are always set when a cursor exists -- a nil *SessionsListCursor on ListSessionsParams means "no cursor", not "partially-empty cursor".
type SessionsListCursor struct {
StartedAt time.Time `json:"started_at"`
ID uuid.UUID `json:"id"`
}
func DecodeSessionsCursor
func DecodeSessionsCursor(s string) (SessionsListCursor, error)
DecodeSessionsCursor parses the base64-encoded token back into a SessionsListCursor. Errors here become HTTP 400 with the spec-mandated detail key {"cursor": "invalid cursor value"} -- see the handler's validation pass.
Validates that BOTH StartedAt and ID are populated. A cursor with a missing field would silently pass through and apply a no-op `(zero_time, nil_uuid) < (started_at, id)` predicate to the query (always true for any real row), effectively ignoring pagination -- so we reject it here as a 400 instead. coderabbit PR #158 feedback.
type StartSessionParams
StartSessionParams is the input to Service.StartSession (ASK-128). UserID is taken from the JWT in the handler -- the spec does not allow accepting a user id from the request body. QuizID is the path parameter.
type StartSessionParams struct {
UserID uuid.UUID
QuizID uuid.UUID
}
type StartSessionResult
StartSessionResult bundles the SessionDetail with a Created flag so the handler can choose 201 (created) vs 200 (resumed) without re-deriving the path from the session row's timestamps. Both paths return the same SessionDetail shape -- only the HTTP status code differs.
type StartSessionResult struct {
Session SessionDetail
Created bool
}
type SubmitAnswerParams
SubmitAnswerParams is the input to Service.SubmitAnswer (ASK-137). UserID is taken from the JWT in the handler -- the spec forbids accepting a user id from the request body. UserAnswer is the raw client input; the backend determines is_correct + verified itself based on the question type, so neither field is part of this struct.
type SubmitAnswerParams struct {
SessionID uuid.UUID
UserID uuid.UUID
QuestionID uuid.UUID
UserAnswer string
}
Generated by gomarkdoc