courses
import "github.com/Ask-Atlas/AskAtlas/api/internal/courses"
Index
- Constants
- func EncodeCursor(c Cursor) (string, error)
- func EncodeMemberCursor(c MemberCursor) (string, error)
- type CheckMembershipParams
- type Course
- type CourseDetail
- type Cursor
- type Enrollment
- type EnrollmentCourse
- type EnrollmentSchool
- type EnrollmentSection
- type GetCourseParams
- type JoinSectionParams
- type LeaveSectionParams
- type ListCoursesParams
- type ListCoursesResult
- type ListMyEnrollmentsParams
- type ListSectionMembersParams
- type ListSectionMembersResult
- type MemberCursor
- type MemberRole
- type Membership
- type MembershipCheck
- type Repository
- type SchoolSummary
- type Section
- type SectionMember
- type Service
- func NewService(repo Repository) *Service
- func (s *Service) CheckMembership(ctx context.Context, p CheckMembershipParams) (MembershipCheck, error)
- func (s *Service) GetCourse(ctx context.Context, p GetCourseParams) (CourseDetail, error)
- func (s *Service) JoinSection(ctx context.Context, p JoinSectionParams) (Membership, error)
- func (s *Service) LeaveSection(ctx context.Context, p LeaveSectionParams) error
- func (s *Service) ListCourses(ctx context.Context, p ListCoursesParams) (ListCoursesResult, error)
- func (s *Service) ListMyEnrollments(ctx context.Context, p ListMyEnrollmentsParams) ([]Enrollment, error)
- func (s *Service) ListSectionMembers(ctx context.Context, p ListSectionMembersParams) (ListSectionMembersResult, error)
- type SortDir
- type SortField
Constants
const (
SortFieldDepartment SortField = "department"
SortFieldNumber SortField = "number"
SortFieldTitle SortField = "title"
SortFieldCreatedAt SortField = "created_at"
SortDirAsc SortDir = "asc"
SortDirDesc SortDir = "desc"
)
const (
// DefaultPageLimit is applied when the caller does not specify page_limit
// (or specifies 0). Matches the openapi.yaml default.
DefaultPageLimit int32 = 25
// MaxPageLimit caps the per-page result count. Matches the openapi.yaml maximum.
MaxPageLimit int32 = 100
// MaxSearchLength caps the q parameter length. Matches the openapi.yaml maxLength.
MaxSearchLength int = 200
// MaxDepartmentLength caps the department filter length. Matches openapi.yaml.
MaxDepartmentLength int = 20
)
MaxTermLength matches the openapi.yaml maxLength on the term filter.
const MaxTermLength int = 30
func EncodeCursor
func EncodeCursor(c Cursor) (string, error)
EncodeCursor serializes a Cursor into a base64-encoded string token.
func EncodeMemberCursor
func EncodeMemberCursor(c MemberCursor) (string, error)
EncodeMemberCursor serializes a MemberCursor into a base64-encoded string token. Mirrors EncodeCursor in shape so the wire contract stays consistent for a future client library.
type CheckMembershipParams
CheckMembershipParams is the input to Service.CheckMembership. Mirrors JoinSectionParams structurally because both go through the same course + section preflight before the per-membership query.
type CheckMembershipParams struct {
CourseID uuid.UUID
SectionID uuid.UUID
UserID uuid.UUID
}
type Course
Course is the list-view domain type: course metadata plus the embedded school summary needed to render a course card without a second round-trip.
type Course struct {
ID uuid.UUID
School SchoolSummary
Department string
Number string
Title string
Description *string
CreatedAt time.Time
}
type CourseDetail
CourseDetail is the get-by-id domain type: a Course plus the inline list of all its sections (always non-nil; empty slice when the course has none).
type CourseDetail struct {
Course
Sections []Section
}
type Cursor
Cursor is the opaque pagination token. It carries every possible sort field because the wire format must round-trip across pages without the client needing to know which sort the previous page used. Only the field matching the active SortField is populated on encode.
Department-sorted pages populate both Department and Number (composite cursor) since (department, id) alone would skip rows in the same department; (department, number, id) is a strict total order over the courses table.
type Cursor struct {
ID uuid.UUID `json:"id"`
Department *string `json:"department,omitempty"`
Number *string `json:"number,omitempty"`
Title *string `json:"title,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func DecodeCursor
func DecodeCursor(s string) (Cursor, error)
DecodeCursor parses a base64-encoded string token back into a Cursor.
type Enrollment
Enrollment is the dashboard row returned by Service.ListMyEnrollments: one section the viewer is enrolled in, with just enough course and school context to render a card without a follow-up request.
type Enrollment struct {
Section EnrollmentSection
Course EnrollmentCourse
School EnrollmentSchool
Role MemberRole
JoinedAt time.Time
}
type EnrollmentCourse
EnrollmentCourse is the compact course payload embedded in an Enrollment. No description/created_at -- the course detail endpoint is the source of truth for those.
type EnrollmentCourse struct {
ID uuid.UUID
Department string
Number string
Title string
}
type EnrollmentSchool
EnrollmentSchool is the *very* compact school payload embedded in an Enrollment. Only id + acronym -- the dashboard renders an acronym badge per row, and pulling the full SchoolSummary would bloat the payload for users in many courses.
type EnrollmentSchool struct {
ID uuid.UUID
Acronym string
}
type EnrollmentSection
EnrollmentSection is the compact section payload embedded in an Enrollment. Mirrors api.EnrollmentSectionSummary on the wire.
type EnrollmentSection struct {
ID uuid.UUID
Term string
SectionCode *string
InstructorName *string
}
type GetCourseParams
GetCourseParams is the input to Service.GetCourse.
type GetCourseParams struct {
CourseID uuid.UUID
}
type JoinSectionParams
JoinSectionParams is the input to Service.JoinSection. The CourseID is validated against the SectionID to avoid the cross-course path-traversal case (a real section UUID under a different course's URL).
type JoinSectionParams struct {
CourseID uuid.UUID
SectionID uuid.UUID
UserID uuid.UUID
}
type LeaveSectionParams
LeaveSectionParams is the input to Service.LeaveSection. Same path validation invariants as JoinSectionParams.
type LeaveSectionParams struct {
CourseID uuid.UUID
SectionID uuid.UUID
UserID uuid.UUID
}
type ListCoursesParams
ListCoursesParams is the input to Service.ListCourses.
type ListCoursesParams struct {
SchoolID *uuid.UUID
Department *string
Q *string
SortBy SortField
SortDir SortDir
Limit int32
Cursor *Cursor
}
type ListCoursesResult
ListCoursesResult is the output of Service.ListCourses.
type ListCoursesResult struct {
Courses []Course
HasMore bool
NextCursor *string
}
type ListMyEnrollmentsParams
ListMyEnrollmentsParams is the input to Service.ListMyEnrollments. Term and Role are optional filters; when nil the corresponding sqlc narg short-circuits its WHERE branch.
type ListMyEnrollmentsParams struct {
UserID uuid.UUID
Term *string
Role *MemberRole
}
type ListSectionMembersParams
ListSectionMembersParams is the input to Service.ListSectionMembers. Cursor is its own dedicated type rather than reusing the courses Cursor because the keyset shape is different (joined_at + user_id vs the per-sort polymorphic course cursor); keeping it isolated avoids polluting the existing Cursor with member-roster fields.
type ListSectionMembersParams struct {
CourseID uuid.UUID
SectionID uuid.UUID
Role *MemberRole
Limit int32
Cursor *MemberCursor
}
type ListSectionMembersResult
ListSectionMembersResult is the output of Service.ListSectionMembers.
type ListSectionMembersResult struct {
Members []SectionMember
HasMore bool
NextCursor *string
}
type MemberCursor
MemberCursor is the keyset cursor for ListSectionMembers. The pair (JoinedAt, UserID) is the strict total order matching the SQL ORDER BY -- joined_at alone isn't unique under load, so user_id is the tiebreaker.
type MemberCursor struct {
JoinedAt time.Time `json:"joined_at"`
UserID uuid.UUID `json:"user_id"`
}
func DecodeMemberCursor
func DecodeMemberCursor(s string) (MemberCursor, error)
DecodeMemberCursor parses a base64-encoded string token back into a MemberCursor. Returns an error for malformed base64 or JSON; the handler maps that to a 400 with the spec's "invalid cursor value".
type MemberRole
MemberRole names the values stored in the course_role enum. The wire schema matches; the type alias keeps callers from hand-stringing the values and accidentally drifting from the DB enum.
type MemberRole string
const (
MemberRoleStudent MemberRole = "student"
MemberRoleTA MemberRole = "ta"
MemberRoleInstructor MemberRole = "instructor"
)
type Membership
Membership is the domain representation of a user's enrollment in a course section. Returned by Service.JoinSection.
type Membership struct {
UserID uuid.UUID
SectionID uuid.UUID
Role MemberRole
JoinedAt time.Time
}
type MembershipCheck
MembershipCheck is the per-section enrolled/not-enrolled probe returned by Service.CheckMembership. Role and JoinedAt are pointer types because the wire shape requires explicit JSON nulls (not omitted) when the viewer is not enrolled.
type MembershipCheck struct {
Enrolled bool
Role *MemberRole
JoinedAt *time.Time
}
type Repository
Repository is the data-access surface required by Service. The 8 list methods correspond to the per-sort-variant sqlc queries; ListCourseSections powers the inline sections array in the get-by-id response. The membership methods power join/leave: existence probes return bool, mutating ops return sql.ErrNoRows on the no-op case (already-joined for JoinSection, not-a-member for LeaveSection) so the service can map to the right status.
type Repository interface {
ListCoursesDepartmentAsc(ctx context.Context, arg db.ListCoursesDepartmentAscParams) ([]db.ListCoursesDepartmentAscRow, error)
ListCoursesDepartmentDesc(ctx context.Context, arg db.ListCoursesDepartmentDescParams) ([]db.ListCoursesDepartmentDescRow, error)
ListCoursesNumberAsc(ctx context.Context, arg db.ListCoursesNumberAscParams) ([]db.ListCoursesNumberAscRow, error)
ListCoursesNumberDesc(ctx context.Context, arg db.ListCoursesNumberDescParams) ([]db.ListCoursesNumberDescRow, error)
ListCoursesTitleAsc(ctx context.Context, arg db.ListCoursesTitleAscParams) ([]db.ListCoursesTitleAscRow, error)
ListCoursesTitleDesc(ctx context.Context, arg db.ListCoursesTitleDescParams) ([]db.ListCoursesTitleDescRow, error)
ListCoursesCreatedAtAsc(ctx context.Context, arg db.ListCoursesCreatedAtAscParams) ([]db.ListCoursesCreatedAtAscRow, error)
ListCoursesCreatedAtDesc(ctx context.Context, arg db.ListCoursesCreatedAtDescParams) ([]db.ListCoursesCreatedAtDescRow, error)
GetCourse(ctx context.Context, id pgtype.UUID) (db.GetCourseRow, error)
ListCourseSections(ctx context.Context, courseID pgtype.UUID) ([]db.ListCourseSectionsRow, error)
CourseExists(ctx context.Context, id pgtype.UUID) (bool, error)
SectionInCourseExists(ctx context.Context, arg db.SectionInCourseExistsParams) (bool, error)
JoinSection(ctx context.Context, arg db.JoinSectionParams) (db.CourseMember, error)
LeaveSection(ctx context.Context, arg db.LeaveSectionParams) (pgtype.UUID, error)
ListMyEnrollments(ctx context.Context, arg db.ListMyEnrollmentsParams) ([]db.ListMyEnrollmentsRow, error)
GetMembership(ctx context.Context, arg db.GetMembershipParams) (db.GetMembershipRow, error)
ListSectionMembers(ctx context.Context, arg db.ListSectionMembersParams) ([]db.ListSectionMembersRow, error)
}
func NewSQLCRepository
func NewSQLCRepository(queries *db.Queries) Repository
NewSQLCRepository returns a Repository backed by sqlc-generated Postgres queries.
type SchoolSummary
SchoolSummary is the compact school payload embedded inside other resources (Course, future StudyGuide). Mirrors api.SchoolSummary on the wire.
type SchoolSummary struct {
ID uuid.UUID
Name string
Acronym string
City *string
State *string
Country *string
}
type Section
Section is a course section as it appears in the detail view: term, optional section code + instructor, and a live roster count.
type Section struct {
ID uuid.UUID
Term string
SectionCode *string
InstructorName *string
MemberCount int64
}
type SectionMember
SectionMember is the per-row payload returned by ListSectionMembers. Privacy floor: only the five fields the public schema exposes; no email, clerk_id, or other PII is reachable through this type.
type SectionMember struct {
UserID uuid.UUID
FirstName string
LastName string
Role MemberRole
JoinedAt time.Time
}
type Service
Service is the business-logic layer for the courses 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 ListCourses can dispatch by sort key with no per-request reflection or type switching.
func (*Service) CheckMembership
func (s *Service) CheckMembership(ctx context.Context, p CheckMembershipParams) (MembershipCheck, error)
CheckMembership returns the viewer's membership status in the given section. Non-membership is NOT a 404 -- it's a 200 with enrolled=false so the frontend can distinguish "not enrolled" from "section doesn't exist" (which IS a 404 from assertCourseAndSection above).
Race handling: if the section is cascade-deleted between the preflight and the GetMembership lookup, the membership row vanishes alongside it and GetMembership returns sql.ErrNoRows. We can't tell from the lookup alone whether the user was never enrolled vs the row was just cascaded away, so we re-probe SectionInCourseExists on the not-found branch and surface a 404 if the section is now gone -- matching the ASK-148 spec table row for "section deleted between validation and membership query". Adds one cheap PK lookup to the cold not-enrolled path only; the enrolled happy path stays at one query (assertCourseAndSection's two probes + GetMembership = three round trips total either way).
func (*Service) GetCourse
func (s *Service) GetCourse(ctx context.Context, p GetCourseParams) (CourseDetail, error)
GetCourse returns the full course detail (course + school + sections) for the given UUID. Two queries: the course+school JOIN comes back in one round-trip, sections + member_count in a second. Returns an error wrapping apperrors.ErrNotFound when no course matches; the handler maps that to a 404 with "Course not found".
Sections is always a non-nil slice (empty when the course has none) so the JSON wire format stays "sections": [] rather than null.
func (*Service) JoinSection
func (s *Service) JoinSection(ctx context.Context, p JoinSectionParams) (Membership, error)
JoinSection enrolls the authenticated user in the given section as a 'student'. The role is hardcoded -- callers cannot escalate to instructor or ta via this entry point. Validates course existence, then section existence within the course, then inserts. ON CONFLICT DO NOTHING in the SQL means a duplicate join surfaces as sql.ErrNoRows; we map that to a tailored 409 AppError so the handler returns "Already a member of this section" verbatim.
We construct a typed *AppError instead of returning apperrors.ErrConflict because the shared sentinel maps to the generic message "Resource already exists" -- the spec for ASK-132 requires the more specific phrasing.
func (*Service) LeaveSection
func (s *Service) LeaveSection(ctx context.Context, p LeaveSectionParams) error
LeaveSection hard-deletes the authenticated user's membership in the section. Validates course + section path, then deletes. A no-op DELETE (the user was never a member, or the row is already gone after a race) surfaces as sql.ErrNoRows from the RETURNING clause; we map it to a tailored 404 with "Not a member of this section".
func (*Service) ListCourses
func (s *Service) ListCourses(ctx context.Context, p ListCoursesParams) (ListCoursesResult, error)
ListCourses returns a paginated, optionally-filtered list of courses with embedded school summaries. Sort is dispatched at the service layer because sqlc cannot parameterize ORDER BY, so each (sort_by, sort_dir) combination has its own typed query in the repository.
The HTTP boundary is the primary validator (openapi enforces sort_by enum, sort_dir enum, page_limit 1..100), but the service also clamps and defaults defensively so internal Go callers can't ask Postgres for an unbounded number of rows or an undefined sort.
func (*Service) ListMyEnrollments
func (s *Service) ListMyEnrollments(ctx context.Context, p ListMyEnrollmentsParams) ([]Enrollment, error)
ListMyEnrollments returns every section the viewer is enrolled in, projected through the dashboard-shaped Enrollment payload. The HTTP boundary already enforces role/term validation via the openapi schema, but the service defensively re-validates so internal Go callers can't pass a bogus role through to the database.
No pagination by design (per ASK-154): a user is typically enrolled in 4-8 sections. The fixed sort lives in the SQL: term DESC, then department + number ASC. Sort is *lexicographic* on term, not chronological -- "Spring 2026" sorts before "Fall 2025" because S<F is false but '2026' > '2025' decides it. This is acceptable per the spec, but readers should be aware that "Summer 2025" sorts before "Spring 2025" alphabetically (Summer<Spring). Term + Role filters are optional; an empty or whitespace-only term collapses to "no filter".
func (*Service) ListSectionMembers
func (s *Service) ListSectionMembers(ctx context.Context, p ListSectionMembersParams) (ListSectionMembersResult, error)
ListSectionMembers returns the section roster, paginated. Reuses the assertCourseAndSection preflight so 'Course not found' / 'Section not found' messaging stays identical to the join/leave/check endpoints.
The role filter is validated defensively at the service layer (HTTP boundary already enforces it via the openapi enum). Limit defaults to DefaultPageLimit and is clamped to MaxPageLimit so internal Go callers can't ask Postgres for an unbounded number of rows.
type SortDir
SortDir represents the direction of a list query.
type SortDir string
type SortField
SortField represents the column a list query is ordered by.
type SortField string
Generated by gomarkdoc