diff --git a/docs/developer/api/media-items/create_media_bookmark.md b/docs/developer/api/media-items/create_media_bookmark.md new file mode 100644 index 00000000..301762bf --- /dev/null +++ b/docs/developer/api/media-items/create_media_bookmark.md @@ -0,0 +1,76 @@ +--- +method: POST +path: /api/media-items/:id/bookmarks +auth: jwt +area: media-items +--- + + +# Create Media Bookmark + +Creates a bookmark through the sync-aware path (dedup + last-write-wins + +tombstones). Returns **201** with the stored bookmark row. + +## Example request + +```json example-request +{ + "title": "Chapter 3", + "position": "kindle:pos:1:2", + "notes": "revisit this", + "cfi_position": "epubcfi(/6/4)", + "page_number": 42, + "chapter_number": 3, + "percentage": 35.5, + "chapter_reference": 3, + "origin": "android" +} +``` + +## Example response + +```json example-response +{ + "id": "110e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "page_number": 42, + "chapter_number": 3, + "cfi_position": "epubcfi(/6/4)", + "title": "Chapter 3", + "position": "kindle:pos:1:2", + "notes": "revisit this", + "created_at": "2026-10-24T13:57:01Z", + "dedup_key": "kindle:pos:1:2", + "last_modified_at": "2026-10-24T13:57:01Z", + "last_modified_source": "android", + "device_sync_data": null, + "percentage_location": 35.5, + "epubcfi_location": "epubcfi(/6/4)", + "chapter_reference": 3, + "deleted": false, + "deleted_at": null, + "origin_source": "android" +} +``` + +## Request fields + +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| title | string | yes | 1–255 chars | +| position | string | no | ≤100 chars | +| notes | string | no | ≤100000 chars | +| cfi_position | string | no | ≤255 chars | +| page_number | number | no | | +| chapter_number | number | no | | +| percentage | number | no | | +| chapter_reference | number | no | | +| origin | string | no | client label ("android", "web"); defaults to "web" | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid request"}` / validation error | malformed body | +| 500 | `{"error": "..."}` | annotation service failure | diff --git a/docs/developer/api/media-items/delete_media_bookmark.md b/docs/developer/api/media-items/delete_media_bookmark.md new file mode 100644 index 00000000..f63e395b --- /dev/null +++ b/docs/developer/api/media-items/delete_media_bookmark.md @@ -0,0 +1,20 @@ +--- +method: DELETE +path: /api/media-items/:id/bookmarks/:bookmarkId +auth: jwt +area: media-items +--- + + +# Delete Media Bookmark + +Tombstones a bookmark (sync-aware delete, source "web"). Success is +**204 No Content** — no body. + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid bookmark id"}` / `{"error": "invalid user"}` | malformed | +| 404 | `{"error": "bookmark not found"}` | no such bookmark | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/media-items/delete_media_review.md b/docs/developer/api/media-items/delete_media_review.md new file mode 100644 index 00000000..213a8b8c --- /dev/null +++ b/docs/developer/api/media-items/delete_media_review.md @@ -0,0 +1,26 @@ +--- +method: DELETE +path: /api/media-items/:id/review +auth: jwt +area: media-items +--- + + +# Delete Media Review + +Deletes the signed-in user's review for a media item. + +## Example response + +```json example-response +{ + "message": "review deleted" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid user"}` / `{"error": "invalid media item id"}` | malformed | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/media-items/get_media_bookmarks.md b/docs/developer/api/media-items/get_media_bookmarks.md new file mode 100644 index 00000000..466a7570 --- /dev/null +++ b/docs/developer/api/media-items/get_media_bookmarks.md @@ -0,0 +1,51 @@ +--- +method: GET +path: /api/media-items/:id/bookmarks +auth: jwt +area: media-items +--- + + +# Get Media Bookmarks + +Lists the signed-in user's bookmarks for one media item (sync-aware rows: +dedup key and last-writer bookkeeping included). + +## Example response + +```json example-response +[ + { + "id": "110e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "page_number": null, + "chapter_number": null, + "cfi_position": null, + "title": "Chapter 3", + "position": "kindle:pos:1:2", + "notes": null, + "created_at": "2026-10-24T13:57:01Z", + "dedup_key": "kindle:pos:1:2", + "last_modified_at": "2026-10-24T13:57:01Z", + "last_modified_source": "web", + "device_sync_data": null, + "percentage_location": null, + "epubcfi_location": null, + "chapter_reference": null, + "deleted": false, + "deleted_at": null, + "origin_source": "web" + } +] +``` + +`device_sync_data` is `[]byte` → base64 string when set, `null` otherwise. +pgtype fields are `null` when unset. + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid user"}` / `{"error": "invalid media item id"}` | malformed ids | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/media-items/get_media_highlight.md b/docs/developer/api/media-items/get_media_highlight.md new file mode 100644 index 00000000..499c452f --- /dev/null +++ b/docs/developer/api/media-items/get_media_highlight.md @@ -0,0 +1,53 @@ +--- +method: GET +path: /api/media-items/:id/highlights/:highlightId +auth: jwt +area: media-items +--- + + +# Get Media Highlight + +Returns one highlight by id. + +## Example response + +```json example-response +{ + "id": "440e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "selection_text": "It was a bright cold day in April...", + "start_position": "kobo:3.1", + "end_position": "kobo:3.4", + "color": "yellow", + "note_id": null, + "created_at": "2026-10-24T13:57:01Z", + "updated_at": "2026-10-24T13:57:01Z", + "percentage_start": 41.2, + "percentage_end": 41.8, + "character_start": null, + "character_end": null, + "epubcfi_start": null, + "epubcfi_end": null, + "chapter_reference": 3, + "paragraph_start": null, + "paragraph_end": null, + "panel_number": null, + "device_sync_data": null, + "dedup_key": "kobo:3.1-3.4", + "last_modified_at": "2026-10-24T13:57:01Z", + "last_modified_source": "web", + "note_text": null, + "deleted": false, + "deleted_at": null +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid highlight id"}` | malformed id | +| 404 | `{"error": "highlight not found"}` | no such highlight | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/media-items/get_media_note.md b/docs/developer/api/media-items/get_media_note.md new file mode 100644 index 00000000..e81fdac4 --- /dev/null +++ b/docs/developer/api/media-items/get_media_note.md @@ -0,0 +1,47 @@ +--- +method: GET +path: /api/media-items/:id/notes/:noteId +auth: jwt +area: media-items +--- + + +# Get Media Note + +Returns one note by id. + +## Example response + +```json example-response +{ + "id": "330e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "content": "Key theme here.", + "position": "kobo:3.1", + "created_at": "2026-10-24T13:57:01Z", + "updated_at": "2026-10-24T13:57:01Z", + "percentage_location": 42.0, + "character_start": null, + "character_end": null, + "epubcfi_location": null, + "chapter_reference": 3, + "paragraph_reference": null, + "device_sync_data": null, + "dedup_key": "kobo:3.1", + "last_modified_at": "2026-10-24T13:57:01Z", + "last_modified_source": "web", + "deleted": false, + "deleted_at": null +} +``` + +`device_sync_data` is `[]byte` → base64 string when set. + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid note id"}` | malformed id | +| 404 | `{"error": "note not found"}` | no such note | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/media-items/get_media_review.md b/docs/developer/api/media-items/get_media_review.md new file mode 100644 index 00000000..f3003bc6 --- /dev/null +++ b/docs/developer/api/media-items/get_media_review.md @@ -0,0 +1,32 @@ +--- +method: GET +path: /api/media-items/:id/review +auth: jwt +area: media-items +--- + + +# Get Media Review + +Returns the signed-in user's review for a media item, or `{"review": null}` +when none exists (200 in both cases). + +## Example response + +```json example-response +{ + "id": "220e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "review": "A slow start, but the second act earns it.", + "created_at": "2026-10-24T13:57:01Z", + "updated_at": "2026-10-24T13:57:01Z" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid user"}` / `{"error": "invalid media item id"}` | malformed | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/media-items/purge_deleted_annotation.md b/docs/developer/api/media-items/purge_deleted_annotation.md new file mode 100644 index 00000000..fdd06be9 --- /dev/null +++ b/docs/developer/api/media-items/purge_deleted_annotation.md @@ -0,0 +1,36 @@ +--- +method: DELETE +path: /api/media-items/:id/annotations/:annotationId +auth: jwt +area: media-items +--- + + +# Purge Deleted Annotation + +**Permanent** — removes a tombstoned annotation from history entirely. Kind +comes from the required `annotation_type` query parameter +(`highlight` | `note` | `bookmark`; no body fallback on DELETE). + +## Example request + +```text +DELETE /api/media-items/550e8400-.../annotations/440e8400-...?annotation_type=highlight +``` + +## Example response + +```json example-response +{ + "purged": true +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "annotation_type must be highlight, note, or bookmark"}` | missing/invalid kind | +| 404 | `{"error": "deleted annotation not found"}` | nothing tombstoned under that id | +| 500 | `{"error": "failed to purge annotation"}` | unexpected server error | +| 503 | `{"error": "annotation service unavailable"}` | service not constructed | diff --git a/docs/developer/api/media-items/restore_deleted_annotation.md b/docs/developer/api/media-items/restore_deleted_annotation.md new file mode 100644 index 00000000..5deeecad --- /dev/null +++ b/docs/developer/api/media-items/restore_deleted_annotation.md @@ -0,0 +1,36 @@ +--- +method: POST +path: /api/media-items/:id/annotations/:annotationId/restore +auth: jwt +area: media-items +--- + + +# Restore Deleted Annotation + +Restores a tombstoned annotation (highlight, note, or bookmark). The kind is +read from the `annotation_type` **query parameter** or, if absent, from a JSON +body `{"annotation_type": "..."}`. + +## Example request + +```text +POST /api/media-items/550e8400-.../annotations/440e8400-.../restore?annotation_type=highlight +``` + +## Example response + +```json example-response +{ + "restored": true +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "annotation_type must be highlight, note, or bookmark"}` | missing/invalid kind | +| 404 | `{"error": "deleted annotation not found"}` | nothing tombstoned under that id | +| 500 | `{"error": "failed to restore annotation"}` | unexpected server error | +| 503 | `{"error": "annotation service unavailable"}` | service not constructed | diff --git a/docs/developer/api/media-items/update_media_bookmark.md b/docs/developer/api/media-items/update_media_bookmark.md new file mode 100644 index 00000000..32a64f9d --- /dev/null +++ b/docs/developer/api/media-items/update_media_bookmark.md @@ -0,0 +1,71 @@ +--- +method: PUT +path: /api/media-items/:id/bookmarks/:bookmarkId +auth: jwt +area: media-items +--- + + +# Update Media Bookmark + +Edits a bookmark through the sync service: lands as a proper last-write-wins +edit with `source`/`modified_at` bookkeeping. Zero-valued locator fields +coalesce with the stored row (omitted fields keep their values). + +## Example request + +```json example-request +{ + "title": "Chapter 3 (renamed)", + "notes": "updated note", + "position": "kindle:pos:1:2" +} +``` + +## Example response + +```json example-response +{ + "id": "110e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "page_number": null, + "chapter_number": null, + "cfi_position": "epubcfi(/6/4)", + "title": "Chapter 3 (renamed)", + "position": "kindle:pos:1:2", + "notes": "updated note", + "created_at": "2026-10-24T13:57:01Z", + "dedup_key": "kindle:pos:1:2", + "last_modified_at": "2026-10-24T14:57:01Z", + "last_modified_source": "web", + "device_sync_data": null, + "percentage_location": 35.5, + "epubcfi_location": "epubcfi(/6/4)", + "chapter_reference": 3, + "deleted": false, + "deleted_at": null, + "origin_source": "web" +} +``` + +## Request fields + +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| title | string | yes | 1–255 chars | +| notes | string | no | | +| position | string | no | | +| cfi_position | string | no | zero value preserves stored | +| page_number | number | no | zero value preserves stored | +| chapter_number | number | no | zero value preserves stored | +| percentage | number | no | zero value preserves stored | +| chapter_reference | number | no | zero value preserves stored | +| origin | string | no | client label | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid bookmark id"}` / `{"error": "invalid media item id"}` / `{"error": "invalid request"}` | malformed | +| 500 | `{"error": "..."}` | annotation service failure | diff --git a/docs/developer/api/media-items/upsert_media_review.md b/docs/developer/api/media-items/upsert_media_review.md new file mode 100644 index 00000000..129fd5f3 --- /dev/null +++ b/docs/developer/api/media-items/upsert_media_review.md @@ -0,0 +1,43 @@ +--- +method: POST +path: /api/media-items/:id/review +auth: jwt +area: media-items +--- + + +# Upsert Media Review + +Sets (or updates) the user's personal review for a media item. **An empty / +whitespace review deletes the stored review instead** and returns +`{"review": null}`. + +`PUT /api/media-items/:id/review` is the same handler and behaves identically. + +## Example request + +```json example-request +{ + "review": "A slow start, but the second act earns it." +} +``` + +## Example response + +```json example-response +{ + "id": "220e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "review": "A slow start, but the second act earns it.", + "created_at": "2026-10-24T13:57:01Z", + "updated_at": "2026-10-24T13:57:01Z" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid request"}` / `{"error": "invalid media item id"}` | malformed | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/internal/docs/drift_test.go b/internal/docs/drift_test.go index 79c10e75..4878fbf9 100644 --- a/internal/docs/drift_test.go +++ b/internal/docs/drift_test.go @@ -231,9 +231,15 @@ func validateRequestExample(t *testing.T, doc *APIDoc, schema Schema) { } // Staleness guard: required fields must be present in the example. - for _, field := range requiredJSONFields(reflect.TypeOf(schema.Request)) { - if !jsonHasKey(doc.ExampleRequest, field) { - t.Errorf("%s: example-request missing required field %q", doc.RelPath, field) + rt := reflect.TypeOf(schema.Request) + for rt != nil && rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt != nil && rt.Kind() == reflect.Struct { + for _, field := range requiredJSONFields(rt) { + if !jsonHasKey(doc.ExampleRequest, field) { + t.Errorf("%s: example-request missing required field %q", doc.RelPath, field) + } } } } @@ -283,7 +289,7 @@ func validateResponseExample(t *testing.T, doc *APIDoc, schema Schema) { } // requiredJSONFields lists json field names of struct type whose validate tag -// marks them required. +// marks them required. Callers must pass a struct kind. func requiredJSONFields(rt reflect.Type) []string { if rt.Kind() == reflect.Ptr { rt = rt.Elem() diff --git a/internal/docs/schema.go b/internal/docs/schema.go index fff7ab64..00e91d47 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -163,4 +163,48 @@ var Schemas = map[string]Schema{ "message": "" }`), }, + + // --- media-items batch 3a: reader-adjacent CRUD --- + "GET /api/media-items/:id/bookmarks": { + Response: []database.MediaBookmarks{}, + }, + "POST /api/media-items/:id/bookmarks": { + Request: handlers.CreateMediaBookmarkRequest{}, + Response: database.MediaBookmarks{}, + }, + "PUT /api/media-items/:id/bookmarks/:bookmarkId": { + Request: handlers.UpdateMediaBookmarkRequest{}, + Response: database.MediaBookmarks{}, + }, + "DELETE /api/media-items/:id/bookmarks/:bookmarkId": {}, + // Review: the upsert binds an inline anonymous struct {review}; pinned + // by RawMessage. Empty review string = delete, returns {"review": null}. + "POST /api/media-items/:id/review": { + Request: json.RawMessage(`{"review": ""}`), + Response: database.MediaReviews{}, + }, + "PUT /api/media-items/:id/review": { + Request: json.RawMessage(`{"review": ""}`), + Response: database.MediaReviews{}, + }, + "GET /api/media-items/:id/review": { + Response: database.MediaReviews{}, + }, + "DELETE /api/media-items/:id/review": { + Response: json.RawMessage(`{"message": ""}`), + }, + "GET /api/media-items/:id/notes/:noteId": { + Response: database.MediaNotes{}, + }, + "GET /api/media-items/:id/highlights/:highlightId": { + Response: database.MediaHighlights{}, + }, + "POST /api/media-items/:id/annotations/:annotationId/restore": { + Request: nil, // kind via ?annotation_type= or optional body + Response: json.RawMessage(`{"restored": false}`), + }, + "DELETE /api/media-items/:id/annotations/:annotationId": { + Request: nil, // kind via required ?annotation_type= + Response: json.RawMessage(`{"purged": false}`), + }, }