Owner Burhan

Block Bookmarks — PRD

Context

Keystone has three generations of bookmark code:

  1. v0 (dead)bookmark_mcq / bookmark_docket (+ _bucket) collections, used only by v0_bookmark_services.py, which is not wired to any route.
  2. MCQ bookmarks (live) — the current, shipped pattern: a per-user mcq_attributes row (bookmark_status, bookmark_collection_ids, bookmarked_at) written by MCQAttributesService.bookmarks() via POST /v1/mcqs_attrs/bookmark, synced via GET /v1/mcqs_attrs/sync, with named buckets in bookmark_collections.
  3. content_attributes (built, unrouted) — a generic docket/block bookmark collection that already models blocks (block_id, bookmark_status) but has no route and no writer. Its only consumer is the docket trending Celery task.

Blocks have no live bookmark path today. This PRD adds one by mirroring the live MCQ flow (#2): a new dedicated block_attributes collection and a matching block_attrs route module. We are not extending content_attributes (#3) — the decision is to follow the proven, in-production mcq_attributes shape so blocks behave exactly like MCQs do.

Why a new collection instead of content_attributes (decided). Consistency with the live MCQ bookmark flow wins: one pattern (*_attributes per content type → *_attrs routes) for the whole app, blocks keyed by their own _id like MCQs, and no coupling to the docket-centric content_attributes/trending design. content_attributes is left untouched.

Scope

MVP — a first-class, MCQ-style block bookmark, end to end:

  1. block_attributes collection — new per-user-per-block record mirroring mcq_attributes, carrying bookmark state only (bookmark_status, bookmarked_at). No attempts, no reactions, no collections.
  2. Bookmark write endpointPOST /v1/block_attrs/bookmark: batch set/unset bookmark state for blocks via bookmark_status. Validates each block exists before writing.
  3. Bookmark sync endpointGET /v1/block_attrs/sync: cursor-paginated, course-scoped incremental feed of the user’s block-attribute rows, mirroring mcq_attrs sync exactly.
  4. Route registration — register the new router in src/routes/v1.py behind client auth.

Simple toggle, no collections (decided). Unlike the MCQ flow, blocks are not organised into named bookmark_collections. block_attributes has no bookmark_collection_ids and the write path takes no default_collection_id. Quick-access is a flat, per-course list. (Rejected: full collections mirror — would force generalising the MCQ-only bookmark_collections.)

Bookmark only (decided). Blocks have no attempts; no like/dislike at launch. The model is named generically (block_attributes = “user activity on a block”) so a future like_status / view count can be added without a new collection.

Functional Requirements

  1. An authenticated user can bookmark a block by sending the block’s id and a “bookmarked” status.
  2. The same call can unbookmark (status = NOT_BOOKMARKED) and can carry multiple blocks at once (batch).
  3. A bookmark request for a block that does not exist is rejected (BLOCK_NOT_FOUND).
  4. A client can pull all of its block-attribute rows for a course incrementally (cursor by updated_at) to render a “quick access” list and detect un-bookmarks/changes since the last sync.

Data Model (requirements-level)

Block identity = the block’s own _id (decided). A bookmark is keyed by block_id = the blocks collection Mongo _id, exactly as mcq_attributes keys by mcq_id. The client already receives block _id from block sync. unique_key = "{course_id}_{user_id}_{block_id}". Not the BLK- short_uid and not the docket-body UUID that content_attributes uses.

bookmarked_at is independent of updated_at (decided). Set to current epoch ms on BOOKMARKED, cleared to null on NOT_BOOKMARKED. Drives client-side “recently bookmarked” sort; updated_at drives the sync cursor. (Same semantics as mcq_attributes.)

Un-bookmark flips status, never deletes (decided). NOT_BOOKMARKED rows persist so the sync feed can carry the change; is_deleted stays false in normal use.

block_attributes row (new collection block_attributes):

FieldTypeNotes
unique_keystring"{course_id}_{user_id}_{block_id}", unique. Auto-generated.
block_idObjectIdRequired. The blocks collection _id.
user_idObjectIdRequired.
course_idenumRequired. CourseEnum.
bookmark_statusenumBookmarkStatusEnumBOOKMARKED=1 / NOT_BOOKMARKED=2. Default NOT_BOOKMARKED.
bookmarked_atint (epoch ms), optionalSet on bookmark, cleared on unbookmark.
auditcreated_at, updated_at, is_deleted, created_by, updated_by from BaseBeanieDocumentModel.

Indexes (mirror mcq_attributes): unique_key (unique); (user_id, course_id, updated_at DESC) for sync; block_id for per-block aggregation.

Request item (POST /v1/block_attrs/bookmarkbookmarks: [ … ]):

FieldTypeNotes
block_idObjectIdRequired.
bookmark_statusenumRequired. BOOKMARKED=1 / NOT_BOOKMARKED=2.

Sync item (GET /v1/block_attrs/syncdata: [ … ]): id, block_id, user_id, course_id, bookmark_status, bookmarked_at, created_by. (updated_at drives the cursor.)

Sync returns everything since the cursor (decided). Course-scoped; returns all of the user’s block-attribute rows (bookmarked and un-bookmarked) ordered by (updated_at, _id), limit ≤ 120 (default 10), limit+1 over-fetch for has_more. Client filters to bookmarked locally. Mechanically identical to mcq_attrs sync.

API Surface

SurfaceRouteAuthNotes
v1 (mobile, write)POST /v1/block_attrs/bookmarkclient_jwt_validatorbody BlockBookmarkRequest; batch; validates block existence; encrypted BaseResponse.
v1 (mobile, read)GET /v1/block_attrs/sync (tag Sync)client_jwt_validatorcursor BlockAttributesTimestampCursor; encrypted BaseResponseWithPagination[List[BlockAttributesResponse]].

Mirrors src/api/v1/mcq_attrs/routes.py (router prefix /block_attrs, tag Block Actions).

Delivery Milestones

#MilestoneOutcomeStatusPlan
1block_attributes model + repo + serviceNew collection, bulk upsert bookmark, cursor list — mirrors mcq_attributes stack, bookmark-only.pendingBE plan (TBD)
2Bookmark write endpointUsers bookmark/unbookmark blocks, batched, via POST /v1/block_attrs/bookmark; invalid block → BLOCK_NOT_FOUND.pendingBE plan (TBD)
3Bookmark sync endpoint + registrationGET /v1/block_attrs/sync registered in src/routes/v1.py; clients pull the quick-access feed.pendingBE plan (TBD)
4TestsHappy path, invalid block, unbookmark (status flip clears bookmarked_at), /sync pagination.pendingBE plan (TBD)

Plan key: BE plan = keystone backend (keystone/docs/superpowers/… once specced). All milestones are backend-only in keystone; mobile (parixa) consumes the contract separately.

Open Questions

None remaining — all resolved during PRD review:

  • Trending (resolved): block bookmarks do not feed docket trending in MVP. update_trending_dockets() is left untouched.
  • docket_id denormalization (resolved): omitted (YAGNI). The client resolves the parent docket from block sync (block.docket_id) if the list UI needs it.
  • content_attributes block path (resolved): left entirely as-is — it is legacy/old code. block_attributes is canonical for blocks. No code or doc change to content_attributes.

Risks

RiskLikelihoodImpactMitigation
Trending blind to blocks. Block bookmark activity won’t influence docket.trending_count while trending reads only content_attributes.MedLow/Med — trending under-counts block-driven interestDecide in Open Questions; if needed, extend the trending aggregation to union block_attributes.
Two block-bookmark substrates. content_attributes still models block bookmarks; block_attributes is the live one. Future devs may write to the wrong one.LowMed — split/incorrect statecontent_attributes is legacy/old code, left as-is by decision; block_attributes is canonical for blocks (recorded in this PRD). No code change.
Sync returns non-bookmarked rows. /sync also returns un-bookmarked rows; a naive client could render them.LowLowDocumented contract: client filters by bookmark_status locally (same as mcq_attrs).
Unbounded growth. Rows are never deleted (status flip only).LowLowOne row per (user, block) — upsert by unique_key, bounded by block count.

Technical Details (as built)

Pending — not yet implemented. Greenfield, modeled file-for-file on the mcq_attributes stack.

Naming: PRD vs code (anticipated)

PRD termCode (to create)
Bookmark rowBlockAttributesDBModel (collection block_attributes) / BlockAttributesProjection
Unique keycreate_block_attributes_unique_key(){course_id}_{user_id}_{block_id}
Repositoryblock_attributes_repositorycreate_or_update_bookmark(), list_by_updated_at() (mirror mcq_attributes_repository)
ServiceBlockAttributesService.bookmarks(), .list_user_block_attributes_by_updated_at()
Block existence checkblock_repository.get_by_ids() (count match → else BLOCK_NOT_FOUND)
Write endpointPOST /v1/block_attrs/bookmark (src/api/v1/block_attrs/routes.py)
Sync endpointGET /v1/block_attrs/sync
Write requestBlockBookmarkRequest / BlockBookmarkParameters (block_id, bookmark_status)
Sync responseBlockAttributesResponse + cursor BlockAttributesTimestampCursor
Bookmark statusBookmarkStatusEnum (BOOKMARKED=1, NOT_BOOKMARKED=2) — reused
Route registrationsrc/routes/v1.py (protected_router.include_router(...))

Surface & semantics

SurfaceRoutesNotes
v1 (mobile, write)POST /v1/block_attrs/bookmarkencrypted; batch; validates block existence.
v1 (mobile, read)GET /v1/block_attrs/syncencrypted; cursor (updated_at, _id); returns all block-attr rows for the course.
  • Error codes reused: BLOCK_NOT_FOUND, VALIDATION_FAILED. None new.

Migration

  • No data backfill — new collection; rows created on demand via upsert. Beanie builds the indexes on startup. content_attributes and the trending task are untouched.