diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 8e61af7..d80360d 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -233,21 +233,21 @@ ## Endpoint: GET /messages -- **Purpose:** Bearer required. Lists **top-level** forum notes only (`parent_id` null) newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, live author `role`, and `replyCount`), capped at 200 (latest-200 window). Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for Damus-only); `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. +- **Purpose:** Bearer required. Lists **top-level** forum notes only (`parent_id` null) newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, live author `role`, and `replyCount`), capped at 200 (latest-200 window). A `hasVideo` row whose file is missing or empty is deleted and omitted. For each kept top-level note, missing-file `hasVideo` direct replies in the replies window (cap 200) are deleted (`messages.video.dropped`); `replyCount` is the stored direct-reply count minus those dropped. Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for Damus-only); `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. - **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 503 `{ error: 'Messages are unavailable' }` if the store throws (`messages.list.failed`). - **Used by:** App public comment thread. - **Auth:** `Authorization: Bearer` session. ## Endpoint: GET /messages/:id -- **Purpose:** Public single-note fetch (no Bearer). Returns the public message JSON via `serializeMessage` (`sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`; live `role` for 21gifts authors; Damus-only `accountId: null` omits `role` and sets `payable` false). Never includes `accountId`. Photo/video bytes are never included. -- **Errors:** 404 `{ error: 'Not found' }` when `:id` is not a UUID or the row is missing; 503 `{ error: 'Messages are unavailable' }` when the store throws (`messages.get.failed`). +- **Purpose:** Public single-note fetch (no Bearer). Returns the public message JSON via `serializeMessage` (`sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`; live `role` for 21gifts authors; Damus-only `accountId: null` omits `role` and sets `payable` false). Never includes `accountId`. Photo/video bytes are never included. A `hasVideo` row whose file is missing or empty is deleted (`messages.video.dropped`) and then 404. +- **Errors:** 404 `{ error: 'Not found' }` when `:id` is not a UUID, the row is missing, or a missing-file video row was dropped; 503 `{ error: 'Messages are unavailable' }` when the store throws (`messages.get.failed`). - **Used by:** App deep links / share URLs for one forum note. - **Auth:** none (public). ## Endpoint: GET /messages/:id/replies -- **Purpose:** Bearer required. Lists direct replies for parent `:id` oldest-first (`createdAt` then `id` ASC), capped at 200. Body is `{ messages: [...] }` (same key as `GET /messages`, not `replies`). Each item is public message JSON with `payable` false; may include `accountId` for 21gifts authors (omitted for Damus-only); Damus-only replies omit `role`. +- **Purpose:** Bearer required. Lists direct replies for parent `:id` oldest-first (`createdAt` then `id` ASC), capped at 200. A `hasVideo` reply whose file is missing or empty is deleted (`messages.video.dropped`) and omitted from `{ messages }`. Body is `{ messages: [...] }` (same key as `GET /messages`, not `replies`). Each item is public message JSON with `payable` false; may include `accountId` for 21gifts authors (omitted for Damus-only); Damus-only replies omit `role`. - **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 404 `{ error: 'Not found' }` when `:id` is not a UUID or the parent is missing; 503 `{ error: 'Messages are unavailable' }` (`messages.replies.failed`). - **Used by:** App reply thread under a top-level note. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 54c9bc5..eabb2c5 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -163,7 +163,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `listLatest` is **top-level only** (`WHERE parent_id IS NULL`) with subquery `replyCount` (direct children), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listReplies` is oldest-first (`WHERE parent_id = $1`, `created_at ASC, id ASC`); `listPublishedEventIds` returns non-null top-level `event_id`s newest-first for inbound reply REQ; `create` inserts optional photo bytes and optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure); `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows (`claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id and no child reply exists (`NOT EXISTS`); `listSignedMissingPhoto` returns published **top-level** rows (`parent_id IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, video rows / `video_content_type` excluded so posters are not treated as missing photos, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingVideo` returns published **top-level** rows (`parent_id IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** rows (`parent_id IS NULL`, parents with children skipped via `NOT EXISTS`) whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches, `sats` is 0, and no child reply exists (`NOT EXISTS`); `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse`: raw LNURL callback JSON object or null); `recordZapIngest` / `listZapIngests`. +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `deleteById` removes zap receipts, invoices, child replies, and the row in **one** parameterised data-modifying CTE `query`, then unlinks on-disk videos from the returned rows. `listLatest` is **top-level only** (`WHERE parent_id IS NULL`) with subquery `replyCount` (direct children), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listReplies` is oldest-first (`WHERE parent_id = $1`, `created_at ASC, id ASC`); `listPublishedEventIds` returns non-null top-level `event_id`s newest-first for inbound reply REQ; `create` inserts optional photo bytes and optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure); `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows (`claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id and no child reply exists (`NOT EXISTS`); `listSignedMissingPhoto` returns published **top-level** rows (`parent_id IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, video rows / `video_content_type` excluded so posters are not treated as missing photos, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingVideo` returns published **top-level** rows (`parent_id IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** rows (`parent_id IS NULL`, parents with children skipped via `NOT EXISTS`) whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches, `sats` is 0, and no child reply exists (`NOT EXISTS`); `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse`: raw LNURL callback JSON object or null); `recordZapIngest` / `listZapIngests`. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `MessageRow` / `ForumPhoto` / invoice and ingest rows. Claim uses `FOR UPDATE SKIP LOCKED`. Errors propagate to the route (503) except invoice/ingest persist failures which are caught by callers. - **Used by:** `openBootStores` when `DATABASE_URL` is set. @@ -387,7 +387,7 @@ ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `getByEventId`, `listLatest` (top-level only, `parentId` null, each row has `replyCount`), `listReplies` (oldest-first for a parent), `listPublishedEventIds` (non-null top-level `eventId`s newest-first), claim/sign/publish (`claimUnsigned` is pending + null `eventId`; lease expires at `claimedUntil`), `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId` and the note has no child replies, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded, video rows excluded so posters are not treated as missing photos), `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats), `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse` object or null), `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos), `getByEventId`, `listLatest` (top-level only, `parentId` null, each row has `replyCount`), `listReplies` (oldest-first for a parent), `listPublishedEventIds` (non-null top-level `eventId`s newest-first), claim/sign/publish (`claimUnsigned` is pending + null `eventId`; lease expires at `claimedUntil`), `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId` and the note has no child replies, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded, video rows excluded so posters are not treated as missing photos), `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats; ids are released on `deleteById` so the same receipt can be recorded again), `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse` object or null), `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). - **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false). `listLatest(limit)` is top-level only (`parentId === null`) with `replyCount`, sorts newest `createdAt` then `id` DESC and caps at `limit`. `listReplies(parentId, limit?)` is oldest-first (default 200). `listPublishedEventIds(limit)` is newest-first non-null top-level `eventId`s. `create(row, photo?, video?)` appends a copy; `getPhoto(id)` returns a photo copy or null. - **Returns / side effects:** Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes. When `video` is set, `create` awaits `writeForumVideo` (disk under `MEDIA_DIR`); if that write throws, the row is never pushed (no unlink). - **Used by:** `createApp` default `messageStore`. @@ -597,9 +597,9 @@ ## Function: messagesRoutes -- **Purpose:** Hono sub-app for the public member forum: Bearer `GET /` lists **top-level** notes only (`parent_id` null) newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, `replyCount`); `POST /` creates text and/or one photo (JSON, optional `inReplyTo` UUID of a **top-level** parent) or one video (multipart `video` + optional JPEG/PNG/WebP `poster`) when the account has a non-blank display name; public `GET /:id` (no Bearer) returns one note; Bearer `GET /:id/replies` lists direct replies oldest-first; `GET /:id/photo` serves raw bytes without auth (Nostr `imeta`); `GET /:id/video.mp4|.webm|.mov` serves sized video bytes (`Content-Length`, `Accept-Ranges` / 206 / 416, heal-on-read faststart); `POST /:id/invoice` returns `{ pr, amountSats }` only for a NIP-57 `description_hash` invoice (otherwise 400 author's-wallet copy + persist `not_zap` / `noZap`; invoice limiter after payable/KEK checks; post limiter on create). After a successful **top-level** create (`parentId` null), optional `pushStore` enqueues forum pushes for other subscribed accounts (`push.enqueue.failed` is swallowed; POST still 200); replies do not enqueue. Product UX is a messenger group — clients reverse the newest-first list for display (oldest top, newest bottom). +- **Purpose:** Hono sub-app for the public member forum: Bearer `GET /` lists **top-level** notes only (`parent_id` null) newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, `replyCount`); a `hasVideo` row whose file is missing or empty is **deleted** (`messages.video.dropped`) and omitted; for each kept top-level note, missing-file `hasVideo` direct replies are dropped via `dropMissingVideoRow` / `deleteById` + `messages.video.dropped`, and `replyCount` is the stored `replyCount` minus how many missing-file video children were dropped in the replies window; `POST /` creates text and/or one photo (JSON, optional `inReplyTo` UUID of a **top-level** parent) or one video (multipart `video` + optional JPEG/PNG/WebP `poster`) when the account has a non-blank display name; public `GET /:id` (no Bearer) returns one note (404 after deleting a `hasVideo` row whose file is gone); Bearer `GET /:id/replies` lists direct replies oldest-first and likewise drops missing-file `hasVideo` replies via `dropMissingVideoRow` / `deleteById` + `messages.video.dropped`; `GET /:id/photo` serves raw bytes without auth (Nostr `imeta`); `GET /:id/video.mp4|.webm|.mov` serves sized video bytes (`Content-Length`, `Accept-Ranges` / 206 / 416, heal-on-read faststart); `POST /:id/invoice` returns `{ pr, amountSats }` only for a NIP-57 `description_hash` invoice (otherwise 400 author's-wallet copy + persist `not_zap` / `noZap`; invoice limiter after payable/KEK checks; post limiter on create). After a successful **top-level** create (`parentId` null), optional `pushStore` enqueues forum pushes for other subscribed accounts (`push.enqueue.failed` is swallowed; POST still 200); replies do not enqueue. Product UX is a messenger group — clients reverse the newest-first list for display (oldest top, newest bottom). - **Inputs:** `MessagesRouteDeps`: message `store`, shared `authStore`, `now`, optional `nostrKek`, `fetchImpl`, `postLimiter`, `invoiceLimiter`, optional `pushStore`. -- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/replies/invoice (public `GET /:id` and photo/video do not require Bearer); 400 on bad body / missing name / invalid text / bad photo / bad poster / bad video / unpaid note ("This message cannot be paid yet") / author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 `{ error: 'Not found' }` when JSON `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 404 photo/video/`GET /:id`/`GET /:id/replies` missing; 416 unsatisfiable video Range; 429 on post or invoice rate limits (invoice only after payable checks; NIP-57 reject still counts like other LNURL failures); 503 on store/KEK/sign failure (`messages.list.failed` / `messages.create.failed` / `messages.get.failed` / `messages.replies.failed` / `messages.photo.failed` / `messages.video.failed`). Public JSON includes `sats`/`payable`/`hasPhoto`/`hasVideo`/`videoContentType`/live `role` and omits media bytes (list includes `replyCount`; missing author → `role` `"basis"` on list; Damus-only omits `role`). Signed-in list/replies/create may include `accountId` (21gifts author id; omitted for Damus-only); public `GET /:id` never includes it. +- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/replies/invoice (public `GET /:id` and photo/video do not require Bearer); 400 on bad body / missing name / invalid text / bad photo / bad poster / bad video / unpaid note ("This message cannot be paid yet") / author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 `{ error: 'Not found' }` when JSON `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 404 photo/video/`GET /:id`/`GET /:id/replies` missing; 416 unsatisfiable video Range; 429 on post or invoice rate limits (invoice only after payable checks; NIP-57 reject still counts like other LNURL failures); 503 on store/KEK/sign failure (`messages.list.failed` / `messages.create.failed` / `messages.get.failed` / `messages.replies.failed` / `messages.photo.failed` / `messages.video.failed`). Public JSON includes `sats`/`payable`/`hasPhoto`/`hasVideo`/`videoContentType`/live `role` and omits media bytes (list `replyCount` is stored `replyCount` minus missing-file video children dropped in the replies window; missing author → `role` `"basis"` on list; Damus-only omits `role`). Signed-in list/replies/create may include `accountId` (21gifts author id; omitted for Damus-only); public `GET /:id` never includes it. - **Used by:** `createApp`. ## Function: contactRoutes @@ -646,7 +646,7 @@ ## Function: serializeMessage -- **Purpose:** Project a stored forum row to its public JSON shape including zap totals, payability, `hasPhoto`, `hasVideo`, `videoContentType`, live author role, optional `replyCount`, and optional `accountId`. +- **Purpose:** Project a stored forum row to its public JSON shape including zap totals, payability, `hasPhoto`, `hasVideo`, `videoContentType`, live author role, optional `replyCount`, and optional `accountId`. Callers that serve list/GET/replies delete a `hasVideo` row when the file is missing or empty on disk (`forumVideoFilePresent`) so no empty note remains. - **Inputs:** `MessageRow` (includes `accountId`; never photo/video bytes), `payable` boolean, optional `role` (`AccountRole`; omitted for Damus-only authors), optional `replyCount` (top-level `GET /messages` list rows), and optional `includeAccountId` (signed-in list/replies/create pass true; public GET omits). - **Returns / side effects:** `{ id, name, text, createdAt, sats, payable, hasPhoto, hasVideo, videoContentType }` with ISO-8601 `createdAt`; `videoContentType` is null when `hasVideo` is false; `role` omitted when undefined; `replyCount` omitted when undefined; `accountId` set only when `includeAccountId` is true and `row.accountId !== null` (Damus-only and public GET omit it); never photo/video bytes. No I/O. - **Used by:** `messagesRoutes`. @@ -1353,6 +1353,13 @@ - **Returns / side effects:** path. - **Used by:** write/read/serve. +## Function: forumVideoFilePresent + +- **Purpose:** True when the stored video file exists, is a regular file, and is non-empty. +- **Inputs:** media dir, message id, MIME or `null`, optional `stat` inject. +- **Returns / side effects:** `false` when MIME is null, the path is missing (`ENOENT`), not a file, or size 0; non-ENOENT `stat` errors propagate (callers must not delete the row). Stats disk. +- **Used by:** `messagesRoutes` list, public GET, and replies. + ## Function: wellKnownRoutes - **Purpose:** Hono `GET /nostr.json` (CORS `*`). diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index eef858f..3e872d4 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -1083,6 +1083,12 @@ test('Function: videoFilePath — POST /messages without bearer is 401', async ( }); expect(res.status()).toBe(401); }); +test('Function: forumVideoFilePresent — GET /messages/:id without a file is 404', async ({ + request, +}) => { + const res = await request.get('/messages/5c5051d3-adba-44f9-a964-9bd0df1ce084'); + expect([200, 404]).toContain(res.status()); +}); test('Function: wellKnownRoutes — GET /.well-known/nostr.json is 200', async ({ request }) => { expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); }); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 9f616d8..1a924d3 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; import type { SqlClient } from '@/lib/auth/sql'; import { unsignedNostrDefaults, type ForumPhoto, type MessageRow } from '@/lib/message'; import { @@ -9,6 +10,7 @@ import { type MessageInvoiceAttempt, type ZapIngestRow, } from '@/lib/message-store'; +import { resolveMediaDir, videoFilePath } from '@/lib/video'; class MockSql implements SqlClient { executes: { text: string; params: readonly unknown[] }[] = []; @@ -120,6 +122,65 @@ describe('InMemoryMessageStore', () => { expect(await new InMemoryMessageStore().listLatest(10)).toEqual([]); }); + it('deleteById removes the row and returns false when missing', async () => { + const store = new InMemoryMessageStore([EARLY, LATE]); + expect(await store.deleteById('missing')).toBe(false); + expect(await store.deleteById('a')).toBe(true); + expect(await store.getById('a')).toBeUndefined(); + expect((await store.listLatest(10)).map((row) => row.id)).toEqual(['b']); + }); + + it('deleteById cascades replies, invoices, zap receipts, photo, and video', async () => { + const store = new InMemoryMessageStore(); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await store.create({ ...EARLY, id: 'p-del', text: 'parent' }, JPEG, { + contentType: 'video/mp4', + bytes: mp4, + }); + await store.create({ + ...LATE, + id: 'c-del', + parentId: 'p-del', + text: 'child', + }); + const invoice: MessageInvoiceAttempt = { + id: 'inv-del', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + messageId: 'p-del', + payerAccountId: 'payer', + authorAccountId: 'author', + amountSats: 21, + lightningAddress: 'a@b.com', + zapRequest: { kind: 9734 }, + result: 'ok', + httpStatus: 200, + pr: 'lnbc1', + paymentHash: 'aa'.repeat(32), + description: null, + descriptionHash: 'bb'.repeat(32), + isNip57Invoice: true, + lnurlResponse: null, + }; + await store.recordInvoiceAttempt(invoice); + expect(await store.recordZapReceipt('receipt-del', 'p-del', 21)).toBe(true); + expect(await store.recordZapReceipt('receipt-del', 'p-del', 21)).toBe(false); + await store.create({ ...LATE }); + expect(await store.recordZapReceipt('receipt-keep', LATE.id, 1)).toBe(true); + const videoPath = videoFilePath(resolveMediaDir(), 'p-del', 'video/mp4'); + await readFile(videoPath); + expect(await store.deleteById('p-del')).toBe(true); + expect(await store.getById('p-del')).toBeUndefined(); + expect(await store.getById('c-del')).toBeUndefined(); + expect( + (await store.listInvoiceAttempts(10)).filter((row) => row.messageId === 'p-del'), + ).toEqual([]); + expect(await store.recordZapReceipt('receipt-del', 'p-del', 7)).toBe(true); + expect(await store.recordZapReceipt('receipt-keep', 'b', 1)).toBe(false); + expect(await store.getPhoto('p-del')).toBeNull(); + await expect(readFile(videoPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('copies the seed and listed rows so callers cannot mutate store state', async () => { const seed: MessageRow[] = [EARLY, LATE]; const store = new InMemoryMessageStore(seed); @@ -1171,6 +1232,33 @@ describe('PostgresMessageStore', () => { expect(sql.executes.some((e) => e.text.includes('sats = sats +'))).toBe(true); }); + it('deleteById issues one CTE query for receipts, invoices, and rows', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { id: 'm1', video_content_type: 'video/mp4' }, + { id: 'm1-child', video_content_type: null }, + ]; + expect(await new PostgresMessageStore(sql).deleteById('m1')).toBe(true); + expect(sql.executes).toEqual([]); + expect(sql.queries).toHaveLength(1); + const text = sql.queries[0]?.text ?? ''; + expect(text).toMatch(/WITH/); + expect(text).toMatch(/DELETE FROM nostr_zap_receipt/); + expect(text).toMatch(/DELETE FROM message_invoice/); + expect(text).toMatch(/DELETE FROM message/); + expect(text).toMatch(/parent_id = \$1/); + expect(sql.queries[0]?.params).toEqual(['m1']); + }); + + it('deleteById returns false when the CTE finds no rows', async () => { + const sql = new MockSql(); + sql.nextRows = []; + expect(await new PostgresMessageStore(sql).deleteById('missing')).toBe(false); + expect(sql.executes).toEqual([]); + expect(sql.queries).toHaveLength(1); + expect(sql.queries[0]?.text).toMatch(/WITH/); + }); + it('propagates create execute errors', async () => { const sql = new MockSql(); sql.executeError = new Error('create boom'); diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index 4984033..a18ea7d 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -1105,6 +1105,7 @@ describe('indexOpenZapReceipts', () => { listPublishedEventIds: (limit: number) => base.listPublishedEventIds(limit), create: (...args: Parameters) => base.create(...args), getPhoto: (id: string) => base.getPhoto(id), + deleteById: (id: string) => base.deleteById(id), getById: (id: string) => base.getById(id), getByEventId: (id: string) => base.getByEventId(id), claimUnsigned: (...args: Parameters) => diff --git a/src/__tests__/lib/video.test.ts b/src/__tests__/lib/video.test.ts index af4e319..411aafc 100644 --- a/src/__tests__/lib/video.test.ts +++ b/src/__tests__/lib/video.test.ts @@ -6,6 +6,7 @@ import { detectVideoContentType, faststartIsoBmff, forumVideoExt, + forumVideoFilePresent, forumVideoUrl, isoBmffDisplaySize, parseBytesRange, @@ -188,6 +189,37 @@ describe('video', () => { expect(decodeForumVideo(mp4Bytes())?.contentType).toBe('video/mp4'); }); + it('reports when a video file is present on disk', async () => { + const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + expect(await forumVideoFilePresent(resolveMediaDir(), id, null)).toBe(false); + expect(await forumVideoFilePresent(resolveMediaDir(), id, 'video/mp4')).toBe(false); + await writeForumVideo(id, { contentType: 'video/mp4', bytes: mp4Bytes() }); + try { + expect(await forumVideoFilePresent(resolveMediaDir(), id, 'video/mp4')).toBe(true); + } finally { + await removeForumVideo(id, 'video/mp4'); + } + const emptyId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const emptyPath = videoFilePath(resolveMediaDir(), emptyId, 'video/mp4'); + await fs.writeFile(emptyPath, new Uint8Array()); + try { + expect(await forumVideoFilePresent(resolveMediaDir(), emptyId, 'video/mp4')).toBe(false); + } finally { + await fs.unlink(emptyPath); + } + expect( + await forumVideoFilePresent(resolveMediaDir(), id, 'video/mp4', async () => ({ + isFile: () => false, + size: 12, + })), + ).toBe(false); + await expect( + forumVideoFilePresent(resolveMediaDir(), id, 'video/mp4', async () => { + throw { code: 'EACCES' }; + }), + ).rejects.toMatchObject({ code: 'EACCES' }); + }); + it('builds Damus-friendly video URLs', () => { expect(forumVideoExt('video/mp4')).toBe('mp4'); expect(forumVideoExt('video/webm')).toBe('webm'); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 96da586..b6c9016 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -102,6 +102,7 @@ function throwingStore(overrides: Partial = {}): MessageStore { listPublishedEventIds: boom, create: boom, getPhoto: boom, + deleteById: boom, getById: boom, getByEventId: boom, claimUnsigned: boom, @@ -158,6 +159,121 @@ describe('GET /messages', () => { expect(await res.json()).toEqual({ messages: [] }); }); + it('drops missing-file video notes from the list', async () => { + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore([ + { + id: '5c5051d3-adba-44f9-a964-9bd0df1ce084', + accountId: 'acc', + name: 'Ada', + text: 'clip gone', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + }, + ]); + const res = await mount(authStore, messageStore).request('/messages', { headers: AUTH }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ messages: [] }); + expect(await messageStore.getById('5c5051d3-adba-44f9-a964-9bd0df1ce084')).toBeUndefined(); + }); + + it('lists a live parent with replyCount after dropping a missing-file video reply', async () => { + const parentId = '5c5051d3-adba-44f9-a964-9bd0df1ce085'; + const goneChildId = '5c5051d3-adba-44f9-a964-9bd0df1ce086'; + const keptChildId = '5c5051d3-adba-44f9-a964-9bd0df1ce087'; + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore([ + { + id: parentId, + accountId: 'acc', + name: 'Ada', + text: 'live parent', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: false, + videoContentType: null, + }, + { + id: goneChildId, + accountId: 'acc', + name: 'Ada', + text: 'clip gone', + createdAt: new Date(now() + 1), + ...unsignedNostrDefaults(), + parentId, + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + }, + { + id: keptChildId, + accountId: 'acc', + name: 'Ada', + text: 'text reply', + createdAt: new Date(now() + 2), + ...unsignedNostrDefaults(), + parentId, + hasPhoto: false, + hasVideo: false, + videoContentType: null, + }, + ]); + const res = await mount(authStore, messageStore).request('/messages', { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + messages: Array<{ id: string; replyCount: number; text: string }>; + }; + expect(body.messages).toHaveLength(1); + expect(body.messages[0]?.id).toBe(parentId); + expect(body.messages[0]?.replyCount).toBe(1); + expect(await messageStore.getById(goneChildId)).toBeUndefined(); + expect(await messageStore.getById(keptChildId)).toBeDefined(); + expect(await messageStore.getById(parentId)).toBeDefined(); + }); + + it('lists replyCount above the 200-reply list window', async () => { + const parentId = '5c5051d3-adba-44f9-a964-9bd0df1ce088'; + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: parentId, + accountId: 'acc', + name: 'Ada', + text: 'parent with many replies', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: false, + videoContentType: null, + }); + for (let i = 0; i < 201; i++) { + await messageStore.create({ + id: crypto.randomUUID(), + accountId: 'acc', + name: 'Ada', + text: `reply ${i}`, + createdAt: new Date(now() + 1 + i), + ...unsignedNostrDefaults(), + parentId, + hasPhoto: false, + hasVideo: false, + videoContentType: null, + }); + } + const res = await mount(authStore, messageStore).request('/messages', { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + messages: Array<{ id: string; replyCount: number }>; + }; + expect(body.messages).toHaveLength(1); + expect(body.messages[0]?.id).toBe(parentId); + expect(body.messages[0]?.replyCount).toBe(201); + }); + it('returns newest first', async () => { const authStore = await namedStore('Ada'); const messageStore = new InMemoryMessageStore(); @@ -1830,6 +1946,7 @@ describe('POST /messages/:id/invoice', () => { create: (row, photo) => base.create(row, photo), getPhoto: (id) => base.getPhoto(id), getById: (id) => base.getById(id), + deleteById: (id) => base.deleteById(id), getByEventId: (id) => base.getByEventId(id), claimUnsigned: (...args) => base.claimUnsigned(...args), claimUnpublished: (...args) => base.claimUnpublished(...args), @@ -2087,6 +2204,29 @@ describe('GET /messages/:id', () => { expect(body).not.toHaveProperty('replyCount'); }); + it('deletes a hasVideo note when the file is missing on disk', async () => { + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore([ + { + id: '5c5051d3-adba-44f9-a964-9bd0df1ce084', + accountId: 'acc', + name: 'Ada', + text: 'clip gone', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + }, + ]); + const res = await mount(authStore, messageStore).request( + '/messages/5c5051d3-adba-44f9-a964-9bd0df1ce084', + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + expect(await messageStore.getById('5c5051d3-adba-44f9-a964-9bd0df1ce084')).toBeUndefined(); + }); + it('includes the live author role for a 21gifts note', async () => { const authStore = await namedStore('Ada'); const messageStore = new InMemoryMessageStore(); @@ -2171,6 +2311,56 @@ describe('GET /messages/:id', () => { }); describe('GET /messages/:id/replies', () => { + it('drops replies whose video file is missing', async () => { + const parentId = '14141414-1414-4141-8141-141414141414'; + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore([ + { + id: parentId, + accountId: 'acc', + name: 'Ada', + text: 'parent', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: false, + videoContentType: null, + }, + { + id: '15151515-1515-4151-8151-151515151515', + accountId: null, + name: 'aabbccdd…8899', + text: 'damus clip', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + parentId, + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + authorPubkey: 'ab'.repeat(32), + }, + { + id: '16161616-1616-4161-8161-161616161616', + accountId: 'acc', + name: 'Ada', + text: 'member clip', + createdAt: new Date(now() + 1), + ...unsignedNostrDefaults(), + parentId, + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + }, + ]); + const res = await mount(auth, store).request(`/messages/${parentId}/replies`, { + headers: AUTH, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ messages: [] }); + expect(await store.getById('15151515-1515-4151-8151-151515151515')).toBeUndefined(); + expect(await store.getById('16161616-1616-4161-8161-161616161616')).toBeUndefined(); + }); + it('returns 401 without a session', async () => { const res = await mount(new InMemoryAuthStore()).request( '/messages/14141414-1414-4141-8141-141414141414/replies', @@ -2488,6 +2678,9 @@ describe('forum video', () => { expect(created.hasVideo).toBe(true); expect(created.hasPhoto).toBe(true); expect(created.videoContentType).toBe('video/mp4'); + const publicGet = await app.request(`/messages/${created.id}`); + expect(publicGet.status).toBe(200); + expect(((await publicGet.json()) as { hasVideo: boolean }).hasVideo).toBe(true); const full = await app.request(`/messages/${created.id}/video.mp4`); expect(full.status).toBe(200); expect(full.headers.get('Accept-Ranges')).toBe('bytes'); diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 5b7f9f8..3c31562 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -109,6 +109,15 @@ export interface MessageStore { */ getPhoto(id: string): Promise; + /** + * Delete a note, its direct replies, invoice attempts, zap receipts, photos, + * and on-disk videos. + * + * @param id - Message id. + * @returns True when a row was removed. + */ + deleteById(id: string): Promise; + /** One row by id, or `undefined`. */ getById(id: string): Promise; @@ -221,8 +230,10 @@ export interface MessageStore { /** * Persist a zap receipt once and add its sats to the message. + * Both adapters forget the receipt id when {@link MessageStore.deleteById} + * removes that message, so the same event id may be recorded again. * - * @param receiptEventId - Kind:9735 event id (unique). + * @param receiptEventId - Kind:9735 event id (unique while held). * @param messageId - Forum row to credit. * @param sats - Whole sats to add. * @returns `true` when the receipt was new and sats were added; `false` on @@ -423,7 +434,8 @@ function copyZapIngest(row: ZapIngestRow): ZapIngestRow { */ export class InMemoryMessageStore implements MessageStore { readonly #rows: MessageRow[]; - readonly #receiptIds = new Set(); + /** Kind:9735 event id → message id; cleared when that message is deleted. */ + readonly #receiptIds = new Map(); readonly #photos = new Map(); readonly #invoiceAttempts: MessageInvoiceAttempt[] = []; readonly #zapIngests: ZapIngestRow[] = []; @@ -765,7 +777,7 @@ export class InMemoryMessageStore implements MessageStore { if (this.#receiptIds.has(receiptEventId)) { return false; } - this.#receiptIds.add(receiptEventId); + this.#receiptIds.set(receiptEventId, messageId); await this.addSats(messageId, sats); return true; } @@ -802,6 +814,35 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(sorted.slice(0, limit).map((row) => copyZapIngest(row))); } + async deleteById(id: string): Promise { + const row = this.#rows.find((item) => item.id === id); + if (row === undefined) { + return false; + } + const childIds = this.#rows.filter((item) => item.parentId === id).map((item) => item.id); + const ids = new Set([id, ...childIds]); + for (const item of this.#rows) { + if (!ids.has(item.id)) { + continue; + } + const mime = item.videoContentType; + if (item.hasVideo === true && mime !== undefined && mime !== null) { + await removeForumVideo(item.id, mime); + } + this.#photos.delete(item.id); + } + this.#rows.splice(0, this.#rows.length, ...this.#rows.filter((item) => !ids.has(item.id))); + const kept = this.#invoiceAttempts.filter((item) => !ids.has(item.messageId)); + this.#invoiceAttempts.length = 0; + this.#invoiceAttempts.push(...kept); + for (const [receiptEventId, messageId] of this.#receiptIds) { + if (ids.has(messageId)) { + this.#receiptIds.delete(receiptEventId); + } + } + return true; + } + #claim( predicate: (row: MessageRow) => boolean, limit: number, @@ -1050,6 +1091,42 @@ export class PostgresMessageStore implements MessageStore { return stored; } + async deleteById(id: string): Promise { + const targets = await this.#sql.query<{ id: string; video_content_type: string | null }>( + `WITH + targets AS ( + SELECT id, video_content_type + FROM message + WHERE id = $1 OR parent_id = $1 + ), + del_receipts AS ( + DELETE FROM nostr_zap_receipt + WHERE message_id IN (SELECT id FROM targets) + ), + del_invoices AS ( + DELETE FROM message_invoice + WHERE message_id IN (SELECT id FROM targets) + ), + del_rows AS ( + DELETE FROM message + WHERE id IN (SELECT id FROM targets) + RETURNING id + ) + SELECT t.id, t.video_content_type FROM targets t`, + [id], + ); + if (targets.length === 0) { + return false; + } + for (const target of targets) { + const mime = parseVideoContentType(target.video_content_type); + if (mime !== null) { + await removeForumVideo(target.id, mime); + } + } + return true; + } + async getById(id: string): Promise { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} diff --git a/src/lib/video.ts b/src/lib/video.ts index 8c62ce5..2849bdf 100644 --- a/src/lib/video.ts +++ b/src/lib/video.ts @@ -127,6 +127,41 @@ export function videoFilePath(dir: string, messageId: string, mime: ForumVideoCo return join(dir, `${messageId}.${forumVideoExt(mime)}`); } +/** + * True when the stored video file exists, is a regular file, and is non-empty. + * + * @param dir - Media directory. + * @param messageId - Message id. + * @param mime - Stored type, or `null` when the row has no video. + * @param statFn - Injected `stat` (tests). + * @returns False when `mime` is null, the path is missing (`ENOENT`), not a file, or size 0. + * @throws Non-ENOENT `stat` failures (for example EACCES, EIO). + */ +export async function forumVideoFilePresent( + dir: string, + messageId: string, + mime: ForumVideoContentType | null, + statFn: (path: string) => Promise<{ isFile: () => boolean; size: number }> = fs.stat, +): Promise { + if (mime === null) { + return false; + } + try { + const info = await statFn(videoFilePath(dir, messageId, mime)); + return info.isFile() && info.size > 0; + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return false; + } + throw err; + } +} + /** * Detect MP4 / QuickTime / WebM from magic bytes. * diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 46fe3aa..96c34fa 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -34,6 +34,7 @@ import { MESSAGE_VIDEO_MAX_BYTES, decodeForumVideo, forumVideoExt, + forumVideoFilePresent, parseBytesRange, readForumVideoBytes, resolveMediaDir, @@ -42,6 +43,34 @@ import { } from '@/lib/video'; import { stat } from 'node:fs/promises'; +/** + * Delete a `hasVideo` row whose file is missing or empty. Notes without video + * are unchanged. + * + * @param store - Message store. + * @param row - Store row. + * @returns The row, or `null` when it was deleted. + */ +async function dropMissingVideoRow( + store: MessageStore, + row: MessageRow, +): Promise { + if ( + row.hasVideo !== true || + row.videoContentType === undefined || + row.videoContentType === null + ) { + return row; + } + const present = await forumVideoFilePresent(resolveMediaDir(), row.id, row.videoContentType); + if (present) { + return row; + } + await store.deleteById(row.id); + logEvent('messages.video.dropped'); + return null; +} + /** True when `err` is a Node errno with `code === 'ENOENT'`. */ function isPathNotFound(err: unknown): boolean { return ( @@ -405,7 +434,21 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const payable = row.eventId !== null && author !== undefined && author.lightningAddress !== null; const role = row.accountId === null ? undefined : (author?.role ?? 'basis'); - messages.push(serializeMessage(row, payable, role, row.replyCount, true)); + const kept = await dropMissingVideoRow(deps.store, row); + if (kept === null) { + continue; + } + const children = await deps.store.listReplies(kept.id, MESSAGE_LIST_LIMIT); + let dropped = 0; + for (const child of children) { + const keptChild = await dropMissingVideoRow(deps.store, child); + if (keptChild === null) { + dropped += 1; + } + } + messages.push( + serializeMessage(kept, payable, role, Math.max(0, row.replyCount - dropped), true), + ); } return c.json({ messages }, 200); } catch { @@ -517,12 +560,20 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const messages = []; for (const row of rows) { if (row.accountId === null) { - messages.push(serializeMessage(row, false, undefined, undefined, true)); + const keptDamus = await dropMissingVideoRow(deps.store, row); + if (keptDamus === null) { + continue; + } + messages.push(serializeMessage(keptDamus, false, undefined, undefined, true)); continue; } const author = await deps.authStore.getAccount(row.accountId); const role = author?.role ?? 'basis'; - messages.push(serializeMessage(row, false, role, undefined, true)); + const kept = await dropMissingVideoRow(deps.store, row); + if (kept === null) { + continue; + } + messages.push(serializeMessage(kept, false, role, undefined, true)); } return c.json({ messages }, 200); } catch { @@ -548,7 +599,11 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { author !== undefined && author.lightningAddress !== null; const role = row.accountId === null ? undefined : (author?.role ?? 'basis'); - return c.json(serializeMessage(row, payable, role), 200); + const kept = await dropMissingVideoRow(deps.store, row); + if (kept === null) { + return c.json({ error: 'Not found' }, 404); + } + return c.json(serializeMessage(kept, payable, role), 200); } catch { logEvent('messages.get.failed'); return c.json({ error: 'Messages are unavailable' }, 503);