favorites
import "github.com/Ask-Atlas/AskAtlas/api/internal/favorites"
Package favorites implements the GET /api/me/favorites service surface (ASK-151): a per-viewer paginated list of favorited files, study guides, and courses, used to power the sidebar "Starred" section and the /me/saved page.
The merge happens in the Go service rather than SQL because a UNION over three differently-shaped rows would force a lowest- common-denominator projection or per-row casting, and offset pagination across a UNION is awkward to keep stable. Per-table queries each hit their focused (user_id, created_at) index; merge + offset/limit happen in-process.
The shape mirrors internal/recents (ASK-145) deliberately -- both are sidebar-data endpoints with the same discriminated- union envelope. Kept as separate packages rather than a shared `me/` package so each ticket evolves independently.
Index
- Constants
- func DecodeCursor(s string) (int32, error)
- func EncodeCursor(offset int32) string
- type EntityType
- type FavoriteCourseSummary
- type FavoriteFileSummary
- type FavoriteItem
- type FavoriteStudyGuideSummary
- type ListFavoritesParams
- type ListFavoritesResult
- type Repository
- type Service
- func NewService(repo Repository) *Service
- func (s *Service) ListFavorites(ctx context.Context, p ListFavoritesParams) (ListFavoritesResult, error)
- func (s *Service) ToggleCourseFavorite(ctx context.Context, viewerID, courseID uuid.UUID) (ToggleFavoriteResult, error)
- func (s *Service) ToggleFileFavorite(ctx context.Context, viewerID, fileID uuid.UUID) (ToggleFavoriteResult, error)
- func (s *Service) ToggleStudyGuideFavorite(ctx context.Context, viewerID, studyGuideID uuid.UUID) (ToggleFavoriteResult, error)
- type ToggleFavoriteResult
Constants
const (
// DefaultLimit is applied when the caller omits the limit query
// param (or specifies 0). Matches the openapi.yaml default.
DefaultLimit int32 = 25
// MinLimit is the inclusive lower bound on limit. Matches openapi.yaml.
MinLimit int32 = 1
// MaxLimit is the inclusive upper bound on limit. Matches openapi.yaml.
MaxLimit int32 = 100
// MaxOffset caps the per-table read width. With MaxLimit=100 and
// MaxOffset=1000 the worst case is 1101 rows per table * 3 tables
// = ~3.3k rows in memory -- plenty of headroom for users with
// many favorites while still bounding the per-request cost.
// A request with offset > MaxOffset returns 400 with
// "invalid cursor value".
MaxOffset int32 = 1000
)
func DecodeCursor
func DecodeCursor(s string) (int32, error)
DecodeCursor parses an opaque cursor string into an integer offset. Returns an error for malformed base64, non-integer payload, or a negative / over-cap offset; the handler maps that to 400 with the spec's "invalid cursor value" detail.
func EncodeCursor
func EncodeCursor(offset int32) string
EncodeCursor wraps a non-negative integer offset in a base64 envelope. The wire contract is opaque -- a future migration to keyset pagination is non-breaking because clients pass cursors back verbatim and don't parse them.
type EntityType
EntityType discriminates the populated payload on a FavoriteItem. The string values match the openapi enum exactly so the mapper can cast directly.
type EntityType string
const (
EntityTypeFile EntityType = "file"
EntityTypeStudyGuide EntityType = "study_guide"
EntityTypeCourse EntityType = "course"
)
func (EntityType) Valid
func (e EntityType) Valid() bool
Valid reports whether s is a recognized EntityType. Used by the service to validate the optional entity_type query filter; the openapi wrapper enforces the enum at the HTTP boundary, this is defense in depth for internal Go callers.
type FavoriteCourseSummary
FavoriteCourseSummary mirrors api.FavoriteCourseSummary on the wire.
type FavoriteCourseSummary struct {
ID uuid.UUID
Department string
Number string
Title string
}
type FavoriteFileSummary
FavoriteFileSummary mirrors api.FavoriteFileSummary on the wire.
type FavoriteFileSummary struct {
ID uuid.UUID
Name string
MimeType string
}
type FavoriteItem
FavoriteItem is one merged favorite row. Exactly one of File, StudyGuide, or Course is non-nil; EntityType declares which one. EntityID mirrors the populated summary's ID so callers can route off the envelope without unpacking the per-type payload.
type FavoriteItem struct {
EntityType EntityType
EntityID uuid.UUID
FavoritedAt time.Time
File *FavoriteFileSummary
StudyGuide *FavoriteStudyGuideSummary
Course *FavoriteCourseSummary
}
type FavoriteStudyGuideSummary
FavoriteStudyGuideSummary mirrors api.FavoriteStudyGuideSummary on the wire. Includes the parent course's department + number so the sidebar can render "CPTS 322 -- <title>" without a follow-up.
type FavoriteStudyGuideSummary struct {
ID uuid.UUID
Title string
CourseDepartment string
CourseNumber string
}
type ListFavoritesParams
ListFavoritesParams is the input to Service.ListFavorites. EntityType filters to a single type when non-nil; nil means all three types are merged.
type ListFavoritesParams struct {
ViewerID uuid.UUID
EntityType *EntityType
Limit int32
Cursor *string
}
type ListFavoritesResult
ListFavoritesResult is the output of Service.ListFavorites. NextCursor is *string so an explicit JSON null on the last page can be distinguished from an absent field on the wire.
type ListFavoritesResult struct {
Favorites []FavoriteItem
HasMore bool
NextCursor *string
}
type Repository
Repository is the data-access surface required by the favorites service. The three List* calls power GET /api/me/favorites; the six new Check* + Toggle* calls power the per-entity favorite-toggle endpoints (ASK-130 / ASK-156 / ASK-157).
Defined here (where it is used) rather than alongside db.Queries to keep the surface small and to allow mockery-generated mocks for service tests.
type Repository interface {
ListFileFavorites(ctx context.Context, arg db.ListFileFavoritesParams) ([]db.ListFileFavoritesRow, error)
ListStudyGuideFavorites(ctx context.Context, arg db.ListStudyGuideFavoritesParams) ([]db.ListStudyGuideFavoritesRow, error)
ListCourseFavorites(ctx context.Context, arg db.ListCourseFavoritesParams) ([]db.ListCourseFavoritesRow, error)
// CheckFileExists / CheckStudyGuideExists / CheckCourseExists
// return apperrors.ErrNotFound when the parent entity is missing
// or in a deletion lifecycle (per the spec, both map to 404).
CheckFileExists(ctx context.Context, fileID pgtype.UUID) error
CheckStudyGuideExists(ctx context.Context, studyGuideID pgtype.UUID) error
CheckCourseExists(ctx context.Context, courseID pgtype.UUID) error
// Toggle* atomically inserts when missing / deletes when present
// in a single CTE round trip. The caller is expected to gate
// existence first via the matching Check* probe.
ToggleFileFavorite(ctx context.Context, arg db.ToggleFileFavoriteParams) (db.ToggleFileFavoriteRow, error)
ToggleStudyGuideFavorite(ctx context.Context, arg db.ToggleStudyGuideFavoriteParams) (db.ToggleStudyGuideFavoriteRow, error)
ToggleCourseFavorite(ctx context.Context, arg db.ToggleCourseFavoriteParams) (db.ToggleCourseFavoriteRow, error)
}
func NewSQLCRepository
func NewSQLCRepository(queries *db.Queries) Repository
NewSQLCRepository returns a Repository backed by sqlc-generated Postgres queries.
type Service
Service implements the GET /api/me/favorites business logic.
type Service struct {
// contains filtered or unexported fields
}
func NewService
func NewService(repo Repository) *Service
NewService wires a favorites Service over the given repository.
func (*Service) ListFavorites
func (s *Service) ListFavorites(ctx context.Context, p ListFavoritesParams) (ListFavoritesResult, error)
ListFavorites returns a page of the viewer's favorited entities (ASK-151).
Strategy:
- When EntityType is non-nil: query only that one favorites table with OFFSET/LIMIT directly. Simpler + faster path.
- When EntityType is nil: fan out to all three queries with LIMIT (offset+limit+1), merge in Go, sort by FavoritedAt DESC with EntityID tiebreak, slice [offset:offset+limit] for the page. The +1 row distinguishes "exactly limit items remain" from "more items exist".
The per-table LIMIT (offset+limit+1) is correct for the merge: for any item that lands in positions 0..(offset+limit) of the global ordering, no excluded row from any table can be newer (because that table already returned its top (offset+limit+1) rows). MaxOffset=1000 caps the worst case at 1101 rows per table * 3 tables = ~3.3k rows in memory.
Limit + Cursor + EntityType validation is defense in depth -- the openapi wrapper enforces these at the HTTP boundary; the service re-validates so internal Go callers can't bypass it.
func (*Service) ToggleCourseFavorite
func (s *Service) ToggleCourseFavorite(ctx context.Context, viewerID, courseID uuid.UUID) (ToggleFavoriteResult, error)
ToggleCourseFavorite toggles the (viewer, course) row in course_favorites (ASK-157). Courses do not support soft-delete so existence is the only 404 condition.
func (*Service) ToggleFileFavorite
func (s *Service) ToggleFileFavorite(ctx context.Context, viewerID, fileID uuid.UUID) (ToggleFavoriteResult, error)
ToggleFileFavorite toggles the (viewer, file) row in file_favorites (ASK-130). Returns apperrors.ErrNotFound when the file is missing or in any deletion state -- favoriting is permission-less but requires a live parent row.
func (*Service) ToggleStudyGuideFavorite
func (s *Service) ToggleStudyGuideFavorite(ctx context.Context, viewerID, studyGuideID uuid.UUID) (ToggleFavoriteResult, error)
ToggleStudyGuideFavorite toggles the (viewer, study_guide) row in study_guide_favorites (ASK-156). Same 404 rules as the file path: missing or soft-deleted (deleted_at IS NOT NULL) maps to ErrNotFound.
type ToggleFavoriteResult
ToggleFavoriteResult is the domain output of the per-entity favorite-toggle endpoints (ASK-130 / ASK-156 / ASK-157). `Favorited` is true when the toggle inserted a row, false when it deleted one. `FavoritedAt` carries the row timestamp on insert and is nil on delete (renders as JSON null on the wire).
type ToggleFavoriteResult struct {
Favorited bool
FavoritedAt *time.Time
}
Generated by gomarkdoc