studyguides
import "github.com/Ask-Atlas/AskAtlas/api/internal/studyguides"
Package studyguides hosts the domain types, params, mappers, and service logic for the study-guide surface. Mirrors the layering of internal/courses -- repository interface + sqlc-backed impl, service with per-sort-variant dispatch, pointer-free domain types that the handler projects onto the generated wire schema.
Index
- Constants
- func EncodeCursor(c Cursor) (string, error)
- type AttachFileParams
- type AttachResourceParams
- type CastVoteParams
- type CastVoteResult
- type CreateStudyGuideParams
- type Creator
- type Cursor
- type DeleteStudyGuideParams
- type DetachFileParams
- type DetachResourceParams
- type FileAttachment
- type GetStudyGuideParams
- type GuideCourseSummary
- type GuideFile
- type GuideVote
- type ListStudyGuidesParams
- type ListStudyGuidesResult
- type Quiz
- type RecommendStudyGuideParams
- type Recommendation
- type RemoveRecommendationParams
- type RemoveVoteParams
- type Repository
- type Resource
- type ResourceType
- type Service
- func NewService(repo Repository) *Service
- func (s *Service) AssertCourseExists(ctx context.Context, courseID uuid.UUID) error
- func (s *Service) AttachFile(ctx context.Context, p AttachFileParams) (FileAttachment, error)
- func (s *Service) AttachResource(ctx context.Context, p AttachResourceParams) (Resource, error)
- func (s *Service) CastVote(ctx context.Context, p CastVoteParams) (CastVoteResult, error)
- func (s *Service) CreateStudyGuide(ctx context.Context, p CreateStudyGuideParams) (StudyGuideDetail, error)
- func (s *Service) DeleteStudyGuide(ctx context.Context, p DeleteStudyGuideParams) error
- func (s *Service) DetachFile(ctx context.Context, p DetachFileParams) error
- func (s *Service) DetachResource(ctx context.Context, p DetachResourceParams) error
- func (s *Service) GetStudyGuide(ctx context.Context, p GetStudyGuideParams) (StudyGuideDetail, error)
- func (s *Service) ListStudyGuides(ctx context.Context, p ListStudyGuidesParams) (ListStudyGuidesResult, error)
- func (s *Service) RecommendStudyGuide(ctx context.Context, p RecommendStudyGuideParams) (Recommendation, error)
- func (s *Service) RemoveRecommendation(ctx context.Context, p RemoveRecommendationParams) error
- func (s *Service) RemoveVote(ctx context.Context, p RemoveVoteParams) error
- func (s *Service) UpdateStudyGuide(ctx context.Context, p UpdateStudyGuideParams) (StudyGuideDetail, error)
- type SortDir
- type SortField
- type StudyGuide
- type StudyGuideDetail
- type UpdateStudyGuideParams
Constants
const (
SortFieldScore SortField = "score"
SortFieldViews SortField = "views"
SortFieldNewest SortField = "newest"
SortFieldUpdated SortField = "updated"
SortDirAsc SortDir = "asc"
SortDirDesc SortDir = "desc"
)
const (
// DefaultPageLimit matches the openapi.yaml default on page_limit.
DefaultPageLimit int32 = 25
// MaxPageLimit matches the openapi.yaml maximum on page_limit.
MaxPageLimit int32 = 100
// MaxSearchLength matches the openapi.yaml maxLength on q.
MaxSearchLength int = 200
// MaxTagLength matches the openapi.yaml items.maxLength on tag.
MaxTagLength int = 50
)
const (
// MaxTitleLength matches openapi.yaml CreateStudyGuideRequest.title.maxLength.
MaxTitleLength int = 500
// MaxDescriptionLength matches openapi.yaml CreateStudyGuideRequest.description.maxLength.
MaxDescriptionLength int = 2000
// MaxContentLength matches openapi.yaml CreateStudyGuideRequest.content.maxLength.
MaxContentLength int = 100000
// MaxTagsCount matches openapi.yaml CreateStudyGuideRequest.tags.maxItems.
MaxTagsCount int = 20
)
const (
// MaxResourceTitleLength matches openapi.yaml AttachResourceRequest.title.maxLength.
MaxResourceTitleLength int = 500
// MaxResourceURLLength matches openapi.yaml AttachResourceRequest.url.maxLength.
MaxResourceURLLength int = 2000
// MaxResourceDescriptionLength matches openapi.yaml AttachResourceRequest.description.maxLength.
MaxResourceDescriptionLength int = 1000
)
func EncodeCursor
func EncodeCursor(c Cursor) (string, error)
EncodeCursor serializes a Cursor into a base64-encoded JSON string.
type AttachFileParams
AttachFileParams is the input to Service.AttachFile (ASK-121). AttacherID comes from the JWT viewer; the request body is empty so the only inputs are the two path params + viewer. The service rejects non-owner attachers with 403 (only the file owner can put their files on guides).
type AttachFileParams struct {
StudyGuideID uuid.UUID
FileID uuid.UUID
AttacherID uuid.UUID
}
type AttachResourceParams
AttachResourceParams is the input to Service.AttachResource (ASK-111). Title and URL are required. Description is optional. Type defaults to ResourceTypeLink server-side when nil/empty -- the openapi schema declares the same default.
AttachedBy comes from the JWT viewer; the request body has no equivalent field (would be a privilege-attribution forge vector).
type AttachResourceParams struct {
StudyGuideID uuid.UUID
AttachedBy uuid.UUID
Title string
URL string
Type ResourceType
Description *string
}
type CastVoteParams
CastVoteParams is the input to Service.CastVote (ASK-139). ViewerID is taken from the JWT in the handler. Vote is the GuideVote enum declared in model.go (mirrors the vote_direction Postgres enum).
type CastVoteParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
Vote GuideVote
}
type CastVoteResult
CastVoteResult is the output of Service.CastVote. Returns the post-upsert state so the handler can build CastVoteResponse without re-querying.
type CastVoteResult struct {
Vote GuideVote
VoteScore int64
}
type CreateStudyGuideParams
CreateStudyGuideParams is the input to Service.CreateStudyGuide. 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).
type CreateStudyGuideParams struct {
CourseID uuid.UUID
CreatorID uuid.UUID
Title string
Description *string
Content *string
Tags []string
}
type Creator
Creator is the compact user payload embedded in StudyGuide. Same privacy floor as SectionMember in the courses package: id + first_name + last_name only, no email or clerk_id.
type Creator struct {
ID uuid.UUID
FirstName string
LastName string
}
type Cursor
Cursor is the polymorphic keyset cursor for ListStudyGuides. Only the fields relevant to the active sort are populated on encode; the rest stay nil and are omitted from the JSON token.
Score-sorted pages carry (VoteScore, ViewCount, UpdatedAt, ID) so the per-row aggregate vote_score is part of the strict total order. Views carries (ViewCount, UpdatedAt, ID). Newest carries (CreatedAt, ID). Updated carries (UpdatedAt, ID). ID is always the final tiebreaker.
type Cursor struct {
ID uuid.UUID `json:"id"`
VoteScore *int64 `json:"vote_score,omitempty"`
ViewCount *int64 `json:"view_count,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
func DecodeCursor
func DecodeCursor(s string) (Cursor, error)
DecodeCursor parses a base64-encoded JSON token back into a Cursor. Returns an error for malformed base64 or JSON; the handler maps that to a 400 VALIDATION_ERROR with the spec's "invalid cursor value".
type DeleteStudyGuideParams
DeleteStudyGuideParams is the input to Service.DeleteStudyGuide. ViewerID drives the creator-only authorization check; the service returns apperrors.NewForbidden if it doesn't match the row's creator_id.
type DeleteStudyGuideParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
}
type DetachFileParams
DetachFileParams is the input to Service.DetachFile (ASK-124). ViewerID drives the dual-authz check (file owner OR guide creator); broader than POST (which is owner-only) so a guide creator can curate their guide's attached files without owning every file.
type DetachFileParams struct {
StudyGuideID uuid.UUID
FileID uuid.UUID
ViewerID uuid.UUID
}
type DetachResourceParams
DetachResourceParams is the input to Service.DetachResource (ASK-116). ViewerID drives the dual-authz check (guide-creator OR attached_by); the service maps a non-match to apperrors.NewForbidden.
type DetachResourceParams struct {
StudyGuideID uuid.UUID
ResourceID uuid.UUID
ViewerID uuid.UUID
}
type FileAttachment
FileAttachment is the output of Service.AttachFile. Mirrors the study_guide_files join row -- intentionally narrow (no expanded file metadata) so the wire shape matches the join table and the privacy floor for full file metadata stays on GET /study-guides/{id}.
type FileAttachment struct {
FileID uuid.UUID
StudyGuideID uuid.UUID
CreatedAt time.Time
}
type GetStudyGuideParams
GetStudyGuideParams is the input to Service.GetStudyGuide. ViewerID is used to fetch the user's own vote on the guide (user_vote in the response). A missing viewer vote ships as nil on StudyGuideDetail.
type GetStudyGuideParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
}
type GuideCourseSummary
GuideCourseSummary is the compact course payload embedded in a StudyGuideDetail. Parallels EnrollmentCourse in the courses package but lives here so the two surfaces can evolve independently.
type GuideCourseSummary struct {
ID uuid.UUID
Department string
Number string
Title string
}
type GuideFile
GuideFile is the compact payload for a file attached to a study guide. Privacy floor: id + name + mime_type + size only. No user_id, no s3_key, no checksum.
type GuideFile struct {
ID uuid.UUID
Name string
MimeType string
Size int64
}
type GuideVote
GuideVote is the domain enum for a vote direction on a study guide. Mirrors the vote_direction Postgres enum and the openapi StudyGuideDetailResponse.user_vote enum.
type GuideVote string
const (
GuideVoteUp GuideVote = "up"
GuideVoteDown GuideVote = "down"
)
type ListStudyGuidesParams
ListStudyGuidesParams is the input to Service.ListStudyGuides.
type ListStudyGuidesParams struct {
CourseID uuid.UUID
Q *string
Tags []string
SortBy SortField
SortDir SortDir
Limit int32
Cursor *Cursor
}
type ListStudyGuidesResult
ListStudyGuidesResult is the output of Service.ListStudyGuides.
type ListStudyGuidesResult struct {
StudyGuides []StudyGuide
HasMore bool
NextCursor *string
}
type Quiz
Quiz is the compact payload for a quiz attached to a study guide. Only id + title + question_count -- no creator, no content, no scoring config. Detailed quiz fields are exposed by the quiz detail endpoint (future ticket ASK-142).
type Quiz struct {
ID uuid.UUID
Title string
QuestionCount int64
}
type RecommendStudyGuideParams
RecommendStudyGuideParams is the input to Service.RecommendStudyGuide (ASK-147). ViewerID identifies the recommender (taken from the JWT; never accepted from the body).
type RecommendStudyGuideParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
}
type Recommendation
Recommendation is the output of Service.RecommendStudyGuide. The nested Recommender uses the same Creator privacy floor as everywhere else in this package (id + first_name + last_name only).
type Recommendation struct {
StudyGuideID uuid.UUID
Recommender Creator
CreatedAt time.Time
}
type RemoveRecommendationParams
RemoveRecommendationParams is the input to Service.RemoveRecommendation (ASK-101). ViewerID identifies whose recommendation is being removed (always the JWT viewer).
type RemoveRecommendationParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
}
type RemoveVoteParams
RemoveVoteParams is the input to Service.RemoveVote (ASK-141). ViewerID identifies whose vote is being removed (always the JWT viewer; we never let one user remove another's vote).
type RemoveVoteParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
}
type Repository
Repository is the data-access surface required by Service. The 8 list methods correspond to the per-sort-variant sqlc queries -- sqlc cannot parameterize ORDER BY, so each (sort_by, sort_dir) combination has its own typed query. CourseExistsForGuides is the existence probe used by the handler to produce a 404 when the course is missing.
type Repository interface {
ListStudyGuidesScoreDesc(ctx context.Context, arg db.ListStudyGuidesScoreDescParams) ([]db.ListStudyGuidesScoreDescRow, error)
ListStudyGuidesScoreAsc(ctx context.Context, arg db.ListStudyGuidesScoreAscParams) ([]db.ListStudyGuidesScoreAscRow, error)
ListStudyGuidesViewsDesc(ctx context.Context, arg db.ListStudyGuidesViewsDescParams) ([]db.ListStudyGuidesViewsDescRow, error)
ListStudyGuidesViewsAsc(ctx context.Context, arg db.ListStudyGuidesViewsAscParams) ([]db.ListStudyGuidesViewsAscRow, error)
ListStudyGuidesNewestDesc(ctx context.Context, arg db.ListStudyGuidesNewestDescParams) ([]db.ListStudyGuidesNewestDescRow, error)
ListStudyGuidesNewestAsc(ctx context.Context, arg db.ListStudyGuidesNewestAscParams) ([]db.ListStudyGuidesNewestAscRow, error)
ListStudyGuidesUpdatedDesc(ctx context.Context, arg db.ListStudyGuidesUpdatedDescParams) ([]db.ListStudyGuidesUpdatedDescRow, error)
ListStudyGuidesUpdatedAsc(ctx context.Context, arg db.ListStudyGuidesUpdatedAscParams) ([]db.ListStudyGuidesUpdatedAscRow, error)
CourseExistsForGuides(ctx context.Context, id pgtype.UUID) (bool, error)
GetStudyGuideDetail(ctx context.Context, id pgtype.UUID) (db.GetStudyGuideDetailRow, error)
GetUserVoteForGuide(ctx context.Context, arg db.GetUserVoteForGuideParams) (db.VoteDirection, error)
ListGuideRecommenders(ctx context.Context, studyGuideID pgtype.UUID) ([]db.ListGuideRecommendersRow, error)
ListGuideQuizzesWithQuestionCount(ctx context.Context, studyGuideID pgtype.UUID) ([]db.ListGuideQuizzesWithQuestionCountRow, error)
ListGuideResources(ctx context.Context, studyGuideID pgtype.UUID) ([]db.ListGuideResourcesRow, error)
ListGuideFiles(ctx context.Context, studyGuideID pgtype.UUID) ([]db.ListGuideFilesRow, error)
InsertStudyGuide(ctx context.Context, arg db.InsertStudyGuideParams) (db.InsertStudyGuideRow, error)
GetStudyGuideByIDForUpdate(ctx context.Context, id pgtype.UUID) (db.GetStudyGuideByIDForUpdateRow, error)
SoftDeleteStudyGuide(ctx context.Context, id pgtype.UUID) error
SoftDeleteQuizzesForGuide(ctx context.Context, studyGuideID pgtype.UUID) error
UpdateStudyGuide(ctx context.Context, arg db.UpdateStudyGuideParams) error
GuideExistsAndLive(ctx context.Context, id pgtype.UUID) (bool, error)
UpsertStudyGuideVote(ctx context.Context, arg db.UpsertStudyGuideVoteParams) error
ComputeGuideVoteScore(ctx context.Context, studyGuideID pgtype.UUID) (int64, error)
DeleteStudyGuideVote(ctx context.Context, arg db.DeleteStudyGuideVoteParams) (int64, error)
ViewerCanRecommendForGuide(ctx context.Context, arg db.ViewerCanRecommendForGuideParams) (db.ViewerCanRecommendForGuideRow, error)
InsertStudyGuideRecommendation(ctx context.Context, arg db.InsertStudyGuideRecommendationParams) (db.InsertStudyGuideRecommendationRow, error)
DeleteStudyGuideRecommendation(ctx context.Context, arg db.DeleteStudyGuideRecommendationParams) (int64, error)
URLAlreadyAttachedToGuide(ctx context.Context, arg db.URLAlreadyAttachedToGuideParams) (bool, error)
UpsertResource(ctx context.Context, arg db.UpsertResourceParams) error
GetResourceByCreatorURL(ctx context.Context, arg db.GetResourceByCreatorURLParams) (db.GetResourceByCreatorURLRow, error)
InsertGuideResource(ctx context.Context, arg db.InsertGuideResourceParams) error
GetGuideResourceAttacher(ctx context.Context, arg db.GetGuideResourceAttacherParams) (pgtype.UUID, error)
DeleteGuideResource(ctx context.Context, arg db.DeleteGuideResourceParams) (int64, error)
GetFileForAttach(ctx context.Context, id pgtype.UUID) (db.GetFileForAttachRow, error)
InsertGuideFile(ctx context.Context, arg db.InsertGuideFileParams) (pgtype.Timestamptz, error)
GuideFileAttached(ctx context.Context, arg db.GuideFileAttachedParams) (bool, error)
DeleteGuideFile(ctx context.Context, arg db.DeleteGuideFileParams) (int64, error)
// InTx runs fn inside a single Postgres transaction. The Repository
// passed to fn is scoped to the tx; commits on a nil return,
// rolls back on any error. Used by DeleteStudyGuide for the
// atomic guide + child-quiz cascade.
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; same shape as files.NewSQLCRepository.
type Resource
Resource is the compact payload for an attached resource in the study-guide detail. No creator/uploader info -- the caller doesn't need to know who attached the resource.
type Resource struct {
ID uuid.UUID
Title string
URL string
Type ResourceType
Description *string
CreatedAt time.Time
}
type ResourceType
ResourceType mirrors the resource_type Postgres enum and the openapi ResourceSummary.type enum.
type ResourceType string
const (
ResourceTypeLink ResourceType = "link"
ResourceTypeVideo ResourceType = "video"
ResourceTypeArticle ResourceType = "article"
ResourceTypePDF ResourceType = "pdf"
)
type Service
Service is the business-logic layer for the study-guides 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. The queryTable is built once at construction so ListStudyGuides can dispatch by sort key with no per-request reflection or type switching.
func (*Service) AssertCourseExists
func (s *Service) AssertCourseExists(ctx context.Context, courseID uuid.UUID) error
AssertCourseExists is the 404-distinguishing preflight. Handler calls it before ListStudyGuides to emit a tailored "Course not found" 404 when the course_id doesn't resolve. Pulling the check here (rather than interpreting an empty result) matches the pattern from courses.Service.assertCourseAndSection.
func (*Service) AttachFile
func (s *Service) AttachFile(ctx context.Context, p AttachFileParams) (FileAttachment, error)
AttachFile attaches a file the viewer owns to a study guide (ASK-121). Single transaction wrapping:
- GuideExistsAndLive -- 404 if guide missing/soft-deleted.
- GetFileForAttach -- 404 if the file row doesn't exist.
- Owner check -- 403 if viewer is not the file's user_id. We deliberately surface 403 here (not 404) because the file EXISTS; conflating 'not yours' with 'not found' would hide ownership state from the caller in a way the spec doesn't want.
- Status + soft-delete check -- 404 if file isn't 'complete' or is in any deletion state. Pending/failed uploads aren't attachable; a half-uploaded file would render as broken on the frontend.
- InsertGuideFile (ON CONFLICT DO NOTHING + RETURNING) -- 409 if the (file, guide) pair is already attached. sql.ErrNoRows is the canonical signal for the no-op case here.
No attached_by tracking on the join row -- file ownership is already on files.user_id, and the dual-authz check on detach uses guide.creator_id + file.user_id directly.
func (*Service) AttachResource
func (s *Service) AttachResource(ctx context.Context, p AttachResourceParams) (Resource, error)
AttachResource attaches a URL-based resource to a study guide (ASK-111). Resources are community-contributed -- any authenticated viewer can attach.
Order of operations (single transaction):
- Validate input + canonicalize URL.
- GuideExistsAndLive -- 404 if missing or soft-deleted.
- URLAlreadyAttachedToGuide -- 409 if the URL is already on this guide (regardless of which resources row hosts it).
- UpsertResource (ON CONFLICT (creator_id, url) DO NOTHING) so a URL the viewer has used before reuses the existing row without overwriting its title / description / type.
- GetResourceByCreatorURL to fetch the (possibly-existing) row.
- InsertGuideResource to create the (resource_id, study_guide_id, attached_by) join row. PK violation here only fires on the narrow concurrency-race case where two attachers slip past step 3.
Returns the resource as a domain Resource (same shape as ListGuideResources rows) so the handler emits ResourceSummary without a separate read.
func (*Service) CastVote
func (s *Service) CastVote(ctx context.Context, p CastVoteParams) (CastVoteResult, error)
CastVote upserts the viewer's vote on a guide (ASK-139). Same- direction re-submits are no-ops at the SQL layer (the upsert WHERE clause skips the row update); opposite-direction submits flip the vote. After the upsert, the post-mutation vote_score is recomputed and returned so the UI can patch its local state without a follow- up GET.
Order of checks:
- Validate the requested vote direction (up | down).
- GuideExistsAndLive -> 404 if missing or soft-deleted.
- UpsertStudyGuideVote.
- ComputeGuideVoteScore.
The two SQL calls are NOT wrapped in a transaction: the upsert is already atomic per-row (PK on (user_id, study_guide_id)) and the score recomputation is a snapshot read -- a concurrent vote from a different user that lands between the upsert and the recompute is fine to be reflected in the response.
func (*Service) CreateStudyGuide
func (s *Service) CreateStudyGuide(ctx context.Context, p CreateStudyGuideParams) (StudyGuideDetail, error)
CreateStudyGuide creates a new study guide owned by the authenticated user. Runs AssertCourseExists so a missing course surfaces as a tailored 404 (rather than a generic FK-violation 500). After the insert, hydrates the response via GetStudyGuideDetail so vote_score and is_recommended come out of the same SQL projection used by GET /study-guides/{id} -- the privacy floor stays in one place.
The 5 sibling queries from GetStudyGuide (recommenders, quizzes, resources, files, user_vote) are intentionally skipped: a freshly inserted guide has no children, no votes, and no recommenders by definition. The corresponding response slices are emitted as empty (non-nil) so the JSON wire shape is `[]` and not `null`.
func (*Service) DeleteStudyGuide
func (s *Service) DeleteStudyGuide(ctx context.Context, p DeleteStudyGuideParams) error
DeleteStudyGuide soft-deletes a study guide (creator-only). Wraps the locked SELECT + soft-delete + child-quiz cascade in a single transaction via repo.InTx so the cascade is atomic: either both the guide and its quizzes get deleted_at set, or neither does. The SELECT FOR UPDATE in GetStudyGuideByIDForUpdate prevents two concurrent deletes from racing on the same guide -- one wins with 204, the other sees the row already-deleted in its tx snapshot and returns 404.
404 is returned both when the guide is missing and when it's already soft-deleted (idempotent semantics -- a duplicate DELETE shouldn't surface a 409 since the desired state is reached). 403 is returned when the viewer is not the guide's creator.
func (*Service) DetachFile
func (s *Service) DetachFile(ctx context.Context, p DetachFileParams) error
DetachFile removes the (file, guide) join row (ASK-124). The file itself is preserved. Single transaction wrapping:
- GetStudyGuideByIDForUpdate -- locked SELECT. 404 if guide missing/soft-deleted.
- GetFileForAttach -- 404 if file missing. We re-use the same query as Attach because it returns the user_id we need for the dual-authz comparison.
- GuideFileAttached -- 404 if the file isn't attached to this guide. Short-circuits BEFORE the dual-authz check so 'not attached' beats 'forbidden' on response.
- Dual-authz -- viewer must be EITHER the guide creator OR the file owner. Otherwise 403.
- DeleteGuideFile (:execrows) -- 0 rows means a parallel detach raced ahead, still 404 to match the get-then-delete contract.
func (*Service) DetachResource
func (s *Service) DetachResource(ctx context.Context, p DetachResourceParams) error
DetachResource removes the join row between a resource and a guide (ASK-116). Authorization: viewer must be EITHER the guide's creator OR the user who attached this particular resource. Even the resource's own creator can't detach if they didn't attach it here.
Order of operations (single transaction):
- Lock the guide row + check live -- 404 if missing/deleted.
- Look up the join's attached_by -- 404 if not attached here.
- Dual-authz -- 403 if viewer is neither guide creator nor attacher.
- DeleteGuideResource -- 0 rows means a concurrent detach already removed the join (still a valid 'desired state reached' case but we return 404 to match the get-then-delete race contract).
The resources row is preserved -- it may be attached to other guides + courses. Detach never cascades to the resource itself.
func (*Service) GetStudyGuide
func (s *Service) GetStudyGuide(ctx context.Context, p GetStudyGuideParams) (StudyGuideDetail, error)
GetStudyGuide fetches the full study-guide detail including nested course + creator + recommenders + quizzes + resources + files + the viewer's own vote state.
This is a pure read -- no view-count increment, no last-viewed upsert, no mutation. View tracking will live on its own dedicated POST (future ticket mirroring ASK-134's POST /files/{id}/view).
Runs 6 queries total: GetStudyGuideDetail (404 candidate, must complete before the rest fan out so we don't make 5 speculative round trips for a guide that doesn't exist), then the 5 independent sibling queries (user_vote, recommenders, quizzes, resources, files) issued in parallel via errgroup. The 5-wide fan-out cuts end-to-end latency from sum(query_time) to max(query_time) while preserving the strict error-propagation semantics -- any sibling error short-circuits the rest via ctx cancellation and returns a 500 to the handler.
Soft-deleted guides return apperrors.ErrNotFound via the underlying query's WHERE sg.deleted_at IS NULL. The handler maps that to a 404 'Study guide not found'.
func (*Service) ListStudyGuides
func (s *Service) ListStudyGuides(ctx context.Context, p ListStudyGuidesParams) (ListStudyGuidesResult, error)
ListStudyGuides returns a paginated list of study guides for a course. The handler runs the AssertCourseExists preflight before calling this to map missing courses to 404; by the time we get here, the course is known to exist.
Defensive validation mirrors the courses service: limit is clamped to [1, MaxPageLimit], sort defaults to score/desc, tags are bounded by MaxTagLength, q by MaxSearchLength. oapi-codegen enforces these at the wrapper layer in production; the service re-validates so direct Go callers can't bypass the contract.
func (*Service) RecommendStudyGuide
func (s *Service) RecommendStudyGuide(ctx context.Context, p RecommendStudyGuideParams) (Recommendation, error)
RecommendStudyGuide records that the viewer (an instructor or TA in the guide's course) recommends the guide (ASK-147).
Order of checks:
- ViewerCanRecommendForGuide -> 404 if guide missing/deleted, 403 if viewer lacks instructor/ta role in any section of the guide's course.
- InsertStudyGuideRecommendation -> 409 if the (guide, viewer) row already exists (the SQL uses ON CONFLICT DO NOTHING + RETURNING so a duplicate surfaces as sql.ErrNoRows on the joined SELECT, which we map to a typed Conflict AppError).
Authorization is "any current elevated-role section in the course" per the spec -- holding student in some sections does not block the action as long as instructor/ta is held in at least one.
func (*Service) RemoveRecommendation
func (s *Service) RemoveRecommendation(ctx context.Context, p RemoveRecommendationParams) error
RemoveRecommendation hard-deletes the viewer's recommendation row on a guide (ASK-101). Authorization mirrors the POST side: viewer must currently hold instructor/ta in the guide's course (a former TA who lost the role can't manage their old recommendations -- the policy is "current elevated-role users only").
Order of checks:
- ViewerCanRecommendForGuide -> 404 if guide missing/deleted, 403 if viewer lacks instructor/ta role.
- DeleteStudyGuideRecommendation -> 404 'Recommendation not found' if rows-affected is 0 (viewer never recommended this guide).
func (*Service) RemoveVote
func (s *Service) RemoveVote(ctx context.Context, p RemoveVoteParams) error
RemoveVote hard-deletes the viewer's vote row on a guide (ASK-141). 404 covers BOTH "guide missing/deleted" and "no existing vote" -- both surface as the same status by design (the desired end state is "no vote", which is already true in either case from the caller's point of view). The guide-existence check runs first so the more-specific "Study guide not found" message wins when both conditions are true.
func (*Service) UpdateStudyGuide
func (s *Service) UpdateStudyGuide(ctx context.Context, p UpdateStudyGuideParams) (StudyGuideDetail, error)
UpdateStudyGuide partially updates a guide (ASK-129). Only the fields provided as non-nil pointers in p are touched; absent fields preserve their current values via the SQL's COALESCE(narg, current) pattern.
Order of checks (in a single transaction):
- validateUpdateParams -- per-field caps + at-least-one-field rule.
- Normalize provided fields (trim title; trim+drop-empty for description/content matching CreateStudyGuide; normalize tags).
- GetStudyGuideByIDForUpdate -- locked SELECT inside the tx so a concurrent delete can't race the update.
- 404 if missing or already soft-deleted.
- 403 if creator_id != viewer_id.
- UpdateStudyGuide.
After the tx commits, re-hydrates the full StudyGuideDetail via GetStudyGuide so the response includes the viewer's vote, the recommenders, quizzes, resources, and files (same wire shape as GET /study-guides/{id}). The 5-way sibling fan-out is reused from the read path -- no parallel projection logic to keep in sync with GET.
Description/content trim semantics: a body field of " " is trimmed to "" and treated as "no update on this field" rather than persisting whitespace. Mirrors CreateStudyGuide; users can't clear description/content via this endpoint (would need a separate clear endpoint to distinguish "absent" from "set to NULL").
type SortDir
SortDir is the direction of a list query.
type SortDir string
type SortField
SortField identifies the column the ListStudyGuides query orders by.
type SortField string
type StudyGuide
StudyGuide is the list-row domain type returned by Service.ListStudyGuides. Excludes `content` (only on the get-by-id endpoint) to keep the list payload small.
type StudyGuide struct {
ID uuid.UUID
Title string
Description *string
Tags []string
Creator Creator
CourseID uuid.UUID
VoteScore int64
ViewCount int64
IsRecommended bool
QuizCount int64
CreatedAt time.Time
UpdatedAt time.Time
}
type StudyGuideDetail
StudyGuideDetail is the get-by-id domain type: the full guide with all embedded nested arrays + the viewer's own vote state. UserVote is *GuideVote so the wire shape can emit JSON null when the viewer has not voted, distinguishing "not voted" from "voted with empty string".
type StudyGuideDetail struct {
ID uuid.UUID
Title string
Description *string
Content *string
Tags []string
Creator Creator
Course GuideCourseSummary
VoteScore int64
UserVote *GuideVote
ViewCount int64
IsRecommended bool
RecommendedBy []Creator
Quizzes []Quiz
Resources []Resource
Files []GuideFile
CreatedAt time.Time
UpdatedAt time.Time
}
type UpdateStudyGuideParams
UpdateStudyGuideParams is the input to Service.UpdateStudyGuide (ASK-129). Every updatable field is a pointer so a nil value reliably encodes "field absent in the request body" -- distinct from "field provided as empty/zero". The service rejects an all-nil-fields call as 400 'at least one field required' before SQL.
Tag semantics: nil = don't touch existing tags; non-nil (even length 0) = REPLACE existing tags with the given list (after normalization).
type UpdateStudyGuideParams struct {
StudyGuideID uuid.UUID
ViewerID uuid.UUID
Title *string
Description *string
Content *string
Tags *[]string
}
Generated by gomarkdoc