Block Bookmarks — PRD
Context
Keystone has three generations of bookmark code:
- v0 (dead) —
bookmark_mcq/bookmark_docket(+_bucket) collections, used only byv0_bookmark_services.py, which is not wired to any route. - MCQ bookmarks (live) — the current, shipped pattern: a per-user
mcq_attributesrow (bookmark_status,bookmark_collection_ids,bookmarked_at) written byMCQAttributesService.bookmarks()viaPOST /v1/mcqs_attrs/bookmark, synced viaGET /v1/mcqs_attrs/sync, with named buckets inbookmark_collections. 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 (*_attributesper content type →*_attrsroutes) for the whole app, blocks keyed by their own_idlike MCQs, and no coupling to the docket-centriccontent_attributes/trending design.content_attributesis left untouched.
Scope
MVP — a first-class, MCQ-style block bookmark, end to end:
block_attributescollection — new per-user-per-block record mirroringmcq_attributes, carrying bookmark state only (bookmark_status,bookmarked_at). No attempts, no reactions, no collections.- Bookmark write endpoint —
POST /v1/block_attrs/bookmark: batch set/unset bookmark state for blocks viabookmark_status. Validates each block exists before writing. - Bookmark sync endpoint —
GET /v1/block_attrs/sync: cursor-paginated, course-scoped incremental feed of the user’s block-attribute rows, mirroringmcq_attrssync exactly. - Route registration — register the new router in
src/routes/v1.pybehind client auth.
Simple toggle, no collections (decided). Unlike the MCQ flow, blocks are not organised into named
bookmark_collections.block_attributeshas nobookmark_collection_idsand the write path takes nodefault_collection_id. Quick-access is a flat, per-course list. (Rejected: full collections mirror — would force generalising the MCQ-onlybookmark_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 futurelike_status/ view count can be added without a new collection.
Functional Requirements
- An authenticated user can bookmark a block by sending the block’s id and a “bookmarked” status.
- The same call can unbookmark (status =
NOT_BOOKMARKED) and can carry multiple blocks at once (batch). - A bookmark request for a block that does not exist is rejected (
BLOCK_NOT_FOUND). - 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 byblock_id= theblockscollection Mongo_id, exactly asmcq_attributeskeys bymcq_id. The client already receives block_idfrom block sync.unique_key = "{course_id}_{user_id}_{block_id}". Not theBLK-short_uid and not the docket-body UUID thatcontent_attributesuses.
bookmarked_atis independent ofupdated_at(decided). Set to current epoch ms onBOOKMARKED, cleared tonullonNOT_BOOKMARKED. Drives client-side “recently bookmarked” sort;updated_atdrives the sync cursor. (Same semantics asmcq_attributes.)
Un-bookmark flips status, never deletes (decided).
NOT_BOOKMARKEDrows persist so the sync feed can carry the change;is_deletedstays false in normal use.
block_attributes row (new collection block_attributes):
| Field | Type | Notes |
|---|---|---|
unique_key | string | "{course_id}_{user_id}_{block_id}", unique. Auto-generated. |
block_id | ObjectId | Required. The blocks collection _id. |
user_id | ObjectId | Required. |
course_id | enum | Required. CourseEnum. |
bookmark_status | enum | BookmarkStatusEnum — BOOKMARKED=1 / NOT_BOOKMARKED=2. Default NOT_BOOKMARKED. |
bookmarked_at | int (epoch ms), optional | Set on bookmark, cleared on unbookmark. |
| audit | — | created_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/bookmark → bookmarks: [ … ]):
| Field | Type | Notes |
|---|---|---|
block_id | ObjectId | Required. |
bookmark_status | enum | Required. BOOKMARKED=1 / NOT_BOOKMARKED=2. |
Sync item (GET /v1/block_attrs/sync → data: [ … ]): 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+1over-fetch forhas_more. Client filters to bookmarked locally. Mechanically identical tomcq_attrssync.
API Surface
| Surface | Route | Auth | Notes |
|---|---|---|---|
| v1 (mobile, write) | POST /v1/block_attrs/bookmark | client_jwt_validator | body BlockBookmarkRequest; batch; validates block existence; encrypted BaseResponse. |
| v1 (mobile, read) | GET /v1/block_attrs/sync (tag Sync) | client_jwt_validator | cursor BlockAttributesTimestampCursor; encrypted BaseResponseWithPagination[List[BlockAttributesResponse]]. |
Mirrors src/api/v1/mcq_attrs/routes.py (router prefix /block_attrs, tag Block Actions).
Delivery Milestones
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | block_attributes model + repo + service | New collection, bulk upsert bookmark, cursor list — mirrors mcq_attributes stack, bookmark-only. | pending | BE plan (TBD) |
| 2 | Bookmark write endpoint | Users bookmark/unbookmark blocks, batched, via POST /v1/block_attrs/bookmark; invalid block → BLOCK_NOT_FOUND. | pending | BE plan (TBD) |
| 3 | Bookmark sync endpoint + registration | GET /v1/block_attrs/sync registered in src/routes/v1.py; clients pull the quick-access feed. | pending | BE plan (TBD) |
| 4 | Tests | Happy path, invalid block, unbookmark (status flip clears bookmarked_at), /sync pagination. | pending | BE 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_iddenormalization (resolved): omitted (YAGNI). The client resolves the parent docket from block sync (block.docket_id) if the list UI needs it.content_attributesblock path (resolved): left entirely as-is — it is legacy/old code.block_attributesis canonical for blocks. No code or doc change tocontent_attributes.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Trending blind to blocks. Block bookmark activity won’t influence docket.trending_count while trending reads only content_attributes. | Med | Low/Med — trending under-counts block-driven interest | Decide 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. | Low | Med — split/incorrect state | content_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. | Low | Low | Documented contract: client filters by bookmark_status locally (same as mcq_attrs). |
| Unbounded growth. Rows are never deleted (status flip only). | Low | Low | One 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 term | Code (to create) |
|---|---|
| Bookmark row | BlockAttributesDBModel (collection block_attributes) / BlockAttributesProjection |
| Unique key | create_block_attributes_unique_key() → {course_id}_{user_id}_{block_id} |
| Repository | block_attributes_repository — create_or_update_bookmark(), list_by_updated_at() (mirror mcq_attributes_repository) |
| Service | BlockAttributesService.bookmarks(), .list_user_block_attributes_by_updated_at() |
| Block existence check | block_repository.get_by_ids() (count match → else BLOCK_NOT_FOUND) |
| Write endpoint | POST /v1/block_attrs/bookmark (src/api/v1/block_attrs/routes.py) |
| Sync endpoint | GET /v1/block_attrs/sync |
| Write request | BlockBookmarkRequest / BlockBookmarkParameters (block_id, bookmark_status) |
| Sync response | BlockAttributesResponse + cursor BlockAttributesTimestampCursor |
| Bookmark status | BookmarkStatusEnum (BOOKMARKED=1, NOT_BOOKMARKED=2) — reused |
| Route registration | src/routes/v1.py (protected_router.include_router(...)) |
Surface & semantics
| Surface | Routes | Notes |
|---|---|---|
| v1 (mobile, write) | POST /v1/block_attrs/bookmark | encrypted; batch; validates block existence. |
| v1 (mobile, read) | GET /v1/block_attrs/sync | encrypted; 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_attributesand the trending task are untouched.