quizzes
import "github.com/Ask-Atlas/AskAtlas/api/internal/quizzes"
Package quizzes hosts the domain types, params, mappers, and service logic for the quizzes surface. Mirrors the layering of internal/studyguides -- repository interface + sqlc-backed impl, service that owns transactions + per-type validation, pointer-free domain types that the handler projects onto the generated wire schema.
Index
- Constants
- type CreateQuizMCQOptionInput
- type CreateQuizParams
- type CreateQuizQuestionInput
- type Creator
- type DeleteQuizParams
- type ListQuizzesParams
- type MCQOption
- type Question
- type QuestionType
- type QuizDetail
- type QuizListItem
- type Repository
- type Service
- func NewService(repo Repository) *Service
- func (s *Service) CreateQuiz(ctx context.Context, p CreateQuizParams) (QuizDetail, error)
- func (s *Service) DeleteQuiz(ctx context.Context, p DeleteQuizParams) error
- func (s *Service) ListQuizzes(ctx context.Context, p ListQuizzesParams) ([]QuizListItem, error)
- func (s *Service) UpdateQuiz(ctx context.Context, p UpdateQuizParams) (QuizDetail, error)
- type UpdateQuizParams
Constants
Field-length / collection caps. Each constant mirrors the equivalent openapi schema constraint -- duplicated here so the service layer can re-validate Go callers (including tests) that bypass the wrapper layer's enforcement.
const (
// MaxTitleLength matches openapi CreateQuizRequest.title.maxLength.
MaxTitleLength int = 500
// MaxDescriptionLength matches openapi CreateQuizRequest.description.maxLength.
MaxDescriptionLength int = 2000
// MinQuestionsCount matches openapi CreateQuizRequest.questions.minItems.
MinQuestionsCount int = 1
// MaxQuestionsCount matches openapi CreateQuizRequest.questions.maxItems.
MaxQuestionsCount int = 100
// MaxQuestionLength matches openapi CreateQuizQuestion.question.maxLength.
MaxQuestionLength int = 2000
// MaxHintLength matches openapi CreateQuizQuestion.hint.maxLength.
MaxHintLength int = 1000
// MaxFeedbackLength matches openapi CreateQuizQuestion.feedback_correct/incorrect.maxLength.
MaxFeedbackLength int = 1000
// MinMCQOptions matches openapi CreateQuizQuestion.options.minItems for multiple-choice.
MinMCQOptions int = 2
// MaxMCQOptions matches openapi CreateQuizQuestion.options.maxItems for multiple-choice.
MaxMCQOptions int = 10
// MaxOptionTextLength matches openapi CreateQuizMCQOption.text.maxLength.
MaxOptionTextLength int = 500
// MaxFreeformAnswerLength caps the reference answer string for
// freeform questions. Matches the spec's "max 500 chars" wording
// (the openapi schema does not enforce it directly because
// correct_answer is polymorphic; service-side enforcement is the
// authoritative gate).
MaxFreeformAnswerLength int = 500
)
True/false canonical option labels. The write side inserts two quiz_answer_options rows with these texts (sort_order 0 and 1 respectively); the read side identifies the canonical answer by matching the option text against TrueFalseOptionTrue. Defining the labels in one place keeps the write-side and read-side from drifting out of sync (a typo on either side would silently break correct-answer resolution at runtime).
const (
TrueFalseOptionTrue = "True"
TrueFalseOptionFalse = "False"
)
type CreateQuizMCQOptionInput
CreateQuizMCQOptionInput is the per-option payload on a single MCQ question in CreateQuizParams. The service trims each option's text and rejects empty-after-trim values with a per-question per-option 400.
type CreateQuizMCQOptionInput struct {
Text string
IsCorrect bool
}
type CreateQuizParams
CreateQuizParams is the input to Service.CreateQuiz. CreatorID is taken from the JWT in the handler -- the spec explicitly forbids accepting a creator id from the request body (would be a privilege-attribution forge vector, same rule as studyguides).
type CreateQuizParams struct {
StudyGuideID uuid.UUID
CreatorID uuid.UUID
Title string
Description *string
Questions []CreateQuizQuestionInput
}
type CreateQuizQuestionInput
CreateQuizQuestionInput is the per-question payload on CreateQuizParams. The polymorphic CorrectAnswer field carries the raw wire value (interface{}) and is type-asserted by the service: bool for true-false, string for freeform, ignored for multiple-choice. Options is meaningful only on MCQ.
SortOrder is a pointer so the service can distinguish "client supplied an explicit value" (preserve) from "client omitted" (default to the array index). The CreateQuizQuestion openapi shape declares sort_order as optional; we want to honor the caller's explicit zero when given.
type CreateQuizQuestionInput struct {
Type QuestionType
Question string
Options []CreateQuizMCQOptionInput
CorrectAnswer any
Hint *string
FeedbackCorrect *string
FeedbackIncorrect *string
SortOrder *int32
}
type Creator
Creator is the compact user payload embedded in QuizDetail. Same privacy floor as studyguides.Creator: id + first_name + last_name only, no email or clerk_id. Defined here (not imported) so the quizzes package can evolve independently of the studyguides surface.
type Creator struct {
ID uuid.UUID
FirstName string
LastName string
}
type DeleteQuizParams
DeleteQuizParams is the input to Service.DeleteQuiz (ASK-102). ViewerID drives the creator-only authorization gate; the service returns apperrors.NewForbidden if it doesn't match the row's creator_id.
type DeleteQuizParams struct {
QuizID uuid.UUID
ViewerID uuid.UUID
}
type ListQuizzesParams
ListQuizzesParams is the input to Service.ListQuizzes (ASK-136). No filters / pagination -- the endpoint returns every non-deleted quiz on the guide. ViewerID is intentionally absent: the spec has no per-viewer access control beyond authentication, and the privacy-floor creator info is uniform across all callers.
type ListQuizzesParams struct {
StudyGuideID uuid.UUID
}
type MCQOption
MCQOption is a single multiple-choice option as it lives in the database. The wire shape collapses this into a string slice (options) plus the resolved correct_answer string -- the mapper in handlers/quizzes.go handles that projection.
type MCQOption struct {
ID uuid.UUID
Text string
IsCorrect bool
SortOrder int32
}
type Question
Question is the domain payload for a single quiz question. The CorrectAnswer field is intentionally polymorphic to mirror the wire shape (string for MCQ + freeform, bool for TF); the handler renders it directly into the api.QuizQuestionResponse interface{} field. Options is empty for non-MCQ types.
type Question struct {
ID uuid.UUID
Type QuestionType
Question string
Options []MCQOption
CorrectAnswer any
Hint *string
FeedbackCorrect *string
FeedbackIncorrect *string
SortOrder int32
}
type QuestionType
QuestionType is the domain enum for a quiz question's kind. Values are the kebab-case wire form (matching openapi); the mapper translates to the snake_case Postgres question_type enum at the SQL boundary.
type QuestionType string
const (
QuestionTypeMultipleChoice QuestionType = "multiple-choice"
QuestionTypeTrueFalse QuestionType = "true-false"
QuestionTypeFreeform QuestionType = "freeform"
)
type QuizDetail
QuizDetail is the domain payload for the full quiz returned by CreateQuiz (and the future GetQuiz endpoint). Mirrors the wire QuizDetailResponse shape one-for-one so the handler mapper is near-mechanical.
type QuizDetail struct {
ID uuid.UUID
StudyGuideID uuid.UUID
Title string
Description *string
Creator Creator
Questions []Question
CreatedAt time.Time
UpdatedAt time.Time
}
type QuizListItem
QuizListItem is the row-level domain payload returned by Service.ListQuizzes (ASK-136). Richer than studyguides.Quiz (which embeds in StudyGuideDetailResponse and intentionally stays minimal): includes the creator + description + timestamps so the practice page can render the quiz card without a follow-up GET.
type QuizListItem struct {
ID uuid.UUID
Title string
Description *string
QuestionCount int64
Creator Creator
CreatedAt time.Time
UpdatedAt time.Time
}
type Repository
Repository is the data-access surface required by Service. Mirrors the studyguides.Repository pattern -- the production implementation is sqlc-backed and lives in sqlc_repository.go; tests inject a mockery-generated mock.
type Repository interface {
GuideExistsAndLiveForQuizzes(ctx context.Context, id pgtype.UUID) (bool, error)
InsertQuiz(ctx context.Context, arg db.InsertQuizParams) (db.InsertQuizRow, error)
InsertQuizQuestion(ctx context.Context, arg db.InsertQuizQuestionParams) (pgtype.UUID, error)
InsertQuizAnswerOption(ctx context.Context, arg db.InsertQuizAnswerOptionParams) error
GetQuizDetail(ctx context.Context, id pgtype.UUID) (db.GetQuizDetailRow, error)
ListQuizQuestionsByQuiz(ctx context.Context, quizID pgtype.UUID) ([]db.ListQuizQuestionsByQuizRow, error)
ListQuizAnswerOptionsByQuiz(ctx context.Context, quizID pgtype.UUID) ([]db.QuizAnswerOption, error)
ListQuizzesByStudyGuide(ctx context.Context, studyGuideID pgtype.UUID) ([]db.ListQuizzesByStudyGuideRow, error)
GetQuizByIDForUpdate(ctx context.Context, id pgtype.UUID) (db.GetQuizByIDForUpdateRow, error)
SoftDeleteQuiz(ctx context.Context, id pgtype.UUID) error
GetQuizForUpdateWithParentStatus(ctx context.Context, id pgtype.UUID) (db.GetQuizForUpdateWithParentStatusRow, error)
UpdateQuiz(ctx context.Context, arg db.UpdateQuizParams) 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. Commits on a nil return; rolls back on any
// error. Used by CreateQuiz for the atomic quiz + N questions +
// M options write.
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 quizzes 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) CreateQuiz
func (s *Service) CreateQuiz(ctx context.Context, p CreateQuizParams) (QuizDetail, error)
CreateQuiz creates a quiz with all its questions and answer options atomically (ASK-150). Validates the request thoroughly BEFORE opening the transaction so a 400 doesn't waste a tx slot; inside the tx, gates on the parent guide being live, then writes the quiz row, each question row, and each answer option row.
Validation runs in two passes:
- validateCreateParams: top-level (title, questions count) and per-question (type / question text / options / correct_answer well-formedness). Failures surface as 400 with field-level details keyed by `questions[i].field` so the frontend can highlight the offending input.
- The tx body trusts the validated params and just writes.
True/false questions auto-expand to 2 quiz_answer_options rows ("True", "False") with the matching is_correct flag. Freeform questions store the reference answer on quiz_questions.reference_answer and create no options rows. MCQ stores the per-option text + is_correct directly.
After the tx commits, hydrates the response by loading the quiz + creator (privacy floor) + questions + options via three separate reads. The two-list (questions + options) fan-out matches the studyguides detail pattern; mapping options back onto questions happens in Go via group-by-question_id.
func (*Service) DeleteQuiz
func (s *Service) DeleteQuiz(ctx context.Context, p DeleteQuizParams) error
DeleteQuiz soft-deletes a quiz (creator-only, ASK-102). Wraps the locked SELECT + creator check + soft-delete in a single transaction so a concurrent delete cannot race the auth check (one wins with 204, the other sees the row already-deleted in its tx snapshot and returns 404).
404 is returned both when the quiz is missing and when it's already soft-deleted (idempotent semantics: a duplicate DELETE does not surface a 409 since the desired state is already reached). 403 is returned when the viewer is not the quiz's creator. The order of checks is "missing/deleted -> creator mismatch -> proceed", so a 404 wins over a 403 when both apply (a non-creator probing a deleted quiz can't distinguish "no such quiz" from "you can't touch this quiz").
No cascade: practice sessions, questions, and answer options stay intact. The quiz simply becomes invisible to the list/ detail endpoints (which all filter q.deleted_at IS NULL). This preserves historical practice data per the spec.
func (*Service) ListQuizzes
func (s *Service) ListQuizzes(ctx context.Context, p ListQuizzesParams) ([]QuizListItem, error)
ListQuizzes returns every non-soft-deleted quiz attached to a study guide (ASK-136). The order is created_at DESC with id DESC as tiebreaker (matches the SQL ORDER BY in ListQuizzesByStudyGuide). Returns an empty (non-nil) slice when the guide has no quizzes; the handler renders that as `[]`.
Order of operations:
- GuideExistsAndLiveForQuizzes -- 404 if missing or soft-deleted. Done BEFORE the list query so a soft-deleted guide returns 404 even when the list query would have returned an empty array (the spec is explicit on this: "soft-deleted guide -> 404, never 200 empty").
- ListQuizzesByStudyGuide.
No transaction wrapping -- both reads are snapshot-safe and a race where a guide gets soft-deleted between the live check and the list returns the live-time list, which is acceptable eventual-consistency behavior for a read endpoint.
func (*Service) UpdateQuiz
func (s *Service) UpdateQuiz(ctx context.Context, p UpdateQuizParams) (QuizDetail, error)
UpdateQuiz partially updates a quiz's title and/or description (ASK-153, creator-only). At least one field must be provided (an empty body is a 400 before SQL is touched).
Order of operations (single transaction):
- validateUpdateParams -- per-field caps + at-least-one-field rule.
- GetQuizForUpdateWithParentStatus -- locked SELECT inside the tx so a concurrent delete cannot race the update.
- 404 if quiz missing OR quiz soft-deleted OR parent guide soft-deleted (per spec AC5 + AC6).
- 403 if creator_id != viewer_id.
- UpdateQuiz -- COALESCE on title; CASE on description (so null clears, absent leaves alone).
After the tx commits, re-hydrates the full QuizDetail via the shared hydrate path used by CreateQuiz so the response carries the same wire shape (QuizDetailResponse) as the create endpoint.
Title trim semantics: a body field of " " is rejected by validateUpdateQuizParams (must not be empty after trim). When set, the trimmed value is what gets persisted. Description trim semantics on an EXPLICIT clear (the JSON key was present): " " is downgraded to NULL so the DB never stores a whitespace-only description -- the caller's intent on `{"description":" "}` is clearly "I want this gone", and the trim+downgrade keeps the column from carrying a meaningless blank value. When the key is absent (ClearDescription=false), description is left alone -- no trim, no write.
type UpdateQuizParams
UpdateQuizParams is the input to Service.UpdateQuiz (ASK-153). Tri-state semantics for description require an explicit ClearDescription flag because Go cannot distinguish "field absent in JSON" from "field explicitly null" with a *string alone (both decode to nil pointers). The handler decodes the raw request body to detect the description key's presence and drives the params accordingly:
- Title nil -> column unchanged
- Title non-nil -> set to value (after trim)
- ClearDescription false -> column unchanged
- ClearDescription true, Description nil -> column cleared (set to NULL)
- ClearDescription true, Description set -> column set to value (after trim)
The service rejects an all-nil-fields call as 400 'at least one field required' before SQL.
type UpdateQuizParams struct {
QuizID uuid.UUID
ViewerID uuid.UUID
Title *string
ClearDescription bool
Description *string
}
Generated by gomarkdoc