From 4c2431afde5da27f08b0601f7996acc4497e01eb Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:23:23 +0200 Subject: [PATCH 01/19] Let moderators edit a shop note and keep a history Staff at moderator rank or higher can replace the text of a live top-level shop note at any time. Place and shop-account changes are recorded in the same history. The signed Nostr event stays as published. --- docs/handbook/endpoints.md | 18 +- e2e/http.spec.ts | 12 + src/__tests__/lib/message-store.test.ts | 183 +++++++- src/__tests__/lib/nostr/zap-index.test.ts | 12 + src/__tests__/routes/messages.test.ts | 499 ++++++++++++++++++++++ src/__tests__/sunday-rest-routes.test.ts | 18 + src/lib/message-store.ts | 148 +++++++ src/lib/sunday-rest.ts | 1 + src/routes/messages.ts | 202 ++++++++- 9 files changed, 1074 insertions(+), 19 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index f11bf1e7f..736460eb6 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -37,18 +37,32 @@ ## Endpoint: PATCH /messages/:id/place -- **Purpose:** Bearer required. A moderator sets, replaces, or clears the map pin on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setPlace`. The first pin, when `mapPush` is configured, is posted once to `POST /map/places`; a failure logs `ocp.place.failed` and this response stays 200. Boot leaves `mapPush` unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A replace or a clear does not post again. Does not republish Nostr, change note text, or notify. `place: null` clears; a missing `place` key does not. Success is the live public message JSON (optional `place`, reply count, no hide stamps). +- **Purpose:** Bearer required. A moderator sets, replaces, or clears the map pin on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setPlace`. The first pin, when `mapPush` is configured, is posted once to `POST /map/places`; a failure logs `ocp.place.failed` and this response stays 200. Boot leaves `mapPush` unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A replace or a clear does not post again. Does not republish Nostr, change note text, or notify. `place: null` clears; a missing `place` key does not. A real change appends `message_edit`. Success is the live public message JSON (optional `place`, reply count, no hide stamps). - **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, missing or hidden row, or `setPlace` false; 400 `{ error: 'Invalid body' }` for non-JSON or a missing `place` key; 400 `{ error: 'Place must be a latitude and longitude' }`; 400 `{ error: 'Place label must be at most 80 characters' }`; 400 `{ error: 'A reply cannot include a place' }`; 400 `{ error: 'Only a shop note can set a place' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** Staff shop map pin in the app forum. - **Auth:** `Authorization: Bearer` session (moderator). ## Endpoint: PATCH /messages/:id/shop-account -- **Purpose:** Bearer required. A moderator sets, replaces, or clears the 21.gifts account on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setShopAccount`. The assignment is not the note author. Does not republish Nostr or change note text. `username: null` clears; a missing `username` key does not. Success is the live public message JSON (optional `shopAccount`, reply count, no hide stamps). +- **Purpose:** Bearer required. A moderator sets, replaces, or clears the 21.gifts account on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setShopAccount`. The assignment is not the note author. Does not republish Nostr or change note text. `username: null` clears; a missing `username` key does not. A real change appends `message_edit`. Success is the live public message JSON (optional `shopAccount`, reply count, no hide stamps). - **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a hidden row, or `setShopAccount` false; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or has no `username` key; 400 `{ error: 'Username is not valid' }`; 404 `{ error: 'No account with that username' }`; 400 `{ error: 'A reply cannot include a shop account' }`; 400 `{ error: 'Only a shop note can set a shop account' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** The app shops feed. - **Auth:** `Authorization: Bearer` session (moderator). +## Endpoint: PATCH /messages/:id/text + +- **Purpose:** Bearer required. A moderator changes the text of a live top-level shop note (`#21GiftsShop`) via `MessageStore.updateText`. The shop tag is kept or restored. Does not republish Nostr, notify, or change sats, media, author, mentions, event ids, or publish state. An unchanged body is 200 without a history row. A real change appends `message_edit`. Success is the live public message JSON (reply count, no hide stamps, no `edits` field). +- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a hidden row, or `updateText` undefined; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or `text` is missing or not a string; 400 `{ error: 'Text must be 1–8000 characters' }`; 400 `{ error: 'Text must be 1–8000 characters or include a photo' }`; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). +- **Used by:** Staff shop-note text edit in the app forum. +- **Auth:** `Authorization: Bearer` session (moderator). + +## Endpoint: GET /messages/:id/edits + +- **Purpose:** Bearer required. A moderator lists `message_edit` history for a top-level shop note, newest first, including a hidden shop note. Does not change the note and does not republish Nostr. GET is not a Sunday write. Actors resolve like hide stamps (missing account keeps the id with null name/role). Public message JSON does not include `edits`. Empty history is `{ edits: [] }`. +- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a reply, or a note that is not a shop note; 503 `{ error: 'Messages are unavailable' }`. GET history is not refused with `SUNDAY_REST`. +- **Used by:** Staff shop-note history in the app forum. +- **Auth:** `Authorization: Bearer` session (moderator). + ## Endpoint: GET /messages/hidden - **Purpose:** Bearer session required (moderator; not `DEBUG_TOKEN`). Inverse **read** of `DELETE /messages/:id`. Lists soft-hidden forum rows (`deletedAt` set) newest-hidden first (`deletedAt` desc, then `id` desc), capped at 200, via `listHidden` / `serializeHiddenMessage`. Listed rows include every hidden row regardless of external-zapper entitlement — this endpoint is a moderation view and is intentionally unaffected by the read-visibility rule described above. Each item includes stored `name` (no empty-name pubkey fallback), ISO `createdAt` / `deletedAt`, `hasPhoto` / `photoCount` / `photoTakenAts` (length equals `photoCount`; null when unknown; `[]` when there are no stills; `photoTakenAt` only when `photoCount === 1`) / `hasVideo` / `videoContentType`, optional `goalSats` (positive integer on a top-level note; omitted when unset/null/0/absent or on a reply), optional `goalRepayable: true` only when stored true (omitted when null, never false, omitted on a reply), optional `goalTermDays` when stored (omitted when null, omitted on a reply), `goalCurrency`, `goalAmount`, and the four `goalAmount*` snapshots when `goalCurrency` is stored (omitted on a legacy row; a snapshot may be null), optional `place` when a pin is stored (omitted when unset), optional `shopAccount` (`{ id, username, name }`) when a shop account is stored (omitted when unset), always-present `parentId` (JSON `null` on top-level), and `deletedBy: { id, name, role }` resolved from `authStore.getAccount` (missing account keeps the id with `name` / `role` null; null `deletedBy` is `{ id: null, name: null, role: null }`). Optional `via: 'nostr'` is present exactly when `accountId === null && authorPubkey !== null`, the same rule as public message JSON; the pubkey itself is never included. Includes `accountId` when the author is a 21.gifts account and omits it for an external row. Never includes `eventId`, `nostrPublishState`, `payable`, author `role`, `nostrEvent`, `claimedUntil`, `contentFp`, nsec, or photo/video bytes. Unsigned/non-staff list/GET/photo/video stay 404 for hidden rows; a founder/moderator session may GET the hidden permalink and photo/video. No `forum.read` gate — a moderator without rules agreement is still 200. Logs `messages.hidden.listed` with `{ count }` only (never post text, never message ids). Registered before public `GET /messages/:id` so `"hidden"` is not captured as `:id`. No staff UNHIDE session route. diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index b4bc158fa..61b00e258 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -234,6 +234,18 @@ test('PATCH /messages/:id/shop-account without bearer is 401', async ({ request expect(res.status()).toBe(401); }); +test('PATCH /messages/:id/text without bearer is 401', async ({ request }) => { + const res = await request.patch('/messages/:id/text', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); + +test('GET /messages/:id/edits without bearer is 401', async ({ request }) => { + const res = await request.get('/messages/:id/edits'); + expect(res.status()).toBe(401); +}); + test('GET /messages/:id/replies without bearer is 404', async ({ request }) => { const res = await request.get('/messages/:id/replies'); expect(res.status()).toBe(404); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 8ca0bc140..b54295f72 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -96,7 +96,7 @@ const JPEG2: ForumPhoto = { describe('MESSAGE_SCHEMA_SQL', () => { it('creates message with photo columns, Nostr columns, index, and additive ALTERs', () => { - expect(MESSAGE_SCHEMA_SQL).toHaveLength(96); + expect(MESSAGE_SCHEMA_SQL).toHaveLength(98); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch( /ALTER TABLE message ADD COLUMN IF NOT EXISTS place_lat double precision/i, ); @@ -251,28 +251,40 @@ describe('MESSAGE_SCHEMA_SQL', () => { expect(fundedColumn).toBeGreaterThan(recordedColumn); expect(fundedBackfill).toBe(fundedColumn + 1); expect(recordedBackfill).toBe(fundedColumn + 2); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain('FROM pg_trigger'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain("tgname = 'trg_db_change'"); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain("jsonb_typeof(nostr_event) = 'string'"); - expect(MESSAGE_SCHEMA_SQL.at(-1)).not.toContain('EXCEPTION WHEN others'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).not.toContain('EXCEPTION WHEN invalid_text_representation'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain( + expect(MESSAGE_SCHEMA_SQL[95]).toContain('FROM pg_trigger'); + expect(MESSAGE_SCHEMA_SQL[95]).toContain("tgname = 'trg_db_change'"); + expect(MESSAGE_SCHEMA_SQL[95]).toContain("jsonb_typeof(nostr_event) = 'string'"); + expect(MESSAGE_SCHEMA_SQL[95]).not.toContain('EXCEPTION WHEN others'); + expect(MESSAGE_SCHEMA_SQL[95]).not.toContain('EXCEPTION WHEN invalid_text_representation'); + expect(MESSAGE_SCHEMA_SQL[95]).toContain( 'EXCEPTION WHEN data_exception OR statement_too_complex THEN', ); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain( + expect(MESSAGE_SCHEMA_SQL[95]).toContain( "unwrapped := (repair_row.nostr_event #>> '{}')::jsonb;", ); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain('SET nostr_event = unwrapped'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain('nostr_attempts = 0'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain('CONTINUE;'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toContain('AND nostr_event = repair_row.nostr_event'); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toMatch( + expect(MESSAGE_SCHEMA_SQL[95]).toContain('SET nostr_event = unwrapped'); + expect(MESSAGE_SCHEMA_SQL[95]).toContain('nostr_attempts = 0'); + expect(MESSAGE_SCHEMA_SQL[95]).toContain('CONTINUE;'); + expect(MESSAGE_SCHEMA_SQL[95]).toContain('AND nostr_event = repair_row.nostr_event'); + expect(MESSAGE_SCHEMA_SQL[95]).toMatch( /WHERE id = repair_row\.id[\s\S]*?jsonb_typeof\(nostr_event\) = 'string'[\s\S]*?AND nostr_event = repair_row\.nostr_event;/, ); - expect(MESSAGE_SCHEMA_SQL.at(-1)).toMatch( + expect(MESSAGE_SCHEMA_SQL[95]).toMatch( /unwrapped := \(repair_row\.nostr_event #>> '\{\}'\)::jsonb;[\s\S]*?EXCEPTION WHEN data_exception OR statement_too_complex THEN[\s\S]*?CONTINUE;[\s\S]*?END;[\s\S]*?UPDATE message/, ); - expect(MESSAGE_SCHEMA_SQL.at(-1)).not.toContain('repair_row.unwrapped_event'); + expect(MESSAGE_SCHEMA_SQL[95]).not.toContain('repair_row.unwrapped_event'); + expect(MESSAGE_SCHEMA_SQL[96]).toMatch(/CREATE TABLE IF NOT EXISTS message_edit/); + expect(MESSAGE_SCHEMA_SQL[96]).toMatch( + /CONSTRAINT message_edit_field_chk CHECK \(field IN \('text', 'place', 'shop_account'\)\)/, + ); + expect(MESSAGE_SCHEMA_SQL[96]).toMatch(/before jsonb NOT NULL/); + expect(MESSAGE_SCHEMA_SQL[96]).toMatch(/after jsonb NOT NULL/); + expect(MESSAGE_SCHEMA_SQL[97]).toMatch( + /CREATE INDEX IF NOT EXISTS message_edit_message_created_idx/, + ); + expect(MESSAGE_SCHEMA_SQL[97]).toMatch( + /ON message_edit \(message_id, created_at DESC, id DESC\)/, + ); }); }); @@ -997,6 +1009,65 @@ describe('InMemoryMessageStore', () => { expect(cleared?.place).toEqual({ lat: 1, lng: 2, label: 'Pin' }); }); + it('appendEdit stores copies and listEdits returns newest first', async () => { + const store = new InMemoryMessageStore(); + expect(await store.listEdits('missing')).toEqual([]); + const place = { lat: 1, lng: 2, label: 'Stall' }; + const older = new Date('2026-08-01T00:00:00.000Z'); + const newer = new Date('2026-08-02T00:00:00.000Z'); + await store.appendEdit({ + id: 'b', + messageId: 'm1', + actorId: 'acc', + createdAt: newer, + field: 'place', + before: null, + after: place, + }); + await store.appendEdit({ + id: 'a', + messageId: 'm1', + actorId: 'acc', + createdAt: newer, + field: 'text', + before: 'old', + after: 'new', + }); + await store.appendEdit({ + id: 'c', + messageId: 'm1', + actorId: 'acc', + createdAt: older, + field: 'shop_account', + before: { id: 's', username: 'ada', name: 'Ada' }, + after: null, + }); + await store.appendEdit({ + id: 'other', + messageId: 'm2', + actorId: 'acc', + createdAt: newer, + field: 'text', + before: 'x', + after: 'y', + }); + place.label = 'mutated'; + newer.setTime(0); + const listed = await store.listEdits('m1'); + expect(listed.map((row) => row.id)).toEqual(['b', 'a', 'c']); + expect(listed[0]?.after).toEqual({ lat: 1, lng: 2, label: 'Stall' }); + expect(listed[0]?.createdAt.toISOString()).toBe('2026-08-02T00:00:00.000Z'); + expect(listed[2]?.before).toEqual({ id: 's', username: 'ada', name: 'Ada' }); + const first = listed[0]; + if (first === undefined || typeof first.after !== 'object' || first.after === null) { + throw new Error('expected place'); + } + (first.after as { label: string }).label = 'listed'; + const again = await store.listEdits('m1'); + expect(again[0]?.after).toEqual({ lat: 1, lng: 2, label: 'Stall' }); + expect(await store.listEdits('m2')).toHaveLength(1); + }); + it('create leaves shopAccount null and copyRow copies a snapshot', async () => { const seeded = new InMemoryMessageStore([ EARLY, @@ -6339,6 +6410,88 @@ describe('PostgresMessageStore', () => { expect(await new PostgresMessageStore(missing).setShopAccount('gone', account)).toBe(false); }); + it('appendEdit inserts jsonb copies and listEdits maps rows newest-query order', async () => { + const sql = new MockSql(); + const at = new Date('2026-08-02T00:00:00.000Z'); + await new PostgresMessageStore(sql).appendEdit({ + id: 'e1', + messageId: 'm1', + actorId: 'acc', + createdAt: at, + field: 'text', + before: null, + after: { lat: 1, lng: 2, label: null }, + }); + expect(sql.queries).toEqual([]); + expect(sql.executes).toHaveLength(1); + expect(sql.executes[0]?.text).toMatch(/INSERT INTO message_edit/); + expect(sql.executes[0]?.text).toMatch(/\$6::jsonb, \$7::jsonb/); + expect(sql.executes[0]?.params).toEqual([ + 'e1', + 'm1', + 'acc', + at, + 'text', + 'null', + JSON.stringify({ lat: 1, lng: 2, label: null }), + ]); + + sql.nextRows = [ + { + id: 'e2', + message_id: 'm1', + actor_id: 'acc', + created_at: '2026-08-03T00:00:00.000Z', + field: 'shop_account', + before: '{"id":"s","username":"ada","name":"Ada"}', + after: null, + }, + { + id: 'e3', + message_id: 'm1', + actor_id: 'acc', + created_at: at, + field: 'place', + before: { lat: 1, lng: 2, label: 'Stall' }, + after: 'not-json', + }, + { + id: 'e4', + message_id: 'm1', + actor_id: 'acc', + created_at: at, + field: 'nope', + before: 'plain', + after: 4, + }, + ]; + const listed = await new PostgresMessageStore(sql).listEdits('m1'); + expect(sql.queries[0]?.text).toMatch(/FROM message_edit/); + expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC/); + expect(sql.queries[0]?.params).toEqual(['m1']); + expect(listed[0]).toMatchObject({ + id: 'e2', + messageId: 'm1', + field: 'shop_account', + before: { id: 's', username: 'ada', name: 'Ada' }, + after: null, + }); + expect(listed[0]?.createdAt.toISOString()).toBe('2026-08-03T00:00:00.000Z'); + expect(listed[1]).toMatchObject({ + field: 'place', + before: { lat: 1, lng: 2, label: 'Stall' }, + after: 'not-json', + }); + expect(listed[2]).toMatchObject({ field: 'text', before: 'plain', after: 4 }); + const place = listed[1]?.before as { label: string }; + place.label = 'mutated'; + expect(sql.nextRows[1]).toMatchObject({ before: { lat: 1, lng: 2, label: 'Stall' } }); + + const empty = new MockSql(); + empty.nextRows = []; + expect(await new PostgresMessageStore(empty).listEdits('none')).toEqual([]); + }); + it('list and claim SQL require deleted_at IS NULL', async () => { const sql = new MockSql(); sql.nextRows = []; diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index 86fc4e8ad..32c2b17bf 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -4841,6 +4841,10 @@ describe('indexOpenZapReceipts', () => { setPlace: (...args: Parameters) => base.setPlace(...args), setShopAccount: (...args: Parameters) => base.setShopAccount(...args), + appendEdit: (...args: Parameters) => + base.appendEdit(...args), + listEdits: (...args: Parameters) => + base.listEdits(...args), getById: (id: string) => base.getById(id), getByEventId: async (id: string) => { getByEventIdCalls += 1; @@ -5085,6 +5089,10 @@ describe('indexOpenZapReceipts', () => { setPlace: (...args: Parameters) => base.setPlace(...args), setShopAccount: (...args: Parameters) => base.setShopAccount(...args), + appendEdit: (...args: Parameters) => + base.appendEdit(...args), + listEdits: (...args: Parameters) => + base.listEdits(...args), getById: (id: string) => base.getById(id), getByEventId: (id: string) => base.getByEventId(id), claimUnsigned: (...args: Parameters) => @@ -5873,6 +5881,10 @@ describe('indexOpenZapReceipts', () => { setPlace: (...args: Parameters) => base.setPlace(...args), setShopAccount: (...args: Parameters) => base.setShopAccount(...args), + appendEdit: (...args: Parameters) => + base.appendEdit(...args), + listEdits: (...args: Parameters) => + base.listEdits(...args), getById: (id: string) => base.getById(id), getByEventId: (id: string) => base.getByEventId(id), claimUnsigned: (...args: Parameters) => diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index caf354cf3..05cffa2ec 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -8,6 +8,7 @@ import { InMemoryMessageStore, type MessageStore } from '@/lib/message-store'; import { InMemoryNotificationStore } from '@/lib/notification-store'; import { MESSAGE_MAX_LENGTH, + type MessageRow, decodeMessageFeedCursor, encodeMessageFeedCursor, truncatePubkeyDisplay, @@ -213,6 +214,8 @@ function throwingStore(overrides: Partial = {}): MessageStore { markUndeleted: boom, setPlace: boom, setShopAccount: boom, + appendEdit: boom, + listEdits: boom, getById: boom, getByEventId: boom, claimUnsigned: boom, @@ -3285,6 +3288,8 @@ describe('POST /messages', () => { markUndeleted: (id) => base.markUndeleted(id), setPlace: (id, place) => base.setPlace(id, place), setShopAccount: (id, account) => base.setShopAccount(id, account), + appendEdit: (row) => base.appendEdit(row), + listEdits: (messageId) => base.listEdits(messageId), getById: (id) => base.getById(id), getByEventId: (eventId) => base.getByEventId(eventId), listPublishedEventIds: (limit) => base.listPublishedEventIds(limit), @@ -3406,6 +3411,8 @@ describe('POST /messages', () => { markUndeleted: (id) => base.markUndeleted(id), setPlace: (id, place) => base.setPlace(id, place), setShopAccount: (id, account) => base.setShopAccount(id, account), + appendEdit: (row) => base.appendEdit(row), + listEdits: (messageId) => base.listEdits(messageId), getById: (id) => base.getById(id), getByEventId: (eventId) => base.getByEventId(eventId), listPublishedEventIds: (limit) => base.listPublishedEventIds(limit), @@ -5062,6 +5069,8 @@ describe('POST /messages/:id/invoice', () => { markUndeleted: (id) => base.markUndeleted(id), setPlace: (id, place) => base.setPlace(id, place), setShopAccount: (id, account) => base.setShopAccount(id, account), + appendEdit: (row) => base.appendEdit(row), + listEdits: (messageId) => base.listEdits(messageId), getByEventId: (id) => base.getByEventId(id), claimUnsigned: (...args) => base.claimUnsigned(...args), claimUnpublished: (...args) => base.claimUnpublished(...args), @@ -10894,6 +10903,7 @@ describe('PATCH /messages/:id/shop-account', () => { }); }); + function recordingMap(): { mapPush: MapPush; calls: string[]; @@ -11024,3 +11034,492 @@ describe('shop OCP place hook', () => { expect(parsedEvents(warn).some((e) => e['event'] === 'ocp.place.failed')).toBe(true); }); }); + +describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { + const SHOP_ID = '11111111-1111-4111-8111-111111111111'; + const REPLY_ID = '33333333-3333-4333-8333-333333333333'; + const PLAIN_ID = '66666666-6666-4666-8666-666666666666'; + const EVENT_ID = 'ee'.repeat(32); + + async function roleStore(role: 'initiator' | 'founder'): Promise { + const auth = await staffStore('Ada'); + const existing = await auth.getAccount('acc'); + if (existing === undefined) { + throw new Error('expected account'); + } + await auth.updateAccount({ ...existing, role }); + return auth; + } + + async function shopNote( + messages: InMemoryMessageStore, + overrides: Partial = {}, + ): Promise { + await messages.create({ + id: SHOP_ID, + accountId: 'acc', + name: 'Ada', + text: 'Cafe\n\n#21GiftsShop', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: EVENT_ID, + ...overrides, + }); + } + + function patchText( + auth: InMemoryAuthStore, + id: string, + body: unknown, + messages: InMemoryMessageStore = new InMemoryMessageStore(), + ) { + return mount(auth, messages).request('/messages/' + id + '/text', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + it('returns 401 without a session', async () => { + const res = await mount(await seededStore()).request('/messages/' + SHOP_ID + '/text', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe' }), + }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + const edits = await mount(await seededStore()).request('/messages/' + SHOP_ID + '/edits'); + expect(edits.status).toBe(401); + }); + + it('returns 403 below moderator', async () => { + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const basis = await patchText(await seededStore(), SHOP_ID, { text: 'Cafe' }, messages); + expect(basis.status).toBe(403); + const verified = await patchText(await namedStore('Ada'), SHOP_ID, { text: 'Cafe' }, messages); + expect(verified.status).toBe(403); + const listed = await mount(await namedStore('Ada'), messages).request( + '/messages/' + SHOP_ID + '/edits', + { headers: AUTH }, + ); + expect(listed.status).toBe(403); + }); + + it('returns 400 for a bad id, a bad body, and text that cannot be stored', async () => { + const auth = await staffStore('Ada'); + const badId = await patchText(auth, 'nope', { text: 'Cafe' }); + expect(badId.status).toBe(404); + const missingText = await patchText(auth, SHOP_ID, {}); + expect(missingText.status).toBe(400); + expect(await missingText.json()).toEqual({ error: 'Invalid body' }); + const numberText = await patchText(auth, SHOP_ID, { text: 1 }); + expect(numberText.status).toBe(400); + const arrayBody = await mount(auth).request('/messages/' + SHOP_ID + '/text', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify(['Cafe']), + }); + expect(arrayBody.status).toBe(400); + const control = await patchText(auth, SHOP_ID, { text: 'bad\u0000text' }); + expect(control.status).toBe(400); + expect(await control.json()).toEqual({ error: 'Text must be 1–8000 characters' }); + const tooLong = await patchText(auth, SHOP_ID, { text: 'a'.repeat(8001) }); + expect(tooLong.status).toBe(400); + }); + + it('returns 404 for a hidden note, 400 for a reply, and 400 for a non-shop note', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + await messages.create({ + id: REPLY_ID, + accountId: 'acc', + name: 'Ada', + text: 'Reply #21GiftsShop', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + parentId: SHOP_ID, + }); + const reply = await patchText(auth, REPLY_ID, { text: 'Next' }, messages); + expect(reply.status).toBe(400); + expect(await reply.json()).toEqual({ error: 'A reply cannot be edited' }); + await messages.markDeleted(SHOP_ID, new Date(now()), 'acc'); + const hidden = await patchText(auth, SHOP_ID, { text: 'Next' }, messages); + expect(hidden.status).toBe(404); + await messages.create({ + id: PLAIN_ID, + accountId: 'acc', + name: 'Ada', + text: 'Hello', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const plain = await patchText(auth, PLAIN_ID, { text: 'Next' }, messages); + expect(plain.status).toBe(400); + expect(await plain.json()).toEqual({ error: 'Only a shop note can be edited' }); + }); + + it('rejects empty text without media and a body that would exceed the cap after the tag', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const empty = await patchText(auth, SHOP_ID, { text: ' ' }, messages); + expect(empty.status).toBe(400); + expect(await empty.json()).toEqual({ + error: 'Text must be 1–8000 characters or include a photo', + }); + const capped = await patchText(auth, SHOP_ID, { text: 'a'.repeat(8000) }, messages); + expect(capped.status).toBe(400); + expect(await capped.json()).toEqual({ error: 'Text must be 1–8000 characters' }); + expect((await messages.getById(SHOP_ID))?.text).toBe('Cafe\n\n#21GiftsShop'); + expect(await messages.listEdits(SHOP_ID)).toEqual([]); + }); + + it('lets a moderator, an initiator, and a founder replace text and keep the shop tag', async () => { + for (const role of ['moderator', 'initiator', 'founder'] as const) { + const auth = role === 'moderator' ? await staffStore('Ada') : await roleStore(role); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const res = await patchText(auth, SHOP_ID, { text: 'Cafe Sol' }, messages); + expect(res.status).toBe(200); + const json = (await res.json()) as { text: string }; + expect(json.text).toBe('Cafe Sol\n\n#21GiftsShop'); + expect(json).not.toHaveProperty('edits'); + expect((await messages.getById(SHOP_ID))?.eventId).toBe(EVENT_ID); + const edits = await messages.listEdits(SHOP_ID); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ + field: 'text', + before: 'Cafe\n\n#21GiftsShop', + after: 'Cafe Sol\n\n#21GiftsShop', + actorId: 'acc', + }); + } + }); + + it('does not append history when the text is unchanged, including an existing tag', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const res = await patchText(auth, SHOP_ID, { text: 'Cafe\n\n#21GiftsShop' }, messages); + expect(res.status).toBe(200); + expect(((await res.json()) as { text: string }).text).toBe('Cafe\n\n#21GiftsShop'); + expect(await messages.listEdits(SHOP_ID)).toEqual([]); + }); + + it('edits an external shop note and a note whose author account is gone', async () => { + const auth = await staffStore('Ada'); + const external = new InMemoryMessageStore(); + await shopNote(external, { accountId: null, authorPubkey: 'ab'.repeat(32) }); + const same = await patchText(auth, SHOP_ID, { text: 'Cafe\n\n#21GiftsShop' }, external); + expect(same.status).toBe(200); + expect((await same.json()) as { role?: string }).not.toHaveProperty('role'); + const changed = await patchText(auth, SHOP_ID, { text: 'Cafe Sol' }, external); + expect(changed.status).toBe(200); + expect((await changed.json()) as { role?: string }).not.toHaveProperty('role'); + + const gone = new InMemoryMessageStore(); + await shopNote(gone, { accountId: 'gone' }); + const untouched = await patchText(auth, SHOP_ID, { text: 'Cafe\n\n#21GiftsShop' }, gone); + expect(untouched.status).toBe(200); + expect(((await untouched.json()) as { role: string }).role).toBe('basis'); + const renamed = await patchText(auth, SHOP_ID, { text: 'Cafe Sol' }, gone); + expect(renamed.status).toBe(200); + expect(((await renamed.json()) as { role: string }).role).toBe('basis'); + }); + + it('stores only the shop tag for a photo-only or video-only note', async () => { + const auth = await staffStore('Ada'); + const seeded = { + id: SHOP_ID, + accountId: 'acc', + name: 'Ada', + text: 'Cafe\n\n#21GiftsShop', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + eventId: EVENT_ID, + }; + const photos = new InMemoryMessageStore([{ ...seeded, hasPhoto: true, hasVideo: false }]); + const photo = await patchText(auth, SHOP_ID, { text: '' }, photos); + expect(photo.status).toBe(200); + expect((await photos.getById(SHOP_ID))?.text).toBe('#21GiftsShop'); + const videos = new InMemoryMessageStore([{ ...seeded, hasPhoto: false, hasVideo: true }]); + const video = await patchText(auth, SHOP_ID, { text: '' }, videos); + expect(video.status).toBe(200); + expect((await videos.getById(SHOP_ID))?.text).toBe('#21GiftsShop'); + }); + + it('returns 404 when updateText misses and 503 when the store throws', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const row = await messages.getById(SHOP_ID); + if (row === undefined) { + throw new Error('expected shop'); + } + const missing = await mount( + auth, + throwingStore({ + getById: async () => row, + updateText: async () => undefined, + }), + ).request('/messages/' + SHOP_ID + '/text', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Next' }), + }); + expect(missing.status).toBe(404); + warn.mockClear(); + const failed = await mount( + auth, + throwingStore({ + getById: async () => row, + updateText: async () => { + throw new Error('boom'); + }, + }), + ).request('/messages/' + SHOP_ID + '/text', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Next' }), + }); + expect(failed.status).toBe(503); + expect(parsedEvents(warn).some((event) => event['event'] === 'messages.text.failed')).toBe( + true, + ); + const historyFailed = await mount( + auth, + throwingStore({ + getById: async () => ({ ...row, text: 'Cafe\n\n#21GiftsShop' }), + updateText: async () => ({ ...row, text: 'Next\n\n#21GiftsShop' }), + appendEdit: async () => { + throw new Error('boom'); + }, + }), + ).request('/messages/' + SHOP_ID + '/text', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Next' }), + }); + expect(historyFailed.status).toBe(503); + let reads = 0; + const gone = await mount( + auth, + throwingStore({ + getById: async () => { + reads += 1; + return reads === 1 ? row : undefined; + }, + updateText: async () => row, + appendEdit: async () => undefined, + }), + ).request('/messages/' + SHOP_ID + '/text', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Next' }), + }); + expect(gone.status).toBe(404); + }); + + it('lists history newest first for staff, including a hidden shop note', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const empty = await mount(auth, messages).request('/messages/' + SHOP_ID + '/edits', { + headers: AUTH, + }); + expect(empty.status).toBe(200); + expect(await empty.json()).toEqual({ edits: [] }); + await messages.appendEdit({ + id: '11111111-1111-4111-8111-111111111112', + messageId: SHOP_ID, + actorId: 'acc', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + field: 'place', + before: null, + after: { lat: 1, lng: 2, label: 'Stall' }, + }); + await messages.appendEdit({ + id: '11111111-1111-4111-8111-111111111113', + messageId: SHOP_ID, + actorId: 'missing-actor', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + field: 'shop_account', + before: null, + after: { id: 's', username: 'luna', name: 'Luna' }, + }); + const listed = await mount(auth, messages).request('/messages/' + SHOP_ID + '/edits', { + headers: AUTH, + }); + expect(listed.status).toBe(200); + expect(await listed.json()).toEqual({ + edits: [ + { + id: '11111111-1111-4111-8111-111111111113', + createdAt: '2026-08-02T00:00:00.000Z', + field: 'shopAccount', + before: null, + after: { id: 's', username: 'luna', name: 'Luna' }, + actor: { id: 'missing-actor', name: null, role: null }, + }, + { + id: '11111111-1111-4111-8111-111111111112', + createdAt: '2026-08-01T00:00:00.000Z', + field: 'place', + before: null, + after: { lat: 1, lng: 2, label: 'Stall' }, + actor: { id: 'acc', name: 'Ada', role: 'moderator' }, + }, + ], + }); + await messages.markDeleted(SHOP_ID, new Date(now()), 'acc'); + const hidden = await mount(auth, messages).request('/messages/' + SHOP_ID + '/edits', { + headers: AUTH, + }); + expect(hidden.status).toBe(200); + expect(((await hidden.json()) as { edits: unknown[] }).edits).toHaveLength(2); + const plain = await mount(auth, messages).request('/messages/' + PLAIN_ID + '/edits', { + headers: AUTH, + }); + expect(plain.status).toBe(404); + const bad = await mount(auth, messages).request('/messages/nope/edits', { headers: AUTH }); + expect(bad.status).toBe(404); + }); + + it('returns 404 for a reply history read and 503 when listing throws', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + await messages.create({ + id: REPLY_ID, + accountId: 'acc', + name: 'Ada', + text: 'Reply #21GiftsShop', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + parentId: SHOP_ID, + }); + const reply = await mount(auth, messages).request('/messages/' + REPLY_ID + '/edits', { + headers: AUTH, + }); + expect(reply.status).toBe(404); + const row = await messages.getById(SHOP_ID); + if (row === undefined) { + throw new Error('expected shop'); + } + warn.mockClear(); + const failed = await mount( + auth, + throwingStore({ + getById: async () => row, + listEdits: async () => { + throw new Error('boom'); + }, + }), + ).request('/messages/' + SHOP_ID + '/edits', { headers: AUTH }); + expect(failed.status).toBe(503); + expect(parsedEvents(warn).some((event) => event['event'] === 'messages.edits.failed')).toBe( + true, + ); + }); + + it('records a place or shop-account change once and skips an identical save', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const pin = { lat: 47.1, lng: 8.5, label: 'Stall' }; + const setPlace = await mount(auth, messages).request('/messages/' + SHOP_ID + '/place', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ place: pin }), + }); + expect(setPlace.status).toBe(200); + const samePlace = await mount(auth, messages).request('/messages/' + SHOP_ID + '/place', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ place: pin }), + }); + expect(samePlace.status).toBe(200); + expect(await messages.listEdits(SHOP_ID)).toHaveLength(1); + const clearPlace = await mount(auth, messages).request('/messages/' + SHOP_ID + '/place', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ place: null }), + }); + expect(clearPlace.status).toBe(200); + const clearAgain = await mount(auth, messages).request('/messages/' + SHOP_ID + '/place', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ place: null }), + }); + expect(clearAgain.status).toBe(200); + expect( + (await messages.listEdits(SHOP_ID)) + .map((row) => row.field) + .slice() + .sort(), + ).toEqual(['place', 'place']); + + await auth.createAccount({ + id: 'shop-acc', + linkingKey: null, + role: 'basis', + name: 'Luna', + username: 'luna', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: 'c'.repeat(64), + createdAt: 2_000_000, + rulesAgreedAt: null, + }); + const setAccount = await mount(auth, messages).request( + '/messages/' + SHOP_ID + '/shop-account', + { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'luna' }), + }, + ); + expect(setAccount.status).toBe(200); + const sameAccount = await mount(auth, messages).request( + '/messages/' + SHOP_ID + '/shop-account', + { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'luna' }), + }, + ); + expect(sameAccount.status).toBe(200); + const clearAccount = await mount(auth, messages).request( + '/messages/' + SHOP_ID + '/shop-account', + { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ username: null }), + }, + ); + expect(clearAccount.status).toBe(200); + const clearAccountAgain = await mount(auth, messages).request( + '/messages/' + SHOP_ID + '/shop-account', + { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ username: null }), + }, + ); + expect(clearAccountAgain.status).toBe(200); + expect( + (await messages.listEdits(SHOP_ID)) + .map((row) => row.field) + .slice() + .sort(), + ).toEqual(['place', 'place', 'shop_account', 'shop_account']); + }); +}); diff --git a/src/__tests__/sunday-rest-routes.test.ts b/src/__tests__/sunday-rest-routes.test.ts index 8edf39ec1..b62e525ec 100644 --- a/src/__tests__/sunday-rest-routes.test.ts +++ b/src/__tests__/sunday-rest-routes.test.ts @@ -86,6 +86,24 @@ describe('sunday rest routes', () => { expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); }); + it('refuses PATCH /messages/:id/text when the device zone is Sunday', async () => { + const res = await app().request('/messages/note-1/text', { + method: 'PATCH', + headers: { 'Time-Zone': 'Europe/Zurich', 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello' }), + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); + }); + + it('does not refuse GET /messages/:id/edits with Time-Zone Europe/Zurich on Sunday', async () => { + const res = await app().request('/messages/note-1/edits', { + headers: { 'Time-Zone': 'Europe/Zurich' }, + }); + expect(res.status).not.toBe(403); + expect(((await res.json()) as { error?: string }).error).not.toBe('SUNDAY_REST'); + }); + it('refuses GET /conversations/moderator-group when the device zone is Sunday', async () => { const res = await app().request('/conversations/moderator-group', { headers: { 'Time-Zone': 'Europe/Zurich' }, diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 7f6cb2d59..a34c7d42d 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -280,6 +280,24 @@ export interface AccountMessageCounts { replyCount: number; } +/** One stored change of shop-note text, place, or shop account. */ +export interface MessageEditRow { + /** Opaque unique history id. */ + id: string; + /** Message this row belongs to. */ + messageId: string; + /** Staff account that made the change. */ + actorId: string; + /** When the change was stored. */ + createdAt: Date; + /** Which field changed. SQL `shop_account` stays `shop_account`. */ + field: 'text' | 'place' | 'shop_account'; + /** Value before the write (`null` when clearing). */ + before: unknown; + /** Value after the write (`null` when clearing). */ + after: unknown; +} + /** * Persistence port for forum messages. */ @@ -645,6 +663,23 @@ export interface MessageStore { account: { id: string; username: string; name: string } | null, ): Promise; + /** + * Append one edit-history row. Does not change the message. The stored + * `before` / `after` values are copies. + * + * @param row - History row to store. + */ + appendEdit(row: MessageEditRow): Promise; + + /** + * Edit history for one message. Newest `createdAt` then `id` first. + * Missing message → `[]`. Caller-owned copies. + * + * @param messageId - Message id. + * @returns History row copies, newest first. + */ + listEdits(messageId: string): Promise; + /** * Direct children of `parentId` (`parentId` match), including hidden, * Damus-only (`accountId` null), and gift-only rows. Oldest `createdAt` @@ -1609,6 +1644,18 @@ $message_goal_term_days$`, END LOOP; END; $unwrap$;`, + `CREATE TABLE IF NOT EXISTS message_edit ( + id uuid PRIMARY KEY, + message_id uuid NOT NULL REFERENCES message (id) ON DELETE CASCADE, + actor_id uuid NOT NULL, + created_at timestamptz NOT NULL, + field text NOT NULL, + before jsonb NOT NULL, + after jsonb NOT NULL, + CONSTRAINT message_edit_field_chk CHECK (field IN ('text', 'place', 'shop_account')) +)`, + `CREATE INDEX IF NOT EXISTS message_edit_message_created_idx + ON message_edit (message_id, created_at DESC, id DESC)`, ]; /** @@ -1827,6 +1874,44 @@ function copyRow(row: MessageRow): MessageRow { return copy; } +/** Caller-owned clone of a jsonb `before` / `after` value. */ +function cloneEditValue(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + return JSON.parse(JSON.stringify(value)); +} + +/** Copy one history row (cloned `createdAt` and jsonb values). */ +function copyEdit(row: MessageEditRow): MessageEditRow { + return { + id: row.id, + messageId: row.messageId, + actorId: row.actorId, + createdAt: new Date(row.createdAt.getTime()), + field: row.field, + before: cloneEditValue(row.before), + after: cloneEditValue(row.after), + }; +} + +/** Driver jsonb: parse a JSON string, otherwise keep the value (including `null`). */ +function readStoredEditJson(value: unknown): unknown { + if (typeof value !== 'string') { + return cloneEditValue(value); + } + try { + return cloneEditValue(JSON.parse(value)); + } catch { + return value; + } +} + +/** Map a SQL `field` text onto {@link MessageEditRow.field}. */ +function mapEditField(value: unknown): MessageEditRow['field'] { + return value === 'place' || value === 'shop_account' ? value : 'text'; +} + /** * A receipt counts toward the frozen credit when it was stored at or before * the note filled. A missing timestamp is a row from before that column. @@ -1961,6 +2046,7 @@ export class InMemoryMessageStore implements MessageStore { readonly #zapIngests: ZapIngestRow[] = []; readonly #zappers = new Map(); readonly #blockedPubkeys = new Map(); + readonly #edits: MessageEditRow[] = []; readonly #paymentFiat: Required; /** @@ -3656,6 +3742,25 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(true); } + appendEdit(row: MessageEditRow): Promise { + this.#edits.push(copyEdit(row)); + return Promise.resolve(); + } + + listEdits(messageId: string): Promise { + const rows = this.#edits + .filter((item) => item.messageId === messageId) + .sort((a, b) => { + const byTime = b.createdAt.getTime() - a.createdAt.getTime(); + if (byTime !== 0) { + return byTime; + } + return b.id.localeCompare(a.id); + }) + .map((item) => copyEdit(item)); + return Promise.resolve(rows); + } + /** * Direct children of `parentId`, including hidden, Damus-only, and * gift-only rows. Oldest `createdAt` then `id` first. Missing parent → `[]`. @@ -4835,6 +4940,49 @@ export class PostgresMessageStore implements MessageStore { return rows[0] !== undefined; } + async appendEdit(row: MessageEditRow): Promise { + await this.#sql.execute( + `INSERT INTO message_edit (id, message_id, actor_id, created_at, field, before, after) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)`, + [ + row.id, + row.messageId, + row.actorId, + row.createdAt, + row.field, + JSON.stringify(row.before), + JSON.stringify(row.after), + ], + ); + } + + async listEdits(messageId: string): Promise { + const rows = await this.#sql.query<{ + id: string; + message_id: string; + actor_id: string; + created_at: Date | string; + field: string; + before: unknown; + after: unknown; + }>( + `SELECT id, message_id, actor_id, created_at, field, before, after + FROM message_edit + WHERE message_id = $1 + ORDER BY created_at DESC, id DESC`, + [messageId], + ); + return rows.map((row) => ({ + id: row.id, + messageId: row.message_id, + actorId: row.actor_id, + createdAt: new Date(row.created_at), + field: mapEditField(row.field), + before: readStoredEditJson(row.before), + after: readStoredEditJson(row.after), + })); + } + /** * Direct children of `parentId` (`parent_id = $1`), including hidden, * Damus-only, and gift-only rows. Oldest `created_at` then `id` first. diff --git a/src/lib/sunday-rest.ts b/src/lib/sunday-rest.ts index 2dc762e61..196a74620 100644 --- a/src/lib/sunday-rest.ts +++ b/src/lib/sunday-rest.ts @@ -37,6 +37,7 @@ const SUNDAY_REST_WRITES: ReadonlyArray = [ ['POST', /^\/messages\/[^/]+\/repayment$/], ['DELETE', /^\/messages\/[^/]+$/], ['PATCH', /^\/messages\/[^/]+\/place$/], + ['PATCH', /^\/messages\/[^/]+\/text$/], ['PATCH', /^\/messages\/[^/]+\/shop-account$/], ['POST', /^\/funding\/apply$/], ['POST', /^\/funding\/trial$/], diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 626104842..ff8804545 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -42,6 +42,7 @@ import { } from '@/lib/message'; import { textHasHashtagToken, + type MessageEditRow, type MessageFeedQuery, type MessageInvoiceAttempt, type MessageInvoiceResult, @@ -345,6 +346,37 @@ async function resolveDeletedBy( : { id: deleter.id, name: deleter.name, role: deleter.role }; } +/** + * Keep `#21GiftsShop` on an already-normalised shop-note body. + * Empty text becomes the tag alone (photo/video-only notes). + */ +function ensureShopNoteTag(text: string): string { + if (textHasHashtagToken(text, '21GiftsShop')) { + return text; + } + return text === '' ? '#21GiftsShop' : `${text}\n\n#21GiftsShop`; +} + +/** + * Whether two shop-account snapshots are the same assignment. + * Both absent matches. One absent does not. + */ +function shopAccountsMatch( + a: { id: string; username: string; name: string } | null | undefined, + b: { id: string; username: string; name: string } | null, +): boolean { + const left = a ?? null; + if (left === null || b === null) { + return left === b; + } + return left.id === b.id && left.username === b.username && left.name === b.name; +} + +/** Public JSON `field` for one history row. */ +function publicEditField(field: MessageEditRow['field']): 'text' | 'place' | 'shopAccount' { + return field === 'shop_account' ? 'shopAccount' : field; +} + /** Hex UUID as stored on `message.id` (rejects values Postgres would error on). */ export const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -1230,8 +1262,9 @@ const translateBody = z.object({ * @returns A Hono app with `GET /`, `POST /`, `GET /compose-target`, * `GET /places`, `GET /:id/photo` plus `.jpg` / `.jpeg` / `.png` / `.webp`, * `GET /:id/video.mp4|.webm|.mov`, public `GET /:id/replies` (`accountId` when - * the stored author id is non-null), `DELETE /:id`, staff `PATCH /:id/place` and - * staff `PATCH /:id/shop-account` (moderator session; no `forum.read`), + * the stored author id is non-null), `DELETE /:id`, staff `PATCH /:id/place`, + * staff `PATCH /:id/shop-account`, staff `PATCH /:id/text`, and staff + * `GET /:id/edits` (moderator session; no `forum.read`), * staff `GET /hidden` (moderator session; no `forum.read`), public * `GET /:id` (optional `?sinceSats=`), and * `POST /:id/invoice`, `POST /:id/translate`, and public `GET /stats`. @@ -1859,6 +1892,17 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (updated === undefined) { return c.json({ error: 'Not found' }, 404); } + if (!placesMatch(row.place ?? null, parsed.value)) { + await deps.store.appendEdit({ + id: crypto.randomUUID(), + messageId: id, + actorId: account.id, + createdAt: new Date(deps.now()), + field: 'place', + before: row.place ?? null, + after: parsed.value, + }); + } const author = updated.accountId === null ? undefined @@ -1965,6 +2009,17 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (updated === undefined) { return c.json({ error: 'Not found' }, 404); } + if (!shopAccountsMatch(row.shopAccount, snapshot)) { + await deps.store.appendEdit({ + id: crypto.randomUUID(), + messageId: id, + actorId: account.id, + createdAt: new Date(deps.now()), + field: 'shop_account', + before: row.shopAccount ?? null, + after: snapshot, + }); + } const author = updated.accountId === null ? undefined @@ -1990,6 +2045,149 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Messages are unavailable' }, 503); } }) + .patch('/:id/text', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!roleAtLeast(account.role, 'moderator')) { + return c.json({ error: 'Forbidden' }, 403); + } + const id = c.req.param('id'); + if (!MESSAGE_ID_RE.test(id)) { + return c.json({ error: 'Not found' }, 404); + } + const raw: unknown = await c.req.json().catch(() => null); + if ( + raw === null || + typeof raw !== 'object' || + Array.isArray(raw) || + !Object.prototype.hasOwnProperty.call(raw, 'text') || + typeof (raw as { text: unknown }).text !== 'string' + ) { + return c.json({ error: 'Invalid body' }, 400); + } + const normalized = normalizeForumText((raw as { text: string }).text); + if (normalized === null) { + return c.json({ error: `Text must be 1–${MESSAGE_MAX_LENGTH} characters` }, 400); + } + try { + const row = await deps.store.getById(id); + if (row === undefined || row.deletedAt !== null) { + return c.json({ error: 'Not found' }, 404); + } + if (row.parentId !== null) { + return c.json({ error: 'A reply cannot be edited' }, 400); + } + if (!textHasHashtagToken(row.text, '21GiftsShop')) { + return c.json({ error: 'Only a shop note can be edited' }, 400); + } + if (normalized === '' && !row.hasPhoto && row.hasVideo !== true) { + return c.json( + { error: `Text must be 1–${MESSAGE_MAX_LENGTH} characters or include a photo` }, + 400, + ); + } + const ensured = ensureShopNoteTag(normalized); + if (ensured.length > MESSAGE_MAX_LENGTH) { + return c.json({ error: `Text must be 1–${MESSAGE_MAX_LENGTH} characters` }, 400); + } + if (ensured === row.text) { + const author = + row.accountId === null ? undefined : await deps.authStore.getAccount(row.accountId); + const payable = row.accountId === null ? false : payableOf(row, author); + const role = row.accountId === null ? undefined : (author?.role ?? 'basis'); + return c.json( + serializeMessage(row, payable, role, await deps.store.countAttributedReplies(row.id)), + 200, + ); + } + const written = await deps.store.updateText(id, ensured); + if (written === undefined) { + return c.json({ error: 'Not found' }, 404); + } + await deps.store.appendEdit({ + id: crypto.randomUUID(), + messageId: id, + actorId: account.id, + createdAt: new Date(deps.now()), + field: 'text', + before: row.text, + after: ensured, + }); + const updated = await deps.store.getById(id); + if (updated === undefined) { + return c.json({ error: 'Not found' }, 404); + } + const author = + updated.accountId === null + ? undefined + : await deps.authStore.getAccount(updated.accountId); + const payable = updated.accountId === null ? false : payableOf(updated, author); + const role = updated.accountId === null ? undefined : (author?.role ?? 'basis'); + logEvent('messages.text.updated', { + messageId: id, + accountId: account.id, + role: account.role, + }); + return c.json( + serializeMessage( + updated, + payable, + role, + await deps.store.countAttributedReplies(updated.id), + ), + 200, + ); + } catch { + logEvent('messages.text.failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } + }) + .get('/:id/edits', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!roleAtLeast(account.role, 'moderator')) { + return c.json({ error: 'Forbidden' }, 403); + } + const id = c.req.param('id'); + if (!MESSAGE_ID_RE.test(id)) { + return c.json({ error: 'Not found' }, 404); + } + try { + const row = await deps.store.getById(id); + if ( + row === undefined || + row.parentId !== null || + !textHasHashtagToken(row.text, '21GiftsShop') + ) { + return c.json({ error: 'Not found' }, 404); + } + const rows = await deps.store.listEdits(id); + const edits = []; + for (const item of rows) { + const actor = await deps.authStore.getAccount(item.actorId); + edits.push({ + id: item.id, + createdAt: item.createdAt.toISOString(), + field: publicEditField(item.field), + before: item.before, + after: item.after, + actor: + actor === undefined + ? { id: item.actorId, name: null, role: null } + : { id: actor.id, name: actor.name, role: actor.role }, + }); + } + logEvent('messages.edits.listed', { messageId: id, count: edits.length }); + return c.json({ edits }, 200); + } catch { + logEvent('messages.edits.failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } + }) .get('/stats', async (c) => { try { const rows = await deps.store.postCountsByUtcDay(); From d2fcf7969150542a6fb493aab5b79233b819ed43 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:59:06 +0200 Subject: [PATCH 02/19] Assign a shop account when a shop note is created A new top-level shop note can name a 21.gifts user. The first assignment is not edit history. --- docs/handbook/endpoints.md | 4 +- src/__tests__/routes/messages.test.ts | 276 ++++++++++++++++++++++++++ src/routes/messages.ts | 98 ++++++++- 3 files changed, 370 insertions(+), 8 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 736460eb6..5f3126ffe 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -576,8 +576,8 @@ ## Endpoint: POST /messages -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + username + Lightning Address; skip timestamps do not satisfy; username cannot be skipped). JSON `{ text?, photo?: { contentType, data, takenAt? }, photos?: { contentType, data, takenAt? }[], inReplyTo?, goalSats?, goalCurrency?, goalAmount?, goalRepayable?, goalTermDays? }` (`takenAt` is optional `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1; `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not 400) (`photos` max 10; non-empty `photos` wins over singular `photo`; dual-send uses `photos`) (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), optional JPEG/PNG/WebP `poster`, and optional `goalSats` (string form field) or both `goalCurrency` and `goalAmount` (JSON accepts that same pair: both or neither, not mixed with `goalSats`, not half a pair), plus optional `goalRepayable` (multipart `"true"`; an empty field is absent) and optional `goalTermDays` (multipart digits from 1 to 3650; an empty field is absent). A currency ask freezes `goal_sats` plus `goal_currency`, `goal_amount`, and four `goal_fiat_*` snapshots from the latest gift-day with sats > 0 (same path as `GET /gifts/stats`). Legacy `goalSats` leaves those columns null. JSON omits the new keys when `goalCurrency` is null. Progress fiat is the sum of per-payment snapshots; a null delta does not wipe a stored total. One-time/Daily is not stored. Optional `goalSats` is a whole-sat ask on a top-level note (JSON number; multipart string). Omitted, JSON `null`, or a multipart empty/missing field means no goal. Max 10_000_000; above max is rejected, not clamped. 200 JSON may include `goalSats` or omit the key. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only (singular or `photos`) or video-only allowed; at least one of non-empty trimmed text, photo, non-empty `photos`, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `photoCount` (0–10), `photoTakenAts` (always; length equals `photoCount`; null when unknown; `[]` when there are no stills), `photoTakenAt` only when `photoCount === 1`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped; never `contentFp`). Identical live photo/video from the same account+parent (same normalised text + same media bytes) and the same pin returns the existing row (200, same id) without consuming the 1/10s burst limiter and without a second push; the same media with a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only is unchanged (new row + burst). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates call `notifyForumPost` (kind `forum_post`, tag `forum_post:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (Web Push still only to bell subscribers, same filter). After a new top-level persist, a note with `#21GiftsShop` and a pin posts once to `POST /map/places` when `mapPush` is set. Boot leaves it unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A blank URL or token also keeps the forum pin, and a failed post logs `ocp.place.failed` and still returns 200. A replace is not this path. After a new top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including the About-me note, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independent of `eligibleToday` (the new row need not itself have media; Spend pays once per Lightning Address; this API may ping again). Replies, and any role other than `verified`, do not welcome-ping. Replies do not ping. A reply calls `notifyForumReply` (kind `forum_reply`, tag `forum_reply:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` — Damus-only parents still fan out; a self-reply skips only the actor. The booted process always has those stores (in-memory without `DATABASE_URL`, Postgres when it is set). Photo-only empty text still notifies; missing `pushStore` still writes in-app rows; notification or push failure still returns 200. It does not copy into the member↔member inbox. Unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on the platform profile note. `verified` stays unpaid-write exempt. Optional JSON `place` `{ lat, lng, label? }` and multipart fields `placeLat`, `placeLng`, `placeLabel`. Omitted place stores nothing and the 200 JSON omits `place`. Invalid place is 400 with the normalizePlace error. A reply with a non-null place is 400 `A reply cannot include a place`. Multipart with exactly one of placeLat/placeLng is 400 `Place must be a latitude and longitude`. -- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, username, and/or Lightning Address are missing (order `rules`, then `name`, then `username`, then `lightning-address`); 400 Expected a JSON body with text and/or photo (including JSON `goalSats` type/range errors); 400 Text must be 1–8000 characters; 400 Text must be 1–8000 characters or include a photo; 400 Text must be 1–8000 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 `{ error: 'At most 10 photos' }` when `photos.length > 10`; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 400 `{ error: 'A reply cannot ask for a goal' }` when `inReplyTo` is set and the body sends `goalSats`, `goalCurrency`, `goalAmount`, `goalRepayable`, or `goalTermDays`; 400 `{ error: 'Ask obligation must be true' }` when `goalRepayable` is present and not JSON `true` or multipart `"true"` (JSON `""` is rejected; a multipart empty field is absent); 400 `{ error: 'A repayment obligation needs an ask' }` when `goalRepayable` is true without an ask; 400 `{ error: 'Ask term must be a whole number of days from 1 to 3650' }` when `goalTermDays` is present and outside that range; 400 `{ error: 'A repayment term needs a repayable ask' }` when a term is sent without `goalRepayable: true`; 400 `{ error: 'A repayable ask needs a term in days' }` when `goalRepayable` is true and the term is absent; JSON includes `goalRepayable` only when true (never false) and `goalTermDays` only when set; 400 `{ error: 'Goal must be a positive whole-sat amount' }` when a multipart `goalSats` is present and not `/^\d+$/` or not an integer 1..10_000_000, and when a BTC `goalAmount` is not an integer 1..10_000_000; 400 `{ error: 'Send either goalSats or both goalCurrency and goalAmount' }` when both styles or only one of the new pair is sent, or `goalAmount` is not a canonical decimal string; 400 `{ error: 'Ask amount is unavailable' }` when a fiat ask has no usable gift-day quote or the frozen sats fall outside 1..10_000_000; 503 `{ error: 'Messages are unavailable' }` when the gift-day loader throws for a fiat ask (a BTC ask still stores the typed sats); 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, soft-hidden (`deletedAt` set), or the parent is itself a reply (`parentId !== null`); 403 `{ error: 'A post needs a Bitcoin payment' }` or `{ error: 'A reply needs a Bitcoin payment' }` when the caller is below `verified` (including the parent author) and the body is text-only; photo or video posts and replies from basis are 200; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on that note; 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). 409 `{ error: 'A live note with this media already exists' }` when the same account, parent, and live media fingerprint already exists with a different pin. 400 `{ error: 'Place must be a latitude and longitude' }` when JSON `place` is invalid or multipart has exactly one of placeLat/placeLng; 400 `{ error: 'Place label must be at most 80 characters' }` when the label fails normalizePlace; 400 `{ error: 'A reply cannot include a place' }` when `inReplyTo` is set and place is non-null. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). Pay links, the till, contact, and ordinary private threads are not this error. +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + username + Lightning Address; skip timestamps do not satisfy; username cannot be skipped). JSON `{ text?, photo?: { contentType, data, takenAt? }, photos?: { contentType, data, takenAt? }[], inReplyTo?, goalSats?, goalCurrency?, goalAmount?, goalRepayable?, goalTermDays? }` (`takenAt` is optional `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1; `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not 400) (`photos` max 10; non-empty `photos` wins over singular `photo`; dual-send uses `photos`) (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), optional JPEG/PNG/WebP `poster`, and optional `goalSats` (string form field) or both `goalCurrency` and `goalAmount` (JSON accepts that same pair: both or neither, not mixed with `goalSats`, not half a pair), plus optional `goalRepayable` (multipart `"true"`; an empty field is absent) and optional `goalTermDays` (multipart digits from 1 to 3650; an empty field is absent). A currency ask freezes `goal_sats` plus `goal_currency`, `goal_amount`, and four `goal_fiat_*` snapshots from the latest gift-day with sats > 0 (same path as `GET /gifts/stats`). Legacy `goalSats` leaves those columns null. JSON omits the new keys when `goalCurrency` is null. Progress fiat is the sum of per-payment snapshots; a null delta does not wipe a stored total. One-time/Daily is not stored. Optional `goalSats` is a whole-sat ask on a top-level note (JSON number; multipart string). Omitted, JSON `null`, or a multipart empty/missing field means no goal. Max 10_000_000; above max is rejected, not clamped. 200 JSON may include `goalSats` or omit the key. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only (singular or `photos`) or video-only allowed; at least one of non-empty trimmed text, photo, non-empty `photos`, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `photoCount` (0–10), `photoTakenAts` (always; length equals `photoCount`; null when unknown; `[]` when there are no stills), `photoTakenAt` only when `photoCount === 1`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped; never `contentFp`). Identical live photo/video from the same account+parent (same normalised text + same media bytes) and the same pin returns the existing row (200, same id) without consuming the 1/10s burst limiter and without a second push; the same media with a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only is unchanged (new row + burst). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates call `notifyForumPost` (kind `forum_post`, tag `forum_post:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (Web Push still only to bell subscribers, same filter). After a new top-level persist, a note with `#21GiftsShop` and a pin posts once to `POST /map/places` when `mapPush` is set. Boot leaves it unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A blank URL or token also keeps the forum pin, and a failed post logs `ocp.place.failed` and still returns 200. A replace is not this path. After a new top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including the About-me note, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independent of `eligibleToday` (the new row need not itself have media; Spend pays once per Lightning Address; this API may ping again). Replies, and any role other than `verified`, do not welcome-ping. Replies do not ping. A reply calls `notifyForumReply` (kind `forum_reply`, tag `forum_reply:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` — Damus-only parents still fan out; a self-reply skips only the actor. The booted process always has those stores (in-memory without `DATABASE_URL`, Postgres when it is set). Photo-only empty text still notifies; missing `pushStore` still writes in-app rows; notification or push failure still returns 200. It does not copy into the member↔member inbox. Unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on the platform profile note. `verified` stays unpaid-write exempt. Optional JSON `place` `{ lat, lng, label? }` and multipart fields `placeLat`, `placeLng`, `placeLabel`. Omitted place stores nothing and the 200 JSON omits `place`. Invalid place is 400 with the normalizePlace error. A reply with a non-null place is 400 `A reply cannot include a place`. Multipart with exactly one of placeLat/placeLng is 400 `Place must be a latitude and longitude`. Optional `shopUsername` (JSON string or multipart field) assigns a 21.gifts account on a new top-level shop note (`#21GiftsShop`). Omitted, null, blank, or only `@` stores no shop account. A non-string, or a handle `normalizeUsername` rejects, is 400 `Username is not valid`. Unknown username, or a stored username that is missing or blank, is 404 `No account with that username` before the row is created. A reply or a note that is not a shop is 400 `Only a shop note can set a shop account` when the handle is non-blank. After create, `setShopAccount` runs and the 200 JSON includes `shopAccount`; that first assignment does not write `message_edit`. A media replay of an existing live note does not change its shop account. Any account that can post the shop may set it. Later changes stay on staff `PATCH /messages/:id/shop-account`. +- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, username, and/or Lightning Address are missing (order `rules`, then `name`, then `username`, then `lightning-address`); 400 Expected a JSON body with text and/or photo (including JSON `goalSats` type/range errors); 400 Text must be 1–8000 characters; 400 Text must be 1–8000 characters or include a photo; 400 Text must be 1–8000 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 `{ error: 'At most 10 photos' }` when `photos.length > 10`; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 400 `{ error: 'A reply cannot ask for a goal' }` when `inReplyTo` is set and the body sends `goalSats`, `goalCurrency`, `goalAmount`, `goalRepayable`, or `goalTermDays`; 400 `{ error: 'Ask obligation must be true' }` when `goalRepayable` is present and not JSON `true` or multipart `"true"` (JSON `""` is rejected; a multipart empty field is absent); 400 `{ error: 'A repayment obligation needs an ask' }` when `goalRepayable` is true without an ask; 400 `{ error: 'Ask term must be a whole number of days from 1 to 3650' }` when `goalTermDays` is present and outside that range; 400 `{ error: 'A repayment term needs a repayable ask' }` when a term is sent without `goalRepayable: true`; 400 `{ error: 'A repayable ask needs a term in days' }` when `goalRepayable` is true and the term is absent; JSON includes `goalRepayable` only when true (never false) and `goalTermDays` only when set; 400 `{ error: 'Goal must be a positive whole-sat amount' }` when a multipart `goalSats` is present and not `/^\d+$/` or not an integer 1..10_000_000, and when a BTC `goalAmount` is not an integer 1..10_000_000; 400 `{ error: 'Send either goalSats or both goalCurrency and goalAmount' }` when both styles or only one of the new pair is sent, or `goalAmount` is not a canonical decimal string; 400 `{ error: 'Ask amount is unavailable' }` when a fiat ask has no usable gift-day quote or the frozen sats fall outside 1..10_000_000; 503 `{ error: 'Messages are unavailable' }` when the gift-day loader throws for a fiat ask (a BTC ask still stores the typed sats); 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, soft-hidden (`deletedAt` set), or the parent is itself a reply (`parentId !== null`); 403 `{ error: 'A post needs a Bitcoin payment' }` or `{ error: 'A reply needs a Bitcoin payment' }` when the caller is below `verified` (including the parent author) and the body is text-only; photo or video posts and replies from basis are 200; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on that note; 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). 409 `{ error: 'A live note with this media already exists' }` when the same account, parent, and live media fingerprint already exists with a different pin. 400 `{ error: 'Place must be a latitude and longitude' }` when JSON `place` is invalid or multipart has exactly one of placeLat/placeLng; 400 `{ error: 'Place label must be at most 80 characters' }` when the label fails normalizePlace; 400 `{ error: 'A reply cannot include a place' }` when `inReplyTo` is set and place is non-null. 400 `{ error: 'Username is not valid' }` when `shopUsername` is not a usable handle; 404 `{ error: 'No account with that username' }` when that handle matches no account or the stored username is missing or blank; 400 `{ error: 'Only a shop note can set a shop account' }` when a non-blank `shopUsername` is sent on a reply or a note without `#21GiftsShop`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). Pay links, the till, contact, and ordinary private threads are not this error. - **Used by:** App forum composer and reply composer. - **Auth:** `Authorization: Bearer` session. - **Marks:** `POST /messages` resolves `@username` at send time (longest username-character run, not an address like `name@21.gifts`) to the account that owns that username then, stores `{ accountId, username }` on the row, and notifies that person once with kind `forum_mention` (not the author; the same person twice is one row). A later rename does not move the mark. About me, private messages, and inbound Nostr notes are not parsed. Live JSON includes `mentions` only when `accountId` is included and the list is non-empty. Push body is `{name} marked you` in the recipient locale (`de` `{name} hat dich markiert`, `es` `{name} te marcó`, `fil` `Minarkahan ka ni {name}`). diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 05cffa2ec..e8b847628 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -1242,6 +1242,282 @@ describe('POST /messages', () => { expect(body.hasPhoto).toBe(true); }); + async function withLuna(auth: InMemoryAuthStore, name: string | null = 'Luna'): Promise { + await auth.createAccount({ + id: 'shop-acc', + linkingKey: null, + role: 'basis', + name, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: 'd'.repeat(64), + createdAt: now(), + rulesAgreedAt: now(), + }); + const luna = await auth.getAccount('shop-acc'); + expect(luna).toBeDefined(); + if (luna === undefined) { + throw new Error('expected luna'); + } + await auth.updateAccount({ ...luna, username: 'luna', name }); + } + + it('assigns shopUsername on a new shop note and writes no edit history', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth); + const messages = new InMemoryMessageStore(); + const res = await mount(auth, messages).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe Luna #21GiftsShop', shopUsername: '@Luna' }), + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { + id: string; + shopAccount?: { id: string; username: string; name: string }; + }; + expect(created.shopAccount).toEqual({ id: 'shop-acc', username: 'luna', name: 'Luna' }); + expect(await messages.listEdits(created.id)).toEqual([]); + }); + + it('stores an empty shop name when the assigned account has none', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth, null); + const res = await mount(auth).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'luna' }), + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { shopAccount?: { name: string } }; + expect(created.shopAccount?.name).toBe(''); + }); + + it('lets a basis account assign a shop username on a photo shop note', async () => { + const auth = await namedStore('Ada'); + const ada = await auth.getAccount('acc'); + expect(ada).toBeDefined(); + await auth.updateAccount({ ...ada!, role: 'basis' }); + await withLuna(auth); + const res = await mount(auth).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + text: 'Cafe #21GiftsShop', + shopUsername: 'luna', + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + }), + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { shopAccount?: { username: string } }; + expect(created.shopAccount?.username).toBe('luna'); + }); + + it('ignores a blank shop username', async () => { + const auth = await namedStore('Ada'); + for (const shopUsername of ['', ' ', '@', null]) { + const res = await mount(auth).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: `Cafe ${String(shopUsername)} #21GiftsShop`, shopUsername }), + }); + expect(res.status).toBe(200); + expect(await res.json()).not.toHaveProperty('shopAccount'); + } + }); + + it('rejects a shop username that is not a shop note, a reply, or a usable handle', async () => { + const auth = await namedStore('Ada'); + const messages = new InMemoryMessageStore(); + const parentId = '11111111-1111-4111-8111-111111111111'; + await messages.create({ + id: parentId, + accountId: 'acc', + name: 'Ada', + text: 'Parent', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const app = mount(auth, messages); + const post = (body: unknown) => + app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const notShop = await post({ text: 'Hello', shopUsername: 'luna' }); + expect(notShop.status).toBe(400); + expect(await notShop.json()).toEqual({ error: 'Only a shop note can set a shop account' }); + const reply = await post({ + text: 'Cafe #21GiftsShop', + inReplyTo: parentId, + shopUsername: 'luna', + }); + expect(reply.status).toBe(400); + expect(await reply.json()).toEqual({ error: 'Only a shop note can set a shop account' }); + const invalid = await post({ text: 'Cafe #21GiftsShop', shopUsername: 'not a user' }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ error: 'Username is not valid' }); + const number = await post({ text: 'Cafe #21GiftsShop', shopUsername: 1 }); + expect(number.status).toBe(400); + expect(await number.json()).toEqual({ error: 'Username is not valid' }); + expect(await messages.listLatest(10)).toHaveLength(1); + }); + + it('returns 404 before create when the shop username is unknown', async () => { + const auth = await namedStore('Ada'); + const messages = new InMemoryMessageStore(); + const res = await mount(auth, messages).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'missing' }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'No account with that username' }); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + + it('returns 404 when the stored shop username is blank or null', async () => { + const auth = await namedStore('Ada'); + const ada = await auth.getAccount('acc'); + expect(ada).toBeDefined(); + if (ada === undefined) { + throw new Error('expected account'); + } + const messages = new InMemoryMessageStore(); + const app = mount(auth, messages); + for (const username of [' ', null]) { + vi.spyOn(auth, 'getAccountByUsername').mockResolvedValueOnce({ ...ada, username }); + const res = await app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + text: `Cafe ${String(username)} #21GiftsShop`, + shopUsername: 'luna', + }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'No account with that username' }); + } + expect(await messages.listLatest(10)).toHaveLength(0); + }); + + it('does not change the shop account when the same media is posted again', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth); + const messages = new InMemoryMessageStore(); + const setShop = vi.spyOn(messages, 'setShopAccount'); + const app = mount(auth, messages); + const photo = { contentType: 'image/jpeg', data: JPEG_B64 }; + const first = await app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', photo }), + }); + expect(first.status).toBe(200); + const firstId = ((await first.json()) as { id: string }).id; + const second = await app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', photo, shopUsername: 'luna' }), + }); + expect(second.status).toBe(200); + const replay = (await second.json()) as { id: string }; + expect(replay.id).toBe(firstId); + expect(replay).not.toHaveProperty('shopAccount'); + expect(setShop).not.toHaveBeenCalled(); + expect(await messages.listEdits(firstId)).toEqual([]); + }); + + it('returns 503 when storing the shop account fails after create', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth); + const messages = new InMemoryMessageStore(); + vi.spyOn(messages, 'setShopAccount').mockResolvedValue(false); + warn.mockClear(); + const res = await mount(auth, messages).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'luna' }), + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Messages are unavailable' }); + expect(parsedEvents(warn).some((event) => event['event'] === 'messages.create.failed')).toBe( + true, + ); + expect((await messages.listLatest(10))[0]?.shopAccount).toBeNull(); + }); + + it('returns 503 when the shop row disappears after the account write', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth); + const messages = new InMemoryMessageStore(); + vi.spyOn(messages, 'getById').mockResolvedValue(undefined); + const res = await mount(auth, messages).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'luna' }), + }); + expect(res.status).toBe(503); + }); + + it('returns 503 when setShopAccount throws', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth); + const messages = new InMemoryMessageStore(); + vi.spyOn(messages, 'setShopAccount').mockRejectedValue(new Error('boom')); + const res = await mount(auth, messages).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'luna' }), + }); + expect(res.status).toBe(503); + }); + + it('assigns shopUsername from a multipart shop note', async () => { + const auth = await namedStore('Ada'); + await withLuna(auth); + const form = new FormData(); + form.set('text', 'Cafe #21GiftsShop'); + form.set('shopUsername', '@Luna'); + const res = await mount(auth).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { shopAccount?: { username: string } }; + expect(created.shopAccount?.username).toBe('luna'); + }); + + it('rejects a multipart shop username that is a file or not a shop note', async () => { + const auth = await namedStore('Ada'); + const app = mount(auth); + const fileForm = new FormData(); + fileForm.set('text', 'Cafe #21GiftsShop'); + fileForm.set('shopUsername', new File(['x'], 'name.txt', { type: 'text/plain' })); + const fileRes = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: fileForm, + }); + expect(fileRes.status).toBe(400); + expect(await fileRes.json()).toEqual({ error: 'Username is not valid' }); + const plain = new FormData(); + plain.set('text', 'Hello'); + plain.set('shopUsername', 'luna'); + const plainRes = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: plain, + }); + expect(plainRes.status).toBe(400); + expect(await plainRes.json()).toEqual({ error: 'Only a shop note can set a shop account' }); + }); + it('returns 429 on a burst of posts', async () => { const limiter = new PostRateLimiter(); const app = new Hono().route( diff --git a/src/routes/messages.ts b/src/routes/messages.ts index ff8804545..7ae74a62f 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -779,6 +779,59 @@ async function frozenAskResponse( return { goal: frozen.goal }; } +/** + * Resolve an optional shop username before a note is created. + * + * Omitted, null, blank, or only `@` means no assignment. A non-blank value + * is only valid on a top-level shop note. Unknown and unusable handles fail + * before `persistForumPost`. + * + * @param deps - Auth store used for the username lookup. + * @param text - Normalised note body. + * @param parentId - Reply parent, or null for a top-level note. + * @param raw - JSON or multipart `shopUsername`, or undefined when omitted. + * @returns The account snapshot, null when unset, or a 400/404 error. + */ +async function postedShopAccount( + deps: MessagesRouteDeps, + text: string, + parentId: string | null, + raw: unknown, +): Promise< + | { ok: true; snapshot: { id: string; username: string; name: string } | null } + | { ok: false; status: 400 | 404; error: string } +> { + if (raw === undefined || raw === null) { + return { ok: true, snapshot: null }; + } + if (typeof raw !== 'string') { + return { ok: false, status: 400, error: 'Username is not valid' }; + } + const trimmed = raw.trim().replace(/^@/, ''); + if (trimmed === '') { + return { ok: true, snapshot: null }; + } + if (parentId !== null || !textHasHashtagToken(text, '21GiftsShop')) { + return { ok: false, status: 400, error: 'Only a shop note can set a shop account' }; + } + const normalized = normalizeUsername(trimmed); + if (normalized === null) { + return { ok: false, status: 400, error: 'Username is not valid' }; + } + const found = await deps.authStore.getAccountByUsername(normalized); + if (found === undefined) { + return { ok: false, status: 404, error: 'No account with that username' }; + } + const storedUsername = found.username; + if (typeof storedUsername !== 'string' || storedUsername.trim() === '') { + return { ok: false, status: 404, error: 'No account with that username' }; + } + return { + ok: true, + snapshot: { id: found.id, username: storedUsername, name: found.name ?? '' }, + }; +} + /** * Media collapse → burst limiter → create → optional {@link notifyForumPost} * (every account except the actor; no-op when the actor is the official @@ -801,6 +854,9 @@ async function frozenAskResponse( * replies store null. * @param place - Optional map pin for a top-level note. Default `null`. * Stored as `null` when `parentId` is set. + * @param shopAccount - Optional shop assignment for a new top-level shop + * note. Default `null`. Not applied on a media replay, and not written + * as edit history. * @returns 200 / 403 (unpaid text-only below verified) / 409 (same live * media, different pin) / 429 / 503. */ @@ -817,6 +873,7 @@ async function persistForumPost( extraPhotos?: readonly ForumPhoto[], goal: FrozenAsk = NO_ASK, place: ForumPlace | null = null, + shopAccount: { id: string; username: string; name: string } | null = null, ): Promise { const extras = video !== undefined ? [] : [...(extraPhotos ?? [])]; if (photo !== undefined || video !== undefined) { @@ -1002,20 +1059,32 @@ async function persistForumPost( logEvent('messages.mention.notify.failed'); } } + let published = created; + if (!isReplay && shopAccount !== null) { + const written = await deps.store.setShopAccount(created.id, shopAccount); + if (!written) { + throw new Error('shop account was not stored'); + } + const updated = await deps.store.getById(created.id); + if (updated === undefined) { + throw new Error('shop account was not stored'); + } + published = updated; + } if (!isReplay) { await recordFirstShopOcpPlace({ ...(deps.mapPush === undefined ? {} : { mapPush: deps.mapPush }), - messageId: created.id, - text: created.text, - parentId: created.parentId ?? null, - place: created.place ?? null, - authorName: created.name, + messageId: published.id, + text: published.text, + parentId: published.parentId ?? null, + place: published.place ?? null, + authorName: published.name, hadPlaceBefore: false, textHasHashtagToken, }); } return c.json( - serializeMessage(created, payableOf(created, account), account.role, undefined, true), + serializeMessage(published, payableOf(published, account), account.role, undefined, true), 200, ); } catch (err) { @@ -1147,6 +1216,16 @@ async function postMultipartMessage( if (!termed.ok) { return c.json({ error: termed.error }, 400); } + const rawShop = form.get('shopUsername'); + const shopParsed = await postedShopAccount( + deps, + text, + null, + rawShop === null ? undefined : rawShop, + ); + if (!shopParsed.ok) { + return c.json({ error: shopParsed.error }, shopParsed.status); + } return persistForumPost( deps, postLimiter, @@ -1160,6 +1239,7 @@ async function postMultipartMessage( undefined, termed.goal, place, + shopParsed.snapshot, ); } @@ -1174,6 +1254,7 @@ const postBody = z goalRepayable: z.unknown().nullish(), goalTermDays: z.unknown().nullish(), place: z.unknown().nullish(), + shopUsername: z.unknown().nullish(), photo: z .object({ contentType: z.string(), @@ -1650,6 +1731,10 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (!termed.ok) { return c.json({ error: termed.error }, 400); } + const shopParsed = await postedShopAccount(deps, text, parentId, parsed.data.shopUsername); + if (!shopParsed.ok) { + return c.json({ error: shopParsed.error }, shopParsed.status); + } return persistForumPost( deps, postLimiter, @@ -1663,6 +1748,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { extraPhotos.length > 0 ? extraPhotos : undefined, termed.goal, place, + shopParsed.snapshot, ); }) .get('/compose-target', async (c) => { From 29827a2d9f24c973d9b07be5757735ae6f2e7bd8 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:45 +0200 Subject: [PATCH 03/19] Replace the stills on a shop note without a history row A moderator can clear or replace the photos. The video, the sats, and the published event stay as they are. --- docs/handbook/endpoints.md | 7 + e2e/http.spec.ts | 7 + src/__tests__/lib/message-store.test.ts | 67 +++++++++ src/__tests__/lib/nostr/zap-index.test.ts | 6 + src/__tests__/routes/messages.test.ts | 164 ++++++++++++++++++++++ src/__tests__/sunday-rest-routes.test.ts | 10 ++ src/lib/message-store.ts | 68 +++++++++ src/lib/sunday-rest.ts | 1 + src/routes/messages.ts | 85 +++++++++++ 9 files changed, 415 insertions(+) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 5f3126ffe..18b69ca2e 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -56,6 +56,13 @@ - **Used by:** Staff shop-note text edit in the app forum. - **Auth:** `Authorization: Bearer` session (moderator). +## Endpoint: PATCH /messages/:id/photos + +- **Purpose:** Bearer required. A moderator replaces the stills on a live top-level shop note (`#21GiftsShop`) via `MessageStore.replacePhotos`. Body `{ photos: { contentType, data, takenAt? }[] }` (at most 10; empty clears stills). Does not republish Nostr, write `message_edit`, or change text, video, sats, author, or event ids. A video note keeps its video. Success is the live public message JSON. +- **Errors:** 401 `{ error: 'Unauthorized' }`; 403 `{ error: 'Forbidden' }` below moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a hidden row, or `replacePhotos` undefined; 400 `{ error: 'Invalid body' }`; 400 `{ error: 'At most 10 photos' }`; 400 `{ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }`; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). +- **Used by:** The shop-note pencil wizard in the app. +- **Auth:** `Authorization: Bearer` session (moderator). + ## Endpoint: GET /messages/:id/edits - **Purpose:** Bearer required. A moderator lists `message_edit` history for a top-level shop note, newest first, including a hidden shop note. Does not change the note and does not republish Nostr. GET is not a Sunday write. Actors resolve like hide stamps (missing account keeps the id with null name/role). Public message JSON does not include `edits`. Empty history is `{ edits: [] }`. diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 61b00e258..037749fa2 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -241,6 +241,13 @@ test('PATCH /messages/:id/text without bearer is 401', async ({ request }) => { expect(res.status()).toBe(401); }); +test('PATCH /messages/:id/photos without bearer is 401', async ({ request }) => { + const res = await request.patch('/messages/:id/photos', { + data: { photos: [] }, + }); + expect(res.status()).toBe(401); +}); + test('GET /messages/:id/edits without bearer is 401', async ({ request }) => { const res = await request.get('/messages/:id/edits'); expect(res.status()).toBe(401); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index b54295f72..8efa8436f 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -2467,6 +2467,36 @@ describe('InMemoryMessageStore', () => { expect(await store.updatePhoto('missing', JPEG)).toBeUndefined(); }); + it('replacePhotos sets, adds an extra, and clears without changing sats or eventId', async () => { + const store = new InMemoryMessageStore(); + await store.create({ ...EARLY, eventId: 'ee'.repeat(32), sats: 21, hasVideo: true }); + expect(await store.replacePhotos('missing', [JPEG])).toBeUndefined(); + const one = await store.replacePhotos('a', [JPEG]); + expect(one?.hasPhoto).toBe(true); + expect(one?.photoCount).toBe(1); + expect(one?.sats).toBe(21); + expect(one?.eventId).toBe('ee'.repeat(32)); + const two = await store.replacePhotos('a', [ + { ...JPEG, takenAt: '2020-01-01T00:00:00+00:00' }, + JPEG2, + { ...JPEG2, takenAt: '2021-02-02T00:00:00+00:00' }, + ]); + expect(two?.photoCount).toBe(3); + expect(two?.photoTakenAts).toEqual([ + '2020-01-01T00:00:00+00:00', + null, + '2021-02-02T00:00:00+00:00', + ]); + expect(await store.listExtraPhotos('a')).toEqual([ + JPEG2, + { ...JPEG2, takenAt: '2021-02-02T00:00:00+00:00' }, + ]); + const cleared = await store.replacePhotos('a', []); + expect(cleared?.hasPhoto).toBe(false); + expect(cleared?.photoCount).toBe(0); + expect(await store.listExtraPhotos('a')).toEqual([]); + }); + it('pads a listed photo that has no stored capture time', async () => { const store = new InMemoryMessageStore([{ ...EARLY, hasPhoto: true }]); expect((await store.getById('a'))?.photoTakenAts).toEqual([null]); @@ -6758,6 +6788,43 @@ describe('PostgresMessageStore', () => { expect(await store.updatePhoto('missing', JPEG)).toBeUndefined(); }); + it('replacePhotos deletes extras, writes the new stills, and returns undefined when missing', async () => { + const sql = new MockSql(); + const row = { + id: 'm1', + account_id: 'acc', + name: 'Ada', + text: 'Shop #21GiftsShop', + created_at: new Date(0), + has_photo: true, + photo_count: 2, + event_id: 'ee'.repeat(32), + nostr_publish_state: 'published', + sats: 21, + }; + sql.queryQueue = [[row], [row], [row]]; + const store = new PostgresMessageStore(sql); + const updated = await store.replacePhotos('m1', [ + { ...JPEG, takenAt: '2020-01-01T00:00:00+00:00' }, + JPEG2, + { ...JPEG2, takenAt: '2020-01-02T00:00:00+00:00' }, + ]); + expect(updated?.id).toBe('m1'); + expect(sql.executes[0]?.text).toMatch(/DELETE FROM message_extra_photo/); + expect(sql.queries[1]?.text).toMatch(/UPDATE message SET photo/); + expect(sql.queries[1]?.params?.[3]).toBe('2020-01-01T00:00:00+00:00'); + expect(sql.executes[1]?.params?.[4]).toBeNull(); + expect(sql.executes[2]?.params?.[4]).toBe('2020-01-02T00:00:00+00:00'); + sql.queryQueue = [[row], [row], []]; + const fallenBack = await store.replacePhotos('m1', [JPEG]); + expect(fallenBack?.id).toBe('m1'); + expect(sql.queries.at(-2)?.params?.[3]).toBeNull(); + sql.queryQueue = [[row], []]; + expect(await store.replacePhotos('m1', [])).toBeUndefined(); + sql.queryQueue = [[]]; + expect(await store.replacePhotos('missing', [JPEG])).toBeUndefined(); + }); + it('getById maps nostr_event JSON string', async () => { const sql = new MockSql(); sql.nextRows = [ diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index 32c2b17bf..5710efd10 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -4927,6 +4927,8 @@ describe('indexOpenZapReceipts', () => { listIndexedZapIngests: () => base.listIndexedZapIngests(), listAuthoredMessages: (accountId: string) => base.listAuthoredMessages(accountId), listOpenConversationZapEventIds: () => base.listOpenConversationZapEventIds(), + replacePhotos: (...args: Parameters) => + base.replacePhotos(...args), }; const querier = new RecordingQuerier(); querier.events = [ @@ -5178,6 +5180,8 @@ describe('indexOpenZapReceipts', () => { listIndexedZapIngests: () => base.listIndexedZapIngests(), listAuthoredMessages: (accountId: string) => base.listAuthoredMessages(accountId), listOpenConversationZapEventIds: () => base.listOpenConversationZapEventIds(), + replacePhotos: (...args: Parameters) => + base.replacePhotos(...args), }; const querier = new RecordingQuerier(); querier.events = [ @@ -5966,6 +5970,8 @@ describe('indexOpenZapReceipts', () => { listIndexedZapIngests: () => base.listIndexedZapIngests(), listAuthoredMessages: (accountId: string) => base.listAuthoredMessages(accountId), listOpenConversationZapEventIds: () => base.listOpenConversationZapEventIds(), + replacePhotos: (...args: Parameters) => + base.replacePhotos(...args), }; const querier = new RecordingQuerier(); querier.events = [ diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index e8b847628..106c319a9 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -228,6 +228,7 @@ function throwingStore(overrides: Partial = {}): MessageStore { resetSignedEvent: boom, updateText: boom, updatePhoto: boom, + replacePhotos: boom, updateSignedEvent: boom, updatePublishState: boom, addSats: boom, @@ -3608,6 +3609,7 @@ describe('POST /messages', () => { listIndexedZapIngests: () => base.listIndexedZapIngests(), listAuthoredMessages: (accountId) => base.listAuthoredMessages(accountId), listOpenConversationZapEventIds: () => base.listOpenConversationZapEventIds(), + replacePhotos: (id, photos) => base.replacePhotos(id, photos), attributeZapReceipt: (receiptEventId, attribution) => base.attributeZapReceipt(receiptEventId, attribution), recordZapper: (pubkey, receiptEventId, at) => base.recordZapper(pubkey, receiptEventId, at), @@ -3731,6 +3733,7 @@ describe('POST /messages', () => { listIndexedZapIngests: () => base.listIndexedZapIngests(), listAuthoredMessages: (accountId) => base.listAuthoredMessages(accountId), listOpenConversationZapEventIds: () => base.listOpenConversationZapEventIds(), + replacePhotos: (id, photos) => base.replacePhotos(id, photos), attributeZapReceipt: (receiptEventId, attribution) => base.attributeZapReceipt(receiptEventId, attribution), recordZapper: (pubkey, receiptEventId, at) => base.recordZapper(pubkey, receiptEventId, at), @@ -5387,6 +5390,7 @@ describe('POST /messages/:id/invoice', () => { listIndexedZapIngests: () => base.listIndexedZapIngests(), listAuthoredMessages: (accountId) => base.listAuthoredMessages(accountId), listOpenConversationZapEventIds: () => base.listOpenConversationZapEventIds(), + replacePhotos: (id, photos) => base.replacePhotos(id, photos), attributeZapReceipt: (receiptEventId, attribution) => base.attributeZapReceipt(receiptEventId, attribution), recordZapper: (pubkey, receiptEventId, at) => base.recordZapper(pubkey, receiptEventId, at), @@ -11601,6 +11605,166 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { expect(gone.status).toBe(404); }); + function patchPhotos( + auth: InMemoryAuthStore, + id: string, + body: unknown, + messages: InMemoryMessageStore = new InMemoryMessageStore(), + headers: Record = AUTH, + ) { + return mount(auth, messages).request('/messages/' + id + '/photos', { + method: 'PATCH', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + it('replaces shop stills and writes no edit history', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await messages.create( + { + id: SHOP_ID, + accountId: 'acc', + name: 'Ada', + text: 'Cafe\n\n#21GiftsShop', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: EVENT_ID, + sats: 21, + }, + undefined, + { contentType: 'video/mp4', bytes: new Uint8Array([1, 2, 3]) }, + ); + const res = await patchPhotos( + auth, + SHOP_ID, + { + photos: [ + { contentType: 'image/jpeg', data: JPEG_B64, takenAt: '2020-01-01T00:00:00+00:00' }, + { contentType: 'image/jpeg', data: JPEG_B64 }, + ], + }, + messages, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { hasPhoto: boolean; photoCount: number; hasVideo: boolean }; + expect(body.hasPhoto).toBe(true); + expect(body.photoCount).toBe(2); + expect(body.hasVideo).toBe(true); + expect(await messages.listEdits(SHOP_ID)).toEqual([]); + const cleared = await patchPhotos(auth, SHOP_ID, { photos: [] }, messages); + expect(cleared.status).toBe(200); + const clearedBody = (await cleared.json()) as { hasPhoto: boolean; hasVideo: boolean }; + expect(clearedBody.hasPhoto).toBe(false); + expect(clearedBody.hasVideo).toBe(true); + + const external = new InMemoryMessageStore(); + await shopNote(external, { accountId: null, authorPubkey: 'ab'.repeat(32) }); + const externalRes = await patchPhotos(auth, SHOP_ID, { photos: [] }, external); + expect(externalRes.status).toBe(200); + expect((await externalRes.json()) as { role?: string }).not.toHaveProperty('role'); + + const gone = new InMemoryMessageStore(); + await shopNote(gone, { accountId: 'gone' }); + const goneRes = await patchPhotos(auth, SHOP_ID, { photos: [] }, gone); + expect(goneRes.status).toBe(200); + expect(((await goneRes.json()) as { role: string }).role).toBe('basis'); + }); + + it('rejects a bad photo body, a reply, a hidden note, and a non-shop note', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + expect((await patchPhotos(auth, 'nope', { photos: [] }, messages)).status).toBe(404); + expect((await patchPhotos(auth, SHOP_ID, {}, messages)).status).toBe(400); + expect((await patchPhotos(auth, SHOP_ID, { photos: 'x' }, messages)).status).toBe(400); + expect( + (await patchPhotos(auth, SHOP_ID, { photos: [{ contentType: 1, data: 2 }] }, messages)) + .status, + ).toBe(400); + expect( + ( + await patchPhotos( + auth, + SHOP_ID, + { photos: [{ contentType: 'image/jpeg', data: 'not-a-photo' }] }, + messages, + ) + ).status, + ).toBe(400); + expect( + ( + await patchPhotos(auth, SHOP_ID, { + photos: Array.from({ length: 11 }, () => ({ + contentType: 'image/jpeg', + data: JPEG_B64, + })), + }) + ).status, + ).toBe(400); + await messages.create({ + id: REPLY_ID, + accountId: 'acc', + name: 'Ada', + text: 'Reply #21GiftsShop', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + parentId: SHOP_ID, + }); + const reply = await patchPhotos(auth, REPLY_ID, { photos: [] }, messages); + expect(reply.status).toBe(400); + expect(await reply.json()).toEqual({ error: 'A reply cannot be edited' }); + await messages.markDeleted(SHOP_ID, new Date(now()), 'acc'); + expect((await patchPhotos(auth, SHOP_ID, { photos: [] }, messages)).status).toBe(404); + await messages.create({ + id: PLAIN_ID, + accountId: 'acc', + name: 'Ada', + text: 'Hello', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const plain = await patchPhotos(auth, PLAIN_ID, { photos: [] }, messages); + expect(plain.status).toBe(400); + expect(await plain.json()).toEqual({ error: 'Only a shop note can be edited' }); + expect( + ( + await mount(await namedStore('Ada'), messages).request('/messages/' + SHOP_ID + '/photos', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ photos: [] }), + }) + ).status, + ).toBe(403); + expect( + ( + await mount(auth, messages).request('/messages/' + SHOP_ID + '/photos', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ photos: [] }), + }) + ).status, + ).toBe(401); + }); + + it('returns 404 when replacePhotos misses and 503 when it throws', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + vi.spyOn(messages, 'replacePhotos').mockResolvedValueOnce(undefined); + expect((await patchPhotos(auth, SHOP_ID, { photos: [] }, messages)).status).toBe(404); + vi.spyOn(messages, 'replacePhotos').mockRejectedValueOnce(new Error('boom')); + warn.mockClear(); + expect((await patchPhotos(auth, SHOP_ID, { photos: [] }, messages)).status).toBe(503); + expect(parsedEvents(warn).some((event) => event['event'] === 'messages.photos.failed')).toBe( + true, + ); + }); + it('lists history newest first for staff, including a hidden shop note', async () => { const auth = await staffStore('Ada'); const messages = new InMemoryMessageStore(); diff --git a/src/__tests__/sunday-rest-routes.test.ts b/src/__tests__/sunday-rest-routes.test.ts index b62e525ec..2a5c32e7b 100644 --- a/src/__tests__/sunday-rest-routes.test.ts +++ b/src/__tests__/sunday-rest-routes.test.ts @@ -86,6 +86,16 @@ describe('sunday rest routes', () => { expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); }); + it('refuses PATCH /messages/:id/photos when the device zone is Sunday', async () => { + const res = await app().request('/messages/note-1/photos', { + method: 'PATCH', + headers: { 'Time-Zone': 'Europe/Zurich', 'content-type': 'application/json' }, + body: JSON.stringify({ photos: [] }), + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); + }); + it('refuses PATCH /messages/:id/text when the device zone is Sunday', async () => { const res = await app().request('/messages/note-1/text', { method: 'PATCH', diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index a34c7d42d..6d7623291 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -825,6 +825,17 @@ export interface MessageStore { */ updatePhoto(id: string, photo: ForumPhoto | null): Promise; + /** + * Replace every still on a note. Index 0 is the primary photo; the rest are + * extras. An empty list clears stills. Does not change text, video, sats, or + * event ids, and does not recompute `content_fp`. + * + * @param id - Message id. + * @param photos - Decoded stills, at most 10. + * @returns The updated row copy, or `undefined` when no row has that id. + */ + replacePhotos(id: string, photos: readonly ForumPhoto[]): Promise; + /** Persist a signed event id + JSON. Returns false on event-id collision. */ updateSignedEvent( id: string, @@ -2960,6 +2971,33 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(copyRow(row)); } + replacePhotos(id: string, photos: readonly ForumPhoto[]): Promise { + const row = this.#rows.find((item) => item.id === id); + if (row === undefined) { + return Promise.resolve(undefined); + } + this.#photos.delete(id); + this.#extraPhotos.delete(id); + const first = photos[0]; + if (first !== undefined) { + this.#photos.set(id, copyPhoto(first)); + row.hasPhoto = true; + } else { + row.hasPhoto = false; + } + const extras = photos.slice(1).map((item) => copyPhoto(item)); + if (extras.length > 0) { + this.#extraPhotos.set(id, extras); + } + const storedExtras = this.#extraPhotos.get(id) ?? []; + row.photoCount = (first !== undefined ? 1 : 0) + storedExtras.length; + row.photoTakenAts = [ + ...(first !== undefined ? [typeof first.takenAt === 'string' ? first.takenAt : null] : []), + ...storedExtras.map((item) => (typeof item.takenAt === 'string' ? item.takenAt : null)), + ]; + return Promise.resolve(copyRow(row)); + } + updateSignedEvent( id: string, eventId: string, @@ -5232,6 +5270,36 @@ export class PostgresMessageStore implements MessageStore { return row === undefined ? undefined : mapMessageRow(row); } + async replacePhotos(id: string, photos: readonly ForumPhoto[]): Promise { + const existing = await this.getById(id); + if (existing === undefined) { + return undefined; + } + await this.#sql.execute(`DELETE FROM message_extra_photo WHERE message_id = $1`, [id]); + const first = photos[0]; + const rows = await this.#sql.query( + `UPDATE message SET photo = $2, photo_content_type = $3, photo_taken_at = $4 WHERE id = $1 RETURNING ${MESSAGE_SELECT_COLUMNS}`, + [ + id, + first === undefined ? null : first.bytes, + first === undefined ? null : first.contentType, + first === undefined || typeof first.takenAt !== 'string' ? null : first.takenAt, + ], + ); + const written = rows[0]; + if (written === undefined) { + return undefined; + } + for (const [index, extra] of photos.slice(1).entries()) { + await this.#sql.execute( + `INSERT INTO message_extra_photo (message_id, idx, photo, photo_content_type, photo_taken_at) VALUES ($1,$2,$3,$4,$5)`, + [id, index + 1, extra.bytes, extra.contentType, extra.takenAt ?? null], + ); + } + const refreshed = await this.getById(id); + return refreshed ?? mapMessageRow(written); + } + async updateSignedEvent( id: string, eventId: string, diff --git a/src/lib/sunday-rest.ts b/src/lib/sunday-rest.ts index 196a74620..c1929a665 100644 --- a/src/lib/sunday-rest.ts +++ b/src/lib/sunday-rest.ts @@ -39,6 +39,7 @@ const SUNDAY_REST_WRITES: ReadonlyArray = [ ['PATCH', /^\/messages\/[^/]+\/place$/], ['PATCH', /^\/messages\/[^/]+\/text$/], ['PATCH', /^\/messages\/[^/]+\/shop-account$/], + ['PATCH', /^\/messages\/[^/]+\/photos$/], ['POST', /^\/funding\/apply$/], ['POST', /^\/funding\/trial$/], ['POST', /^\/funding\/admit$/], diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 7ae74a62f..fa9a653a8 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -2230,6 +2230,91 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Messages are unavailable' }, 503); } }) + .patch('/:id/photos', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!roleAtLeast(account.role, 'moderator')) { + return c.json({ error: 'Forbidden' }, 403); + } + const id = c.req.param('id'); + if (!MESSAGE_ID_RE.test(id)) { + return c.json({ error: 'Not found' }, 404); + } + const raw: unknown = await c.req.json().catch(() => null); + if ( + raw === null || + typeof raw !== 'object' || + Array.isArray(raw) || + !Object.prototype.hasOwnProperty.call(raw, 'photos') || + !Array.isArray((raw as { photos: unknown }).photos) + ) { + return c.json({ error: 'Invalid body' }, 400); + } + const listed = (raw as { photos: unknown[] }).photos; + if (listed.length > 10) { + return c.json({ error: 'At most 10 photos' }, 400); + } + const decoded: ForumPhoto[] = []; + for (const item of listed) { + if ( + item === null || + typeof item !== 'object' || + Array.isArray(item) || + typeof (item as { contentType?: unknown }).contentType !== 'string' || + typeof (item as { data?: unknown }).data !== 'string' + ) { + return c.json({ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + const photo = item as { contentType: string; data: string; takenAt?: unknown }; + const next = decodeForumPhoto(photo.contentType, photo.data); + if (next === null) { + return c.json({ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + next.takenAt = normalizePhotoTakenAt(photo.takenAt); + decoded.push(next); + } + try { + const row = await deps.store.getById(id); + if (row === undefined || row.deletedAt !== null) { + return c.json({ error: 'Not found' }, 404); + } + if (row.parentId !== null) { + return c.json({ error: 'A reply cannot be edited' }, 400); + } + if (!textHasHashtagToken(row.text, '21GiftsShop')) { + return c.json({ error: 'Only a shop note can be edited' }, 400); + } + const written = await deps.store.replacePhotos(id, decoded); + if (written === undefined) { + return c.json({ error: 'Not found' }, 404); + } + const author = + written.accountId === null + ? undefined + : await deps.authStore.getAccount(written.accountId); + const payable = written.accountId === null ? false : payableOf(written, author); + const role = written.accountId === null ? undefined : (author?.role ?? 'basis'); + logEvent('messages.photos.updated', { + messageId: id, + accountId: account.id, + role: account.role, + }); + return c.json( + serializeMessage( + written, + payable, + role, + await deps.store.countAttributedReplies(written.id), + ), + 200, + ); + } catch { + logEvent('messages.photos.failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } + }) .get('/:id/edits', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); if (account === null) { From 9c8982603a0b38ab76bd2a2b2dc3480d1decd62d Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:18:31 +0200 Subject: [PATCH 04/19] Mark shop pins so the map can offer the pencil The places list says which notes are shops. Pins that are not shops stay without the pencil. --- docs/handbook/endpoints.md | 2 +- src/__tests__/lib/message-store.test.ts | 18 +++++++++++++++--- src/__tests__/routes/messages.test.ts | 4 +++- src/lib/message-store.ts | 12 ++++++++++-- src/routes/messages.ts | 1 + 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 18b69ca2e..69c39eeaa 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -499,7 +499,7 @@ ## Endpoint: GET /messages/places -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')`. Lists live top-level notes that have both coordinates (`parent_id` null, `deleted_at` null, `place_lat` and `place_lng` not null) via `listPlaces`. Query `limit` (integer 1–1000, default 1000). Body `{ places: [{ id, name, createdAt, lat, lng, label, accountId? }] }`. `accountId` is set for a 21.gifts author and omitted for an external pin. `createdAt` is ISO-8601. Newest `createdAt` then `id` descending. `label` is a string or null. Replies and soft-hidden notes are excluded. No photo bytes. +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')`. Lists live top-level notes that have both coordinates (`parent_id` null, `deleted_at` null, `place_lat` and `place_lng` not null) via `listPlaces`. Query `limit` (integer 1–1000, default 1000). Body `{ places: [{ id, name, createdAt, lat, lng, label, shop, accountId? }] }`. `shop` is true when the note text contains the shop tag. `accountId` is set for a 21.gifts author and omitted for an external pin. `createdAt` is ISO-8601. Newest `createdAt` then `id` descending. `label` is a string or null. Replies and soft-hidden notes are excluded. No photo bytes. - **Errors:** 401 `{ error: 'Unauthorized' }`; 400 `{ error: 'Invalid limit' }`; 409 `{ error: 'missing_requirements', missing: [...] }` when `forum.read` fails; 503 `{ error: 'Messages are unavailable' }` when `listPlaces` throws (`messages.places.failed`). - **Used by:** App map of live forum pins. - **Auth:** `Authorization: Bearer` session. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 8efa8436f..9d2d1de00 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -2118,7 +2118,13 @@ describe('InMemoryMessageStore', () => { place: { lat: 5, lng: 6, label: 'hid' }, }, { ...LATE, id: 'za', createdAt: same, place: { lat: 7, lng: 8, label: 'A' } }, - { ...LATE, id: 'zb', createdAt: same, place: { lat: 9, lng: 10, label: 'B' } }, + { + ...LATE, + id: 'zb', + text: 'Cafe\n\n#21GiftsShop', + createdAt: same, + place: { lat: 9, lng: 10, label: 'B' }, + }, ]); const listed = await store.listPlaces(10); expect(listed.map((row) => row.id)).toEqual(['zb', 'za', 'a']); @@ -2130,7 +2136,9 @@ describe('InMemoryMessageStore', () => { lng: 10, label: 'B', accountId: 'acc', + shop: true, }); + expect(listed[1]?.shop).toBe(false); expect((await store.listPlaces(1)).map((row) => row.id)).toEqual(['zb']); }); @@ -4941,18 +4949,19 @@ describe('PostgresMessageStore', () => { place_lat: '47.3', place_lng: '8.5', place_label: 'Zürich', + shop: true, }, ]; const listed = await new PostgresMessageStore(sql).listPlaces(10); expect(sql.queries[0]?.text).toMatch( - /SELECT id, name, created_at, place_lat, place_lng, place_label/, + /SELECT id, name, created_at, place_lat, place_lng, place_label, account_id,\s+\(text ~\* \$2\) AS shop/, ); expect(sql.queries[0]?.text).toMatch( /WHERE parent_id IS NULL AND deleted_at IS NULL\s+AND place_lat IS NOT NULL AND place_lng IS NOT NULL/, ); expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC\s+LIMIT \$1/); expect(sql.queries[0]?.text).not.toMatch(/photo/); - expect(sql.queries[0]?.params).toEqual([10]); + expect(sql.queries[0]?.params).toEqual([10, '#21giftsshop([^a-z0-9_]|$)']); expect(listed).toEqual([ { id: 'pin-1', @@ -4961,6 +4970,8 @@ describe('PostgresMessageStore', () => { lat: 47.3, lng: 8.5, label: 'Zürich', + accountId: undefined, + shop: true, }, ]); }); @@ -4982,6 +4993,7 @@ describe('PostgresMessageStore', () => { expect(listed[0]?.lat).toBe(1); expect(listed[0]?.lng).toBe(2); expect(listed[0]?.label).toBeNull(); + expect(listed[0]?.shop).toBe(false); }); it('listPlaces maps an undefined place_label to null', async () => { diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 106c319a9..7134e76c7 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -5665,7 +5665,7 @@ describe('GET /messages/places', () => { id: 'zb', accountId: 'acc', name: 'Ada', - text: 'zb', + text: 'Cafe\n\n#21GiftsShop', createdAt: same, hasPhoto: false, hasVideo: false, @@ -5701,7 +5701,9 @@ describe('GET /messages/places', () => { lng: 10, label: 'B', accountId: 'acc', + shop: true, }); + expect(body.places[1]).toMatchObject({ shop: false }); expect(body.places[1]?.label).toBe('A'); expect(body.places[2]?.label).toBeNull(); expect(body.places[2]?.createdAt).toBe(new Date('2026-08-01T00:00:00.000Z').toISOString()); diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 6d7623291..8b22b04fb 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -412,6 +412,8 @@ export interface MessageStore { lng: number; label: string | null; accountId: string | null; + /** True when the note text contains the shop tag. */ + shop: boolean; }> >; @@ -2339,6 +2341,7 @@ export class InMemoryMessageStore implements MessageStore { lng: number; label: string | null; accountId: string | null; + shop: boolean; }> > { const pinned = this.#rows.filter((row) => { @@ -2369,6 +2372,7 @@ export class InMemoryMessageStore implements MessageStore { lng: place.lng, label: place.label, accountId: row.accountId, + shop: textHasHashtagToken(row.text, '21GiftsShop'), }; }), ); @@ -4402,6 +4406,7 @@ export class PostgresMessageStore implements MessageStore { lng: number; label: string | null; accountId: string | null; + shop: boolean; }> > { const rows = await this.#sql.query<{ @@ -4412,14 +4417,16 @@ export class PostgresMessageStore implements MessageStore { place_lng: string | number | null; place_label: string | null; account_id: string | null; + shop: boolean | null; }>( - `SELECT id, name, created_at, place_lat, place_lng, place_label, account_id + `SELECT id, name, created_at, place_lat, place_lng, place_label, account_id, + (text ~* $2) AS shop FROM message WHERE parent_id IS NULL AND deleted_at IS NULL AND place_lat IS NOT NULL AND place_lng IS NOT NULL ORDER BY created_at DESC, id DESC LIMIT $1`, - [limit], + [limit, posixHashtagTokenPattern('21GiftsShop')], ); return rows.map((row) => ({ id: row.id, @@ -4429,6 +4436,7 @@ export class PostgresMessageStore implements MessageStore { lng: Number(row.place_lng), label: row.place_label === null || row.place_label === undefined ? null : row.place_label, accountId: row.account_id, + shop: row.shop === true, })); } diff --git a/src/routes/messages.ts b/src/routes/messages.ts index fa9a653a8..164f226a7 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -2422,6 +2422,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { lat: row.lat, lng: row.lng, label: row.label, + shop: row.shop, ...(row.accountId === null ? {} : { accountId: row.accountId }), })), }, From aaabbb0bcf22197ea6e4799bf4bd62cbfebbb31a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:01:05 +0200 Subject: [PATCH 05/19] Keep shop-edit history text as stored text A text value that happens to be JSON stays a string. The contract lists the new shop routes. --- CONTRIBUTING.md | 2 +- SPEC.md | 23 +++++++++++++++++++---- docs/schema/message.sql | 13 +++++++++++++ src/__tests__/lib/message-store.test.ts | 14 ++++++++++++++ src/lib/message-store.ts | 17 ++++++++++++----- src/routes/messages.ts | 4 ++-- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02cba2968..0e4b26941 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -310,7 +310,7 @@ api/ │ ├── gift.sql # gift table used by GET /gifts and GET /gifts/stats │ ├── btc_usd_daily.sql # UTC daily BTC-USD closes for historical USD stats │ ├── usd_fiat_daily.sql # UTC daily USD→CHF/EUR/PHP ECB crosses -│ ├── message.sql # message + nostr_zap_receipt + nostr_zapper + nostr_blocked_pubkey + nostr_zap_payment + message_invoice + message_translation + nostr_zap_ingest + message_extra_photo + message_repayment +│ ├── message.sql # message + nostr_zap_receipt + nostr_zapper + nostr_blocked_pubkey + nostr_zap_payment + message_invoice + message_translation + nostr_zap_ingest + message_extra_photo + message_repayment + message_edit │ ├── contact.sql # private contact mailbox table for POST /contact │ ├── conversation.sql # PN threads + messages + conversation_read (per-viewer last-read; member/platform/Damus; closed moderator_group singleton, HTTP-only / skipped Nostr) + conversation_message.photo / photo_content_type + conversation_message_extra_photo + conversation_message_translation │ ├── api_log.sql # HTTP audit log (who called which path) diff --git a/SPEC.md b/SPEC.md index cf51876d2..fddd015ae 100644 --- a/SPEC.md +++ b/SPEC.md @@ -155,6 +155,9 @@ Public base URLs used in examples: | DELETE | `/messages/:id` | Bearer (moderator+) | Soft-hide note + direct replies; retract in-app notifications; external target also blocks that pubkey | | PATCH | `/messages/:id/place` | Bearer (moderator+) | Set, replace, or clear the map pin on a live top-level shop note | | PATCH | `/messages/:id/shop-account` | Bearer (moderator+) | Set, replace, or clear the 21.gifts account on a live top-level shop note | +| PATCH | `/messages/:id/text` | Bearer (moderator+) | Replace the text of a live top-level shop note; the shop tag stays | +| PATCH | `/messages/:id/photos` | Bearer (moderator+) | Replace the stills of a live top-level shop note; a video stays; no edit history | +| GET | `/messages/:id/edits` | Bearer (moderator+) | Staff edit history of a shop note, newest first | | POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | | GET | `/messages/:id/repayment` | none | Public credit ledger: who gave, and each repayment share | | POST | `/messages/:id/repayment` | Bearer | Author pays the next giver share from their own wallet. A repeat for that unpaid share returns the outstanding invoice. | @@ -3675,8 +3678,8 @@ Bearer session required. After auth, the same `forum.read` gate as `GET /messages` (401 without a session; 409 `missing_requirements` when rules are missing). Query `limit` is an integer 1..1000 (default **1000**); otherwise **400** `{ "error": "Invalid limit" }`. Body -`{ "places": [{ "id", "name", "createdAt", "lat", "lng", "label", "accountId?" }] }`. -`accountId` is set for a 21gifts author and omitted for an external pin. +`{ "places": [{ "id", "name", "createdAt", "lat", "lng", "label", "shop", "accountId?" }] }`. +`shop` is true when the note text contains the shop tag. `accountId` is set for a 21gifts author and omitted for an external pin. `createdAt` is ISO-8601. Newest first (`created_at` desc, `id` desc). Only live top-level rows with both coordinates. Replies and hidden notes are excluded. @@ -4296,7 +4299,7 @@ two ids per store. Public single-note fetch. Live rows need **no Bearer.** `:id` is a UUID. Registered **after** photo, video, `GET /messages/:id/replies`, -`DELETE /messages/:id`, `PATCH /messages/:id/place`, `PATCH /messages/:id/shop-account`, `GET /messages/stats`, `GET /messages/hidden`, and +`DELETE /messages/:id`, `PATCH /messages/:id/place`, `PATCH /messages/:id/shop-account`, `PATCH /messages/:id/text`, `PATCH /messages/:id/photos`, `GET /messages/:id/edits`, `GET /messages/stats`, `GET /messages/hidden`, and `GET /messages/places` so those paths are not captured as `:id`. A live GET returns the public message JSON (`sats`, optional `goalSats` on a top-level note @@ -4504,7 +4507,19 @@ Success → **200** live public message JSON (optional `shopAccount` `{ id, username, name }`, omitted when cleared, reply count, no hide stamps). Logs `messages.shop_account.updated` with `messageId`, `accountId`, and `role` only. Text, place, and publish state are -unchanged. The write stores only `shop_account_id`. +unchanged. The write stores only `shop_account_id`. A real change appends `message_edit`. An unchanged account does not. + +### `PATCH /messages/:id/text` + +Staff replacement of the body of a live top-level shop note (`#21GiftsShop`). Bearer session required. Live role must be at least `moderator`. `:id` must match `MESSAGE_ID_RE` or the response is **404**. Body is JSON; a non-object or a missing string `text` is **400** `{ "error": "Invalid body" }`. The shop tag is kept or restored. An unchanged body is **200** without `message_edit`. A real change appends `message_edit` and is **200** public message JSON. A reply is **400** `{ "error": "A reply cannot be edited" }`. A non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. Missing or hidden row → **404**. Store throw → **503** `{ "error": "Messages are unavailable" }`. + +### `PATCH /messages/:id/photos` + +Staff replacement of the stills on a live top-level shop note. Bearer session required. Live role must be at least `moderator`. Body `{ "photos": [{ "contentType", "data", "takenAt?" }] }` with at most 10 items. An empty list clears stills. A video on the note stays. This write does not append `message_edit`. Success is **200** public message JSON. A reply is **400** `{ "error": "A reply cannot be edited" }`. A non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. A bad photo is **400** `{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }`. Store throw → **503**. + +### `GET /messages/:id/edits` + +Staff history for a top-level shop note, newest first, including a hidden shop note. Bearer session required. Live role must be at least `moderator`. GET is not a Sunday write. Success is **200** `{ "edits": [...] }`. A reply or a non-shop note is **404**. Public message JSON does not include `edits`. ### `GET /messages/hidden` diff --git a/docs/schema/message.sql b/docs/schema/message.sql index 952e6a5fd..50b5aef62 100644 --- a/docs/schema/message.sql +++ b/docs/schema/message.sql @@ -311,3 +311,16 @@ BEGIN CHECK (goal_term_days IS NULL OR (goal_repayable IS TRUE AND goal_term_days BETWEEN 1 AND 3650)); END $message_goal_term_days$; +-- Staff history of a shop note. Text, place, and shop account only. +CREATE TABLE IF NOT EXISTS message_edit ( + id uuid PRIMARY KEY, + message_id uuid NOT NULL REFERENCES message (id) ON DELETE CASCADE, + actor_id uuid NOT NULL, + created_at timestamptz NOT NULL, + field text NOT NULL, + before jsonb NOT NULL, + after jsonb NOT NULL, + CONSTRAINT message_edit_field_chk CHECK (field IN ('text', 'place', 'shop_account')) +); +CREATE INDEX IF NOT EXISTS message_edit_message_created_idx + ON message_edit (message_id, created_at DESC, id DESC); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 9d2d1de00..2b255da1e 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -6506,6 +6506,15 @@ describe('PostgresMessageStore', () => { before: 'plain', after: 4, }, + { + id: 'e5', + message_id: 'm1', + actor_id: 'acc', + created_at: at, + field: 'text', + before: '["#21GiftsShop"]', + after: '"kept"', + }, ]; const listed = await new PostgresMessageStore(sql).listEdits('m1'); expect(sql.queries[0]?.text).toMatch(/FROM message_edit/); @@ -6525,6 +6534,11 @@ describe('PostgresMessageStore', () => { after: 'not-json', }); expect(listed[2]).toMatchObject({ field: 'text', before: 'plain', after: 4 }); + expect(listed[3]).toMatchObject({ + field: 'text', + before: '["#21GiftsShop"]', + after: 'kept', + }); const place = listed[1]?.before as { label: string }; place.label = 'mutated'; expect(sql.nextRows[1]).toMatchObject({ before: { lat: 1, lng: 2, label: 'Stall' } }); diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 8b22b04fb..2ea872245 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -1908,13 +1908,20 @@ function copyEdit(row: MessageEditRow): MessageEditRow { }; } -/** Driver jsonb: parse a JSON string, otherwise keep the value (including `null`). */ -function readStoredEditJson(value: unknown): unknown { +/** + * Driver jsonb: parse a JSON string, otherwise keep the value (including `null`). + * A text value that parses to something other than a string stays the stored text. + */ +function readStoredEditJson(value: unknown, field: MessageEditRow['field']): unknown { if (typeof value !== 'string') { return cloneEditValue(value); } try { - return cloneEditValue(JSON.parse(value)); + const parsed: unknown = JSON.parse(value); + if (field === 'text' && typeof parsed !== 'string') { + return value; + } + return cloneEditValue(parsed); } catch { return value; } @@ -5024,8 +5031,8 @@ export class PostgresMessageStore implements MessageStore { actorId: row.actor_id, createdAt: new Date(row.created_at), field: mapEditField(row.field), - before: readStoredEditJson(row.before), - after: readStoredEditJson(row.after), + before: readStoredEditJson(row.before, mapEditField(row.field)), + after: readStoredEditJson(row.after, mapEditField(row.field)), })); } diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 164f226a7..a7e7ccf6e 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -1344,8 +1344,8 @@ const translateBody = z.object({ * `GET /places`, `GET /:id/photo` plus `.jpg` / `.jpeg` / `.png` / `.webp`, * `GET /:id/video.mp4|.webm|.mov`, public `GET /:id/replies` (`accountId` when * the stored author id is non-null), `DELETE /:id`, staff `PATCH /:id/place`, - * staff `PATCH /:id/shop-account`, staff `PATCH /:id/text`, and staff - * `GET /:id/edits` (moderator session; no `forum.read`), + * staff `PATCH /:id/shop-account`, staff `PATCH /:id/text`, staff + * `PATCH /:id/photos`, and staff `GET /:id/edits` (moderator session; no `forum.read`), * staff `GET /hidden` (moderator session; no `forum.read`), public * `GET /:id` (optional `?sinceSats=`), and * `POST /:id/invoice`, `POST /:id/translate`, and public `GET /stats`. From ed780634bf6e0d0f855128086b783e11f89aec0e Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:54 +0200 Subject: [PATCH 06/19] Name the shop text and photo writes in the Sunday list The schema note no longer says the unwrap block is last. --- SPEC.md | 2 +- docs/handbook/functions.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index fddd015ae..d214ecd7d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3737,7 +3737,7 @@ Post to the public member forum. Bearer session required. JSON body (not multipart) with text and/or one photo, optional `photos` (array, max 10, each `{ contentType, data, takenAt? }` same shape as singular `photo`), an optional parent UUID, and optional `goalSats` (positive integer 1..10_000_000 -on a top-level note only). Optional `takenAt` is `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` +on a top-level note only). Optional `shopUsername` on a shop note stores that account at create and does not write `message_edit`. A bad username is 400 `Username is not valid`. An unknown username is 404 `No account with that username`. A non-shop note with a username is 400 `Only a shop note can set a shop account`. Blank, null, or `@` alone stores nothing. Optional `takenAt` is `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1. `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not return 400: diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 6aeaed572..b55b479e7 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -261,7 +261,7 @@ ## Function: migrateMessageSchema -- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and immediately `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`). Later, immediately after `CREATE TABLE message_extra_photo`, an additive `goal_sats bigint` (nullable; SQL null means no ask), then `photo_taken_at text` and `video_taken_at text` on `message` (civil time, not timestamptz) and `photo_taken_at text` on `message_extra_photo`, then `place_lat double precision`, `place_lng double precision`, and `place_label text`, then nullable `goal_currency` (null or BTC/USD/CHF/EUR/PHP), `goal_amount numeric(20, 8)`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php numeric(20, 2)` (null when there is no currency ask; not backfilled), then nullable `goal_repayable boolean`, nullable `goal_term_days integer`, and nullable `goal_funded_at timestamptz`. The `recorded_at` column on `nostr_zap_receipt` is added with the other receipt columns, but an already full repayable ask is stamped `goal_funded_at = now()` immediately after that column is added, and the backfill that sets a null `recorded_at` to `COALESCE(message.goal_funded_at, now())` runs only after that stamp, and then `message_repayment` is created, with checks `goal_repayable IS NOT TRUE OR (parent_id IS NULL AND goal_sats IS NOT NULL)` and `goal_term_days IS NULL OR (goal_repayable IS TRUE AND goal_term_days BETWEEN 1 AND 3650)`, then the feed indexes `message_feed_created_idx` and `message_feed_popular_idx`, then `TRANSLATION_SCHEMA_SQL` (`CREATE TABLE IF NOT EXISTS message_translation`), and only then the last unwrap `DO $unwrap$`. It next creates `nostr_zap_receipt`, additively adds `payer_account_id`, `payer_pubkey`, `zap_request_id`, `gift_reply_id`, and `comment`, and creates partial unique indexes including `nostr_zap_receipt_request_uidx` on non-null `zap_request_id`; this is followed by `nostr_zapper`, `nostr_blocked_pubkey`, and `nostr_zap_payment`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb`, `conversation_id uuid`, `conversation_message_id uuid`, `fiat_pinned boolean NOT NULL DEFAULT false`, and `fiat_usd`, `fiat_chf`, `fiat_eur`, `fiat_php` numeric(20, 2), and their `created_at`/`message_id` and `receipt_id` indexes. After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`, then soft-hide columns `deleted_at timestamptz` and `deleted_by uuid`. Then additive `content_fp text`, `DROP INDEX IF EXISTS` on `message_live_top_content_fp_uidx` and `message_live_reply_content_fp_uidx` before the photo-only backfill via `digest(photo, 'sha256')` (`video_content_type` IS NULL), salt of extra live duplicates (`content_fp || ':' || message.id`), and recreation of those partial unique indexes (live rows with non-null account + fingerprint). The partial index `message_nostr_event_unrepaired_idx` supports the boot repair's predicate so a converged table can be confirmed without a sequential scan. On every boot, the array also runs an idempotent repair unwrapping `nostr_event` values stored as jsonb string scalars (`jsonb_typeof(nostr_event) = 'string'`), which matches no rows once complete. It is skipped while the `db_change` audit trigger is not attached and retried on the next boot; a row whose value cannot be parsed is skipped with a warning instead of failing the migration. Successfully repaired rows have `nostr_attempts` cleared for a fresh repair budget. The unwrap `DO $unwrap$` block remains last in `MESSAGE_SCHEMA_SQL` only (not mirrored in `docs/schema/message.sql`). It also adds nullable `fiat_usd`, `fiat_chf`, `fiat_eur`, and `fiat_php` on `message` and `nostr_zap_ingest`, then backfills rows with a positive sat amount and `fiat_usd IS NULL` from that row's UTC-day close. A missing rate leaves the row null. Rows that already have `fiat_usd` are not rewritten. +- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and immediately `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`). Later, immediately after `CREATE TABLE message_extra_photo`, an additive `goal_sats bigint` (nullable; SQL null means no ask), then `photo_taken_at text` and `video_taken_at text` on `message` (civil time, not timestamptz) and `photo_taken_at text` on `message_extra_photo`, then `place_lat double precision`, `place_lng double precision`, and `place_label text`, then nullable `goal_currency` (null or BTC/USD/CHF/EUR/PHP), `goal_amount numeric(20, 8)`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php numeric(20, 2)` (null when there is no currency ask; not backfilled), then nullable `goal_repayable boolean`, nullable `goal_term_days integer`, and nullable `goal_funded_at timestamptz`. The `recorded_at` column on `nostr_zap_receipt` is added with the other receipt columns, but an already full repayable ask is stamped `goal_funded_at = now()` immediately after that column is added, and the backfill that sets a null `recorded_at` to `COALESCE(message.goal_funded_at, now())` runs only after that stamp, and then `message_repayment` is created, with checks `goal_repayable IS NOT TRUE OR (parent_id IS NULL AND goal_sats IS NOT NULL)` and `goal_term_days IS NULL OR (goal_repayable IS TRUE AND goal_term_days BETWEEN 1 AND 3650)`, then the feed indexes `message_feed_created_idx` and `message_feed_popular_idx`, then `TRANSLATION_SCHEMA_SQL` (`CREATE TABLE IF NOT EXISTS message_translation`), and only then the last unwrap `DO $unwrap$`. It next creates `nostr_zap_receipt`, additively adds `payer_account_id`, `payer_pubkey`, `zap_request_id`, `gift_reply_id`, and `comment`, and creates partial unique indexes including `nostr_zap_receipt_request_uidx` on non-null `zap_request_id`; this is followed by `nostr_zapper`, `nostr_blocked_pubkey`, and `nostr_zap_payment`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb`, `conversation_id uuid`, `conversation_message_id uuid`, `fiat_pinned boolean NOT NULL DEFAULT false`, and `fiat_usd`, `fiat_chf`, `fiat_eur`, `fiat_php` numeric(20, 2), and their `created_at`/`message_id` and `receipt_id` indexes. After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`, then soft-hide columns `deleted_at timestamptz` and `deleted_by uuid`. Then additive `content_fp text`, `DROP INDEX IF EXISTS` on `message_live_top_content_fp_uidx` and `message_live_reply_content_fp_uidx` before the photo-only backfill via `digest(photo, 'sha256')` (`video_content_type` IS NULL), salt of extra live duplicates (`content_fp || ':' || message.id`), and recreation of those partial unique indexes (live rows with non-null account + fingerprint). The partial index `message_nostr_event_unrepaired_idx` supports the boot repair's predicate so a converged table can be confirmed without a sequential scan. On every boot, the array also runs an idempotent repair unwrapping `nostr_event` values stored as jsonb string scalars (`jsonb_typeof(nostr_event) = 'string'`), which matches no rows once complete. It is skipped while the `db_change` audit trigger is not attached and retried on the next boot; a row whose value cannot be parsed is skipped with a warning instead of failing the migration. Successfully repaired rows have `nostr_attempts` cleared for a fresh repair budget. After the unwrap `DO $unwrap$`, `CREATE TABLE message_edit` and its index are last in `MESSAGE_SCHEMA_SQL` (not mirrored in `docs/schema/message.sql`). It also adds nullable `fiat_usd`, `fiat_chf`, `fiat_eur`, and `fiat_php` on `message` and `nostr_zap_ingest`, then backfills rows with a positive sat amount and `fiat_usd IS NULL` from that row's UTC-day close. A missing rate leaves the row null. Rows that already have `fiat_usd` are not rewritten. - **Payment claims table:** Also creates `nostr_zap_payment` (`payment_hash` primary key, `receipt_event_id`, `created_at`) without a foreign key to `message`; it is the durable tombstone that dedupes zap credits by payment hash. Like every public table it is attached to the `db_change` row trigger by `migrateDbChangeSchema`, which boots after this migration. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent SQL execute; `docs/schema/message.sql` mirrors the DDL and documents the boot repair statement by comment (the `DO $unwrap$` block lives only in `MESSAGE_SCHEMA_SQL`). @@ -1115,7 +1115,7 @@ ## Function: sundayRest -- **Purpose:** Hono middleware. A trimmed `Time-Zone` header naming the device IANA zone makes the listed public writes return 403 `{ error: 'SUNDAY_REST' }` while that zone is in Sunday on the injected clock: `POST /messages`, `DELETE /messages/:id` (one segment), `PATCH /messages/:id/place`, `PATCH /messages/:id/shop-account`, `POST /funding/apply|trial|admit|reject`, `POST /me/name|username|location`, `PUT /me/about`, `PUT /pictures/me`, `PUT /banners/me`, Lightning Address link/unlink/verify, the trust verify/moderator writes, and `POST /messages/:id/invoice` (a zap on a forum note) and `POST /messages/:id/repayment` (the author's next giver share). `GET /messages/:id/repayment` stays open. `GET /conversations/moderator-group` is refused the same way, and opening, reading, or sending in a moderator-group thread is refused once that thread is known. Ordinary private messages, contact, pay links, the till, and conversation invoices are not refused. Missing or invalid zone does not refuse. Never 503; does not pause `/healthz`, boot, or workers. +- **Purpose:** Hono middleware. A trimmed `Time-Zone` header naming the device IANA zone makes the listed public writes return 403 `{ error: 'SUNDAY_REST' }` while that zone is in Sunday on the injected clock: `POST /messages`, `DELETE /messages/:id` (one segment), `PATCH /messages/:id/place`, `PATCH /messages/:id/shop-account`, `PATCH /messages/:id/text`, `PATCH /messages/:id/photos`, `POST /funding/apply|trial|admit|reject`, `POST /me/name|username|location`, `PUT /me/about`, `PUT /pictures/me`, `PUT /banners/me`, Lightning Address link/unlink/verify, the trust verify/moderator writes, and `POST /messages/:id/invoice` (a zap on a forum note) and `POST /messages/:id/repayment` (the author's next giver share). `GET /messages/:id/repayment` stays open. `GET /conversations/moderator-group` is refused the same way, and opening, reading, or sending in a moderator-group thread is refused once that thread is known. Ordinary private messages, contact, pay links, the till, and conversation invoices are not refused. Missing or invalid zone does not refuse. Never 503; does not pause `/healthz`, boot, or workers. - **Inputs:** `now` epoch-ms callback (the same clock `createApp` already uses). - **Returns / side effects:** Middleware. JSON 403 or `next()`. - **Used by:** `createApp`. @@ -1123,7 +1123,7 @@ ## Function: messagesRoutes - **Purpose:** Hono sub-app for the public member forum. Public `POST /:id/translate` (`{ target }`) loads the stored `message.text`, returns a `message_translation` hit when `source_sha256` matches, otherwise one DeepL POST coalesced per (id, locale, hash), then upserts (first writer for a hash wins). Empty text 400. Same visibility as `GET /:id`. `{ translatedText, cached }`. 503 when DeepL is unset, 502 when DeepL fails. Public `GET /stats` (no session) counts living notes and replies together as `postCount`, with `postsOverTime` filled through today UTC (gap days are 0; soft-hidden rows are omitted; `posts.stats.failed` → 503). After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + username + Lightning Address), `GET /compose-target` (`forum.post`), and `POST /:id/invoice` (`forum.pay` → payer rules only). Public `GET /:id/repayment` needs no session and lists who gave and each repayment. Bearer `POST /:id/repayment` (`forum.pay`) issues the next giver share. Bearer `GET /` lists **live top-level** notes via `listFeed` (query `mode`/`limit`/`cursor`/optional `hashtag` (name without `#`; token match on `text`), default cap 200, optional `nextCursor` when the page is full; `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, live `replyCount` of children with an account or a recorded zapper pubkey); soft-hidden rows are omitted; missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video after parse/normalize/decode — JSON `photos` max 10, non-empty wins over singular `photo`, `photos.length > 10` is 400 `{ error: 'At most 10 photos' }`; optional `goalSats` alone is a legacy whole-sat ask (1..10_000_000) on a top-level note (JSON number or multipart digits; omitted/null/empty = no goal); alternatively both `goalCurrency` (`BTC`/`USD`/`CHF`/`EUR`/`PHP`) and `goalAmount` (one canonical decimal) and not `goalSats` — half a pair or both styles is 400 `{ error: 'Send either goalSats or both goalCurrency and goalAmount' }`; any goal field on a reply is 400 `{ error: 'A reply cannot ask for a goal' }`; `goalRepayable` other than JSON `true` or multipart `"true"` is 400 `{ error: 'Ask obligation must be true' }` (JSON `""` is rejected; a multipart empty field is absent); `goalRepayable` true without an ask is 400 `{ error: 'A repayment obligation needs an ask' }`; `goalTermDays` outside 1..3650 is 400 `{ error: 'Ask term must be a whole number of days from 1 to 3650' }`; a term without `goalRepayable: true` is 400 `{ error: 'A repayment term needs a repayable ask' }`; `goalRepayable` true without a term is 400 `{ error: 'A repayable ask needs a term in days' }`; `BTC` stores that whole-sat count as `goal_sats` and freezes the four fiat snapshots (`null` when there is no gift-day); fiat stores the typed amount, freezes `goal_sats` from the gift-day proportion, and the four snapshots; no usable rate or sats outside 1..10_000_000 is 400 `{ error: 'Ask amount is unavailable' }`; a thrown `goalRateDay` is 503 `{ error: 'Messages are unavailable' }` for a fiat ask, and a BTC ask still stores the typed sats; public JSON omits the key when unset; optional `place` is `{ lat, lng, label }` on a top-level note (multipart `placeLat` / `placeLng` / `placeLabel`; both empty means no pin; exactly one coordinate is 400; a reply with a place is 400 `{ error: 'A reply cannot include a place' }`; public JSON omits `place` when unset); `GET /places` lists live pins (`forum.read`, limit 1–1000) and is registered before `GET /:id`; `GET /:id/photo/:file` serves extras 1–9; identical live media from the same account+parent with the same pin collapses to the existing row (200, no limiter, no second push); a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only still uses the 1/10s burst then inserts; unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /compose-target` then `POST /:id/invoice` on the platform profile note; `verified` stays unpaid-write exempt; soft-hidden `inReplyTo` parents are 404; public `GET /:id` stays open without a session and includes `accountId` whenever the stored author id is non-null, with or without a session, and omits it for an external author on a live row (a reply with null `accountId` is 200 with `via: 'nostr'` only when `authorPubkey` is set and recorded as a zapper (`isZapperPubkey`); otherwise (no `authorPubkey`, or one that is not yet a recorded zapper) it is 404; external top-level notes stay 200); optional `?sinceSats=` (non-negative integer) long-polls until `sats` is strictly greater (timeout still 200 with the current body; invalid value 400); unsigned/non-staff GET of a hidden row is still 404 `{ error: 'Not found' }` (no `deletedAt` in the 404 body); a founder/moderator Bearer (`roleAtLeast(..., 'moderator')`, no `forum.read`) is 200 public JSON plus `deletedAt` ISO, `deletedBy.{id,name,role}`, `payable: false`, and `accountId` for 21gifts authors (skip missing-video drop; do not long-poll `sinceSats` on hidden rows); a top-level note on GET `/:id`, live or staff-hidden, includes that `replyCount`, and a reply omits `replyCount`; live public JSON still omits hide stamps; public `GET /:id/replies` lists children with an account or a recorded zapper pubkey (live replies include `accountId` whenever the stored author id is non-null, with or without a session; rows with neither identity are skipped); unsigned/non-staff 404s hidden/missing parents; staff Bearer is 200 `{ messages }` from `listReplies(id, limit, true)` including hidden attributed children with hide stamps and `payable: false` (live children stay live serialize); a child whose author lookup or serialize throws (invalid `createdAt`, author lookup) is omitted and siblings still 200 `{ messages }`; 503 `messages.replies.failed` only for `getById` / `listReplies` throws and for `dropMissingVideoRow` store/I/O (non-ENOENT video I/O or `deleteById`); missing-file drop (`null` → omit) still 200; photo/video byte routes 404 hidden ids for public/Damus (no staff bearer); founder/moderator Bearer serves hidden-row bytes with `Cache-Control: private, no-store` and `Vary: Authorization`; staff `DELETE /:id` soft-hides via `markDeleted` (moderator → 204; basis/verified → 403) then best-effort `retractHiddenForumNotes` when `nostrPublisher` and `nostrKek` are set (NIP-09 + optional Cloudflare purge; failure still 204) and best-effort retracts in-app notifications whose `parentId` or `replyId` is the note or a direct child (`listChildIds` + `deleteByMessageIds`; failure logs `messages.delete.notifications_failed` and still 204, never 503); staff `GET /hidden` lists soft-hidden notes newest-hidden-first (moderator session, not `DEBUG_TOKEN`, no `forum.read`; 200 `{ messages }` via `listHidden` / `serializeHiddenMessage`; logs `messages.hidden.listed` with `count` only); invoice returns `{ pr, amountSats }` only for NIP-57 invoices and 404s soft-hidden notes (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `notificationStore` fans out via `notifyForumPost` / `notifyForumReply` to every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (no inbox copy; missing `pushStore` still writes in-app rows; Web Push only to bell subscribers, same filter). Optional `spendPing`: after a **new** top-level persist the route POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, POST still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including About me, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independently of `eligibleToday` and of whether the new row has media (`spend.ping.failed` on throw). Replies, and any role other than `verified`, skip welcome. Replies and media replays skip. Omitted `spendPing` skips. Notification or push failure still returns 200. -- **Sunday rest:** A `Time-Zone` header naming the device IANA zone makes `POST /`, staff `DELETE /:id`, `PATCH /:id/place`, `PATCH /:id/shop-account`, `POST /:id/invoice`, and `POST /:id/repayment` return 403 `{ error: 'SUNDAY_REST' }` while that zone is in Sunday. `GET /:id/repayment` stays open. Pay links, the till, and private messages are not refused. Missing or invalid zone does not refuse. +- **Sunday rest:** A `Time-Zone` header naming the device IANA zone makes `POST /`, staff `DELETE /:id`, `PATCH /:id/place`, `PATCH /:id/shop-account`, `PATCH /:id/text`, `PATCH /:id/photos`, `POST /:id/invoice`, and `POST /:id/repayment` return 403 `{ error: 'SUNDAY_REST' }` while that zone is in Sunday. `GET /:id/repayment` stays open. Pay links, the till, and private messages are not refused. Missing or invalid zone does not refuse. - **External DELETE cascade:** When the target has `accountId === null` and a recorded `authorPubkey`, a successful `markDeleted` is followed by the single atomic `blockPubkeyAndHideRows` operation, which records the block and hides that pubkey's other live external rows. It logs `messages.external.blocked` with `{ messageId, hidden: cascaded + 1 }`; deleting a member row does not trigger this author-wide cascade. - **Inputs:** `MessagesRouteDeps`: message `store`, shared `authStore`, `now`, optional `nostrKek`, optional `nostrPublisher`, optional `env` (relays / `PUBLIC_BASE_URL` / Cloudflare / DeepL; default `{}` on the retract path), optional `translationStore` (default empty `InMemoryTranslationStore`), `fetchImpl`, `postLimiter`, `invoiceLimiter`, optional `pushStore`, optional `spendPing`, optional `mapPush` (omitted → no map POST; set → the first shop pin is posted once to `POST /map/places`, a failure logs `ocp.place.failed`, and the forum response stays 200), optional `fundingStore` (default empty `InMemoryFundingStore`), optional `notificationStore`, optional `conversationStore`, optional `waitSatsSleep` (test inject; default `defaultWaitSatsSleep`), optional `waitSatsTimeoutMs` (test inject; default `WAIT_SATS_TIMEOUT_MS`), optional `waitSatsPollMs` (test inject; default `WAIT_SATS_POLL_MS`), optional `goalRateDay` (`createApp` passes `bindGoalRateDay`; when omitted a fiat ask is 400 `Ask amount is unavailable`, a throw is 503 for a fiat ask, and a BTC ask still stores the typed sats when the loader throws or returns null). From 853d6d90f1e28c5297ef8f8552b57885bf72427a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:46:24 +0200 Subject: [PATCH 07/19] Mirror the shop edit table in the schema handbook --- docs/handbook/functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index b55b479e7..4bd27e397 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -261,7 +261,7 @@ ## Function: migrateMessageSchema -- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and immediately `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`). Later, immediately after `CREATE TABLE message_extra_photo`, an additive `goal_sats bigint` (nullable; SQL null means no ask), then `photo_taken_at text` and `video_taken_at text` on `message` (civil time, not timestamptz) and `photo_taken_at text` on `message_extra_photo`, then `place_lat double precision`, `place_lng double precision`, and `place_label text`, then nullable `goal_currency` (null or BTC/USD/CHF/EUR/PHP), `goal_amount numeric(20, 8)`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php numeric(20, 2)` (null when there is no currency ask; not backfilled), then nullable `goal_repayable boolean`, nullable `goal_term_days integer`, and nullable `goal_funded_at timestamptz`. The `recorded_at` column on `nostr_zap_receipt` is added with the other receipt columns, but an already full repayable ask is stamped `goal_funded_at = now()` immediately after that column is added, and the backfill that sets a null `recorded_at` to `COALESCE(message.goal_funded_at, now())` runs only after that stamp, and then `message_repayment` is created, with checks `goal_repayable IS NOT TRUE OR (parent_id IS NULL AND goal_sats IS NOT NULL)` and `goal_term_days IS NULL OR (goal_repayable IS TRUE AND goal_term_days BETWEEN 1 AND 3650)`, then the feed indexes `message_feed_created_idx` and `message_feed_popular_idx`, then `TRANSLATION_SCHEMA_SQL` (`CREATE TABLE IF NOT EXISTS message_translation`), and only then the last unwrap `DO $unwrap$`. It next creates `nostr_zap_receipt`, additively adds `payer_account_id`, `payer_pubkey`, `zap_request_id`, `gift_reply_id`, and `comment`, and creates partial unique indexes including `nostr_zap_receipt_request_uidx` on non-null `zap_request_id`; this is followed by `nostr_zapper`, `nostr_blocked_pubkey`, and `nostr_zap_payment`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb`, `conversation_id uuid`, `conversation_message_id uuid`, `fiat_pinned boolean NOT NULL DEFAULT false`, and `fiat_usd`, `fiat_chf`, `fiat_eur`, `fiat_php` numeric(20, 2), and their `created_at`/`message_id` and `receipt_id` indexes. After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`, then soft-hide columns `deleted_at timestamptz` and `deleted_by uuid`. Then additive `content_fp text`, `DROP INDEX IF EXISTS` on `message_live_top_content_fp_uidx` and `message_live_reply_content_fp_uidx` before the photo-only backfill via `digest(photo, 'sha256')` (`video_content_type` IS NULL), salt of extra live duplicates (`content_fp || ':' || message.id`), and recreation of those partial unique indexes (live rows with non-null account + fingerprint). The partial index `message_nostr_event_unrepaired_idx` supports the boot repair's predicate so a converged table can be confirmed without a sequential scan. On every boot, the array also runs an idempotent repair unwrapping `nostr_event` values stored as jsonb string scalars (`jsonb_typeof(nostr_event) = 'string'`), which matches no rows once complete. It is skipped while the `db_change` audit trigger is not attached and retried on the next boot; a row whose value cannot be parsed is skipped with a warning instead of failing the migration. Successfully repaired rows have `nostr_attempts` cleared for a fresh repair budget. After the unwrap `DO $unwrap$`, `CREATE TABLE message_edit` and its index are last in `MESSAGE_SCHEMA_SQL` (not mirrored in `docs/schema/message.sql`). It also adds nullable `fiat_usd`, `fiat_chf`, `fiat_eur`, and `fiat_php` on `message` and `nostr_zap_ingest`, then backfills rows with a positive sat amount and `fiat_usd IS NULL` from that row's UTC-day close. A missing rate leaves the row null. Rows that already have `fiat_usd` are not rewritten. +- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and immediately `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`). Later, immediately after `CREATE TABLE message_extra_photo`, an additive `goal_sats bigint` (nullable; SQL null means no ask), then `photo_taken_at text` and `video_taken_at text` on `message` (civil time, not timestamptz) and `photo_taken_at text` on `message_extra_photo`, then `place_lat double precision`, `place_lng double precision`, and `place_label text`, then nullable `goal_currency` (null or BTC/USD/CHF/EUR/PHP), `goal_amount numeric(20, 8)`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php numeric(20, 2)` (null when there is no currency ask; not backfilled), then nullable `goal_repayable boolean`, nullable `goal_term_days integer`, and nullable `goal_funded_at timestamptz`. The `recorded_at` column on `nostr_zap_receipt` is added with the other receipt columns, but an already full repayable ask is stamped `goal_funded_at = now()` immediately after that column is added, and the backfill that sets a null `recorded_at` to `COALESCE(message.goal_funded_at, now())` runs only after that stamp, and then `message_repayment` is created, with checks `goal_repayable IS NOT TRUE OR (parent_id IS NULL AND goal_sats IS NOT NULL)` and `goal_term_days IS NULL OR (goal_repayable IS TRUE AND goal_term_days BETWEEN 1 AND 3650)`, then the feed indexes `message_feed_created_idx` and `message_feed_popular_idx`, then `TRANSLATION_SCHEMA_SQL` (`CREATE TABLE IF NOT EXISTS message_translation`), and only then the last unwrap `DO $unwrap$`. It next creates `nostr_zap_receipt`, additively adds `payer_account_id`, `payer_pubkey`, `zap_request_id`, `gift_reply_id`, and `comment`, and creates partial unique indexes including `nostr_zap_receipt_request_uidx` on non-null `zap_request_id`; this is followed by `nostr_zapper`, `nostr_blocked_pubkey`, and `nostr_zap_payment`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb`, `conversation_id uuid`, `conversation_message_id uuid`, `fiat_pinned boolean NOT NULL DEFAULT false`, and `fiat_usd`, `fiat_chf`, `fiat_eur`, `fiat_php` numeric(20, 2), and their `created_at`/`message_id` and `receipt_id` indexes. After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`, then soft-hide columns `deleted_at timestamptz` and `deleted_by uuid`. Then additive `content_fp text`, `DROP INDEX IF EXISTS` on `message_live_top_content_fp_uidx` and `message_live_reply_content_fp_uidx` before the photo-only backfill via `digest(photo, 'sha256')` (`video_content_type` IS NULL), salt of extra live duplicates (`content_fp || ':' || message.id`), and recreation of those partial unique indexes (live rows with non-null account + fingerprint). The partial index `message_nostr_event_unrepaired_idx` supports the boot repair's predicate so a converged table can be confirmed without a sequential scan. On every boot, the array also runs an idempotent repair unwrapping `nostr_event` values stored as jsonb string scalars (`jsonb_typeof(nostr_event) = 'string'`), which matches no rows once complete. It is skipped while the `db_change` audit trigger is not attached and retried on the next boot; a row whose value cannot be parsed is skipped with a warning instead of failing the migration. Successfully repaired rows have `nostr_attempts` cleared for a fresh repair budget. After the unwrap `DO $unwrap$` (that block stays only in `MESSAGE_SCHEMA_SQL`), `CREATE TABLE message_edit` and its index are last in `MESSAGE_SCHEMA_SQL` and are mirrored in `docs/schema/message.sql`. It also adds nullable `fiat_usd`, `fiat_chf`, `fiat_eur`, and `fiat_php` on `message` and `nostr_zap_ingest`, then backfills rows with a positive sat amount and `fiat_usd IS NULL` from that row's UTC-day close. A missing rate leaves the row null. Rows that already have `fiat_usd` are not rewritten. - **Payment claims table:** Also creates `nostr_zap_payment` (`payment_hash` primary key, `receipt_event_id`, `created_at`) without a foreign key to `message`; it is the durable tombstone that dedupes zap credits by payment hash. Like every public table it is attached to the `db_change` row trigger by `migrateDbChangeSchema`, which boots after this migration. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent SQL execute; `docs/schema/message.sql` mirrors the DDL and documents the boot repair statement by comment (the `DO $unwrap$` block lives only in `MESSAGE_SCHEMA_SQL`). From 250a8cef11b6236e4da24eb38949e8c08dc44515 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:58:13 +0200 Subject: [PATCH 08/19] Write replacement shop stills before dropping the old extras. A failed extra insert no longer clears the gallery first. --- src/__tests__/lib/message-store.test.ts | 26 +++++++++++++++++++------ src/lib/message-store.ts | 12 ++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 2b255da1e..ff4ed6c5a 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -6814,7 +6814,7 @@ describe('PostgresMessageStore', () => { expect(await store.updatePhoto('missing', JPEG)).toBeUndefined(); }); - it('replacePhotos deletes extras, writes the new stills, and returns undefined when missing', async () => { + it('replacePhotos writes the new stills before it drops leftover extras', async () => { const sql = new MockSql(); const row = { id: 'm1', @@ -6836,19 +6836,33 @@ describe('PostgresMessageStore', () => { { ...JPEG2, takenAt: '2020-01-02T00:00:00+00:00' }, ]); expect(updated?.id).toBe('m1'); - expect(sql.executes[0]?.text).toMatch(/DELETE FROM message_extra_photo/); expect(sql.queries[1]?.text).toMatch(/UPDATE message SET photo/); expect(sql.queries[1]?.params?.[3]).toBe('2020-01-01T00:00:00+00:00'); - expect(sql.executes[1]?.params?.[4]).toBeNull(); - expect(sql.executes[2]?.params?.[4]).toBe('2020-01-02T00:00:00+00:00'); + expect(sql.executes[0]?.text).toMatch(/ON CONFLICT \(message_id, idx\) DO UPDATE/); + expect(sql.executes[0]?.params?.[4]).toBeNull(); + expect(sql.executes[1]?.params?.[4]).toBe('2020-01-02T00:00:00+00:00'); + expect(sql.executes[2]?.text).toMatch(/idx > \$2/); + expect(sql.executes[2]?.params).toEqual(['m1', 2]); sql.queryQueue = [[row], [row], []]; const fallenBack = await store.replacePhotos('m1', [JPEG]); expect(fallenBack?.id).toBe('m1'); expect(sql.queries.at(-2)?.params?.[3]).toBeNull(); - sql.queryQueue = [[row], []]; - expect(await store.replacePhotos('m1', [])).toBeUndefined(); + expect(sql.executes.at(-1)?.params).toEqual(['m1', 0]); + const missed = new MockSql(); + missed.queryQueue = [[row], []]; + expect(await new PostgresMessageStore(missed).replacePhotos('m1', [])).toBeUndefined(); + expect(missed.executes).toEqual([]); sql.queryQueue = [[]]; expect(await store.replacePhotos('missing', [JPEG])).toBeUndefined(); + const failing = new MockSql(); + failing.queryQueue = [[row], [row]]; + failing.executeError = new Error('extra'); + await expect( + new PostgresMessageStore(failing).replacePhotos('m1', [JPEG, JPEG2]), + ).rejects.toThrow('extra'); + expect(failing.executes).toHaveLength(1); + expect(failing.executes[0]?.text).toMatch(/ON CONFLICT/); + expect(failing.executes.some((item) => item.text.includes('DELETE'))).toBe(false); }); it('getById maps nostr_event JSON string', async () => { diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 2ea872245..89908fe95 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -5290,7 +5290,6 @@ export class PostgresMessageStore implements MessageStore { if (existing === undefined) { return undefined; } - await this.#sql.execute(`DELETE FROM message_extra_photo WHERE message_id = $1`, [id]); const first = photos[0]; const rows = await this.#sql.query( `UPDATE message SET photo = $2, photo_content_type = $3, photo_taken_at = $4 WHERE id = $1 RETURNING ${MESSAGE_SELECT_COLUMNS}`, @@ -5307,10 +5306,19 @@ export class PostgresMessageStore implements MessageStore { } for (const [index, extra] of photos.slice(1).entries()) { await this.#sql.execute( - `INSERT INTO message_extra_photo (message_id, idx, photo, photo_content_type, photo_taken_at) VALUES ($1,$2,$3,$4,$5)`, + `INSERT INTO message_extra_photo (message_id, idx, photo, photo_content_type, photo_taken_at) + VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (message_id, idx) DO UPDATE + SET photo = EXCLUDED.photo, + photo_content_type = EXCLUDED.photo_content_type, + photo_taken_at = EXCLUDED.photo_taken_at`, [id, index + 1, extra.bytes, extra.contentType, extra.takenAt ?? null], ); } + await this.#sql.execute(`DELETE FROM message_extra_photo WHERE message_id = $1 AND idx > $2`, [ + id, + photos.length - 1, + ]); const refreshed = await this.getById(id); return refreshed ?? mapMessageRow(written); } From dfbda26a094b265ad3b8618ff4708bf0565f1219 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:17:46 +0200 Subject: [PATCH 09/19] Clear in-memory edit history when a message is hard-deleted. --- docs/handbook/functions.md | 2 +- src/__tests__/lib/message-store.test.ts | 31 +++++++++++++++++++++++++ src/lib/message-store.ts | 3 +++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 4bd27e397..b35e34607 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -750,7 +750,7 @@ ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Optional `place` is `{ lat, lng, label }` or null; a reply stores `place: null`. A live media match with the same pin returns the existing row; a different pin throws `place conflicts with live media` (the route maps that to 409). `listPlaces` returns live top-level rows that have both coordinates, newest first. Photos live in a private map, not on listed rows. Extra stills (indices 1–9) live in a second private map (`getExtraPhoto` / `listExtraPhotos`); `create(row, photo?, video?, extraPhotos?)` stores extras (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty); `photoCount` is (photo 0 ? 1 : 0) + extras length. Same port as Postgres: `getById` (still returns soft-hidden rows), `listIdsByPrefix(prefix)` (at most two stored ids whose lowercase form starts with the prefix, prefix not trimmed, including soft-hidden rows), `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos), `markDeleted` (stamps `deletedAt` / `deletedBy` on the target and untagged direct replies; never removes media/invoices), `markUndeleted` (clears `deletedAt` / `deletedBy` on the hidden target and stamp-matched direct children; already-live is a no-op for children; never removes media/invoices), `listDirectChildren` (direct children including hidden, createdAt then id), `getByEventId`, `findLiveByAccountContent` (oldest live account+parent+`contentFp`), `accountHasLiveTopLevelPost` (`parentId === null`, exclude profile id, replies do not count), `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video; seed `hasPhoto: true` alone is not media), `latestLiveTopLevelMediaId` (newest live top-level id with a stored photo, extra stills, or video, including About me; replies, hidden rows, and other accounts do not count), live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listFeed` (GET `/messages` keyset page, optional `hashtag`; `createApp` calls `useProfileNoteIds` so `listFeed` omits name-copy ids returned by that provider (a real About me id is not included; `account.profileMessageId`, trimmed, blank ignored); a store with no provider does not omit them), `listReplies` (children with an account or a recorded zapper pubkey; live-only unless `includeHidden === true`), `listChildIds` (direct child ids, any `deletedAt`), `countByAccount` (uncapped live post/reply totals for one account), `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown), live-only `listPostsByAccount` (newest-first top-level for one account, cap, live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listRepliesByAccount` (newest-first replies for one account, cap, no `replyCount`), `listDebug` (operator newest-first **all** rows: top-level and replies, live and soft-hidden), `postCountsByUtcDay` (living rows only, `deletedAt` null, notes and replies together, grouped by UTC day, days with no rows omitted, no media bytes), `listHidden` (staff newest-hidden-first hidden rows only, `deletedAt` desc then `id` desc), `listDirectChildren` (direct children including hidden, createdAt then id), live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `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`), live-only `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, video rows excluded so posters are not treated as missing photos), live-only `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded), live-only `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, clears `nostrPublishEpoch`, increments `nostrAttempts`, and stamps `nostrFirstAttemptAt` once, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set; `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), `listRecentOkInvoiceAttempts` (same filter and order as the Postgres query: `result === 'ok'` and `createdAt >= since`, newest-first with `id` descending tie-break), `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result === 'ok'` by payment hash, BOLT11 `pr`, or one message plus description), `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted), `sumUnassignedCreditSats` (sats with no payer account), `listRepayments` and `markRepaymentPaid` (in-memory shares; a repeat of the same day and giver is a no-op and does not change `sats`), and `goalFundedAt` set once when a repayable ask first reaches `goalSats`, `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice with both conversation id and conversation message id so the same event id may repeat; no `conversation_message` join — existence filter is in `indexOpenZapReceipts`), `updateZapReceiptGift` (patch payer / gift-reply id / comment; missing receipt is a no-op; omitted patch fields stay), `getZapReceiptGift` (one receipt including comment and gift-reply id), `listZapReceiptsAwaitingGiftReply` (`payerAccountId` or `payerPubkey` set and no gift reply yet, cap, `receiptEventId` ASC, includes `comment`), `recordZapIngest` / `listZapIngests`, `listInvoiceAttemptsForPayer` (uncapped payer filter, newest-first), `listIndexedZapIngests` (uncapped, `outcome = indexed` only), `listAuthoredMessages` (all rows for one account including hidden, no cap), `updateText(id, text)` (mutates `text` only and returns a copy; sats / photos / event ids unchanged; missing id → `undefined`); `create` returns the existing row when `id` is already stored (including after that row's parent was later deleted); a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats`, `goalRepayable`, `goalTermDays`, `goalCurrency`, `goalAmount`, and the four goal fiat snapshots null even if the row carried an ask, and throws without appending when the parent is missing or soft-hidden; `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. Optional `place` is `{ lat, lng, label }` or null; a reply stores `place: null`. A live media match with the same pin returns the existing row; a different pin throws `place conflicts with live media` (the route maps that to 409). `listPlaces` returns live top-level rows that have both coordinates, newest first. Photos live in a private map, not on listed rows. Extra stills (indices 1–9) live in a second private map (`getExtraPhoto` / `listExtraPhotos`); `create(row, photo?, video?, extraPhotos?)` stores extras (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty); `photoCount` is (photo 0 ? 1 : 0) + extras length. Same port as Postgres: `getById` (still returns soft-hidden rows), `listIdsByPrefix(prefix)` (at most two stored ids whose lowercase form starts with the prefix, prefix not trimmed, including soft-hidden rows), `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos, and edit history for those ids), `markDeleted` (stamps `deletedAt` / `deletedBy` on the target and untagged direct replies; never removes media/invoices), `markUndeleted` (clears `deletedAt` / `deletedBy` on the hidden target and stamp-matched direct children; already-live is a no-op for children; never removes media/invoices), `listDirectChildren` (direct children including hidden, createdAt then id), `getByEventId`, `findLiveByAccountContent` (oldest live account+parent+`contentFp`), `accountHasLiveTopLevelPost` (`parentId === null`, exclude profile id, replies do not count), `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video; seed `hasPhoto: true` alone is not media), `latestLiveTopLevelMediaId` (newest live top-level id with a stored photo, extra stills, or video, including About me; replies, hidden rows, and other accounts do not count), live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listFeed` (GET `/messages` keyset page, optional `hashtag`; `createApp` calls `useProfileNoteIds` so `listFeed` omits name-copy ids returned by that provider (a real About me id is not included; `account.profileMessageId`, trimmed, blank ignored); a store with no provider does not omit them), `listReplies` (children with an account or a recorded zapper pubkey; live-only unless `includeHidden === true`), `listChildIds` (direct child ids, any `deletedAt`), `countByAccount` (uncapped live post/reply totals for one account), `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown), live-only `listPostsByAccount` (newest-first top-level for one account, cap, live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listRepliesByAccount` (newest-first replies for one account, cap, no `replyCount`), `listDebug` (operator newest-first **all** rows: top-level and replies, live and soft-hidden), `postCountsByUtcDay` (living rows only, `deletedAt` null, notes and replies together, grouped by UTC day, days with no rows omitted, no media bytes), `listHidden` (staff newest-hidden-first hidden rows only, `deletedAt` desc then `id` desc), `listDirectChildren` (direct children including hidden, createdAt then id), live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `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`), live-only `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, video rows excluded so posters are not treated as missing photos), live-only `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded), live-only `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, clears `nostrPublishEpoch`, increments `nostrAttempts`, and stamps `nostrFirstAttemptAt` once, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set; `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), `listRecentOkInvoiceAttempts` (same filter and order as the Postgres query: `result === 'ok'` and `createdAt >= since`, newest-first with `id` descending tie-break), `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result === 'ok'` by payment hash, BOLT11 `pr`, or one message plus description), `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted), `sumUnassignedCreditSats` (sats with no payer account), `listRepayments` and `markRepaymentPaid` (in-memory shares; a repeat of the same day and giver is a no-op and does not change `sats`), and `goalFundedAt` set once when a repayable ask first reaches `goalSats`, `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice with both conversation id and conversation message id so the same event id may repeat; no `conversation_message` join — existence filter is in `indexOpenZapReceipts`), `updateZapReceiptGift` (patch payer / gift-reply id / comment; missing receipt is a no-op; omitted patch fields stay), `getZapReceiptGift` (one receipt including comment and gift-reply id), `listZapReceiptsAwaitingGiftReply` (`payerAccountId` or `payerPubkey` set and no gift reply yet, cap, `receiptEventId` ASC, includes `comment`), `recordZapIngest` / `listZapIngests`, `listInvoiceAttemptsForPayer` (uncapped payer filter, newest-first), `listIndexedZapIngests` (uncapped, `outcome = indexed` only), `listAuthoredMessages` (all rows for one account including hidden, no cap), `updateText(id, text)` (mutates `text` only and returns a copy; sats / photos / event ids unchanged; missing id → `undefined`); `create` returns the existing row when `id` is already stored (including after that row's parent was later deleted); a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats`, `goalRepayable`, `goalTermDays`, `goalCurrency`, `goalAmount`, and the four goal fiat snapshots null even if the row carried an ask, and throws without appending when the parent is missing or soft-hidden; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). - **External-zapper methods:** `attributeZapReceipt(receiptEventId, { payerPubkey, zapRequestId, comment })` returns `false` when the receipt is missing, when that receipt already has a different request id, or when another receipt in the map already has that request id. A retry with the same request id on the same receipt is idempotent `true`; otherwise it lowercases and stores the payer pubkey, request id, and comment. `recordZapper(pubkey, receiptEventId, at)` lowercases the pubkey and stores the first row in a private map that `deleteById` and receipt queue updates do not clear; `listZapperPubkeys()` returns its keys; `listZappers(limit)` sorts copied rows by `createdAt DESC, pubkey DESC` and caps them. `blockPubkeyAndHideRows(pubkey, at, byAccountId, messageId)` performs the same insert-or-skip and synchronously scans every live null-account row for a case-insensitive author match, stamps it, and returns the hidden count as one store operation; `unblockPubkeyByMessage(messageId)` removes the first matching map entry and reports whether one was found; `isPubkeyBlocked(pubkey)` lowercases its input and checks that map; `isZapperPubkey(pubkey)` lowercases its input and checks the zapper map; `listBlockedPubkeys()` returns the map keys; `listBlockedPubkeyRows(limit)` sorts copied rows by `blockedAt DESC, pubkey DESC` and caps them. `listUnattributedIndexedReceipts(limit, before?)` returns one row per receipt-map entry whose `payerAccountId`, `payerPubkey`, `zapRequestId`, and `giftReplyId` are all null, paired with its newest indexed ingest frame (`createdAt` DESC, then `id` DESC, matching the SQL `JOIN LATERAL … LIMIT 1`), sorts by immutable ingest `createdAt DESC, receiptEventId DESC`, applies an optional strict `{ createdAt, eventId }` keyset cursor and the cap, and returns copies. Unlike an offset over a changing unattributed set, the cursor cannot skip or repeat rows as attribution removes entries. - **Payment claims:** `claimZapPayment` keeps one owner receipt id per lowercase payment hash in a process-local map. The same receipt id may claim again; another id is refused. `deleteById` does not remove the claim, so a re-created message id cannot be credited twice for one payment. - **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false; missing `deletedAt` / `deletedBy` become null). Operator dump: `listExtraPhotoMeta(limit)`, `listZapReceipts(limit)`, and `listZapPayments(limit)` newest-first (cap 200). `listLatest(limit)` is live top-level only with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper. `listFeed(query)` is a live top-level keyset page (`mode` / `limit` / exclusive `cursor` / `staffAccountIds` (`active` only) / optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount` as `listLatest`; `createApp` calls `useProfileNoteIds` so `listFeed` omits name-copy ids returned by that provider (a real About me id is not included; `account.profileMessageId`, trimmed, blank ignored). A store with no provider does not omit them. `active` keeps paid rows, staff unpaid rows, and top-level rows with `goalSats` > 0). `listReplies(parentId, limit?, includeHidden?)` is oldest-first children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (default 200; live-only unless `includeHidden === true`). `listChildIds(parentId)` returns direct child ids (any `deletedAt`). `countByAccount(accountId)` is uncapped live `{ postCount, replyCount }` for that author. `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown). `listPostsByAccount(accountId, limit)` is newest-first live top-level for that author with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (cap). `listRepliesByAccount(accountId, limit)` is newest-first live replies for that author (cap, no `replyCount`). `listDebug(limit)` is newest-first all rows including hidden and replies. `postCountsByUtcDay()` groups living rows (`deletedAt` null), notes and replies together, by UTC day and omits empty days. `listHidden(limit)` is newest-hidden-first hidden rows only (`deletedAt` desc, then `id` desc). `listPublishedEventIds(limit)` is newest-first non-null live top-level `eventId`s. `create(row, photo?, video?, extraPhotos?)` returns the stored row when `id` is already present (no append, no second video write) even if that row's parent was later deleted; a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats`, `goalRepayable`, `goalTermDays`, `goalCurrency`, `goalAmount`, and the four goal fiat snapshots null even if the row carried an ask, and throws without appending when the parent is missing or soft-hidden; otherwise appends a copy, or returns the existing live media match without a second video write when the pin matches; a different pin throws `place conflicts with live media`; extras indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty; `getPhoto(id)` returns a photo copy or null; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` return extra stills from the private map; `photoCount` is (photo 0 ? 1 : 0) + extras length; `markDeleted(id, at, byAccountId)` returns false when missing; `markUndeleted(id)` returns false when missing. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index ff4ed6c5a..2d374a8a1 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -1193,6 +1193,34 @@ describe('InMemoryMessageStore', () => { expect(await store.claimZapPayment('AB'.repeat(32), 'receipt-del', new Date(0))).toBe(true); await store.create({ ...LATE }); expect(await store.recordZapReceipt('receipt-keep', LATE.id, 1, null)).toBe(true); + const editedAt = new Date('2026-08-01T00:00:00.000Z'); + await store.appendEdit({ + id: 'edit-parent', + messageId: 'p-del', + actorId: 'author', + createdAt: editedAt, + field: 'text', + before: 'parent', + after: 'changed', + }); + await store.appendEdit({ + id: 'edit-child', + messageId: 'c-del', + actorId: 'author', + createdAt: editedAt, + field: 'text', + before: 'child', + after: 'changed', + }); + await store.appendEdit({ + id: 'edit-keep', + messageId: LATE.id, + actorId: 'author', + createdAt: editedAt, + field: 'text', + before: 'second', + after: 'kept', + }); const videoPath = videoFilePath(resolveMediaDir(), 'p-del', 'video/mp4'); await readFile(videoPath); expect(await store.deleteById('p-del')).toBe(true); @@ -1204,6 +1232,9 @@ describe('InMemoryMessageStore', () => { expect(await store.recordZapReceipt('receipt-del', 'p-del', 7, null)).toBe(true); expect(await store.recordZapReceipt('receipt-keep', 'b', 1, null)).toBe(false); expect(await store.claimZapPayment('ab'.repeat(32), 'receipt-other', new Date(1))).toBe(false); + expect(await store.listEdits('p-del')).toEqual([]); + expect(await store.listEdits('c-del')).toEqual([]); + expect((await store.listEdits(LATE.id)).map((row) => row.id)).toEqual(['edit-keep']); expect(await store.getPhoto('p-del')).toBeNull(); await expect(readFile(videoPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 89908fe95..679061cdb 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -3717,6 +3717,9 @@ export class InMemoryMessageStore implements MessageStore { const kept = this.#invoiceAttempts.filter((item) => !ids.has(item.messageId)); this.#invoiceAttempts.length = 0; this.#invoiceAttempts.push(...kept); + const keptEdits = this.#edits.filter((item) => !ids.has(item.messageId)); + this.#edits.length = 0; + this.#edits.push(...keptEdits); for (const [receiptEventId, receipt] of this.#receipts) { if (ids.has(receipt.messageId)) { this.#receipts.delete(receiptEventId); From 6c87ccb0dc055e960ca431f16db5b551f99d0a6a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:59:31 +0200 Subject: [PATCH 10/19] Exercise the history read of a live note that is not a shop. --- src/__tests__/routes/messages.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 7134e76c7..7e74e6983 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -11824,6 +11824,15 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { }); expect(hidden.status).toBe(200); expect(((await hidden.json()) as { edits: unknown[] }).edits).toHaveLength(2); + await messages.create({ + id: PLAIN_ID, + accountId: 'acc', + name: 'Ada', + text: 'Hello', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); const plain = await mount(auth, messages).request('/messages/' + PLAIN_ID + '/edits', { headers: AUTH, }); From d13e78e390c9378b9d6c74ec4e47ca927d5db08d Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:17 +0200 Subject: [PATCH 11/19] Drop the extra blank line before the map-push test helper. --- src/__tests__/routes/messages.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 7e74e6983..16f494216 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -11185,7 +11185,6 @@ describe('PATCH /messages/:id/shop-account', () => { }); }); - function recordingMap(): { mapPush: MapPush; calls: string[]; From d1b490382243953faafd8cadd1cfa030ac97a6f9 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:23:41 +0200 Subject: [PATCH 12/19] Document shop edit history fields and unchanged place pins. --- SPEC.md | 5 +++-- docs/handbook/endpoints.md | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index d214ecd7d..088abd569 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4481,7 +4481,8 @@ note → **400** `{ "error": "Only a shop note can set a place" }`. Success → **200** live public message JSON (optional `place`, reply count, no hide stamps). Logs `messages.place.updated` with `messageId`, `accountId`, and `role` only. Text and publish state are -unchanged. +unchanged. A real pin change appends `message_edit`. An identical pin +does not. ### `PATCH /messages/:id/shop-account` @@ -4519,7 +4520,7 @@ Staff replacement of the stills on a live top-level shop note. Bearer session re ### `GET /messages/:id/edits` -Staff history for a top-level shop note, newest first, including a hidden shop note. Bearer session required. Live role must be at least `moderator`. GET is not a Sunday write. Success is **200** `{ "edits": [...] }`. A reply or a non-shop note is **404**. Public message JSON does not include `edits`. +Staff history for a top-level shop note, newest first, including a hidden shop note. Bearer session required. Live role must be at least `moderator`. GET is not a Sunday write. Success is **200** `{ "edits": [{ "id", "createdAt", "field", "before", "after", "actor" }] }`. `createdAt` is ISO. `field` is `"text"`, `"place"`, or `"shopAccount"` (SQL `shop_account` is published as `shopAccount`). `before` and `after` are the previous and next value: a text string, a place pin or null, or a shop account `{ "id", "username", "name" }` or null. `actor` is `{ "id", "name", "role" }`. A missing account keeps `{ "id", "name": null, "role": null }`. Empty history is `{ "edits": [] }`. A reply or a non-shop note is **404**. Public message JSON does not include `edits`. ### `GET /messages/hidden` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 69c39eeaa..3d72e0789 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -37,7 +37,7 @@ ## Endpoint: PATCH /messages/:id/place -- **Purpose:** Bearer required. A moderator sets, replaces, or clears the map pin on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setPlace`. The first pin, when `mapPush` is configured, is posted once to `POST /map/places`; a failure logs `ocp.place.failed` and this response stays 200. Boot leaves `mapPush` unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A replace or a clear does not post again. Does not republish Nostr, change note text, or notify. `place: null` clears; a missing `place` key does not. A real change appends `message_edit`. Success is the live public message JSON (optional `place`, reply count, no hide stamps). +- **Purpose:** Bearer required. A moderator sets, replaces, or clears the map pin on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setPlace`. The first pin, when `mapPush` is configured, is posted once to `POST /map/places`; a failure logs `ocp.place.failed` and this response stays 200. Boot leaves `mapPush` unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A replace or a clear does not post again. Does not republish Nostr, change note text, or notify. `place: null` clears; a missing `place` key does not. A real change appends `message_edit`. An identical pin does not. Success is the live public message JSON (optional `place`, reply count, no hide stamps). - **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, missing or hidden row, or `setPlace` false; 400 `{ error: 'Invalid body' }` for non-JSON or a missing `place` key; 400 `{ error: 'Place must be a latitude and longitude' }`; 400 `{ error: 'Place label must be at most 80 characters' }`; 400 `{ error: 'A reply cannot include a place' }`; 400 `{ error: 'Only a shop note can set a place' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** Staff shop map pin in the app forum. - **Auth:** `Authorization: Bearer` session (moderator). @@ -65,7 +65,7 @@ ## Endpoint: GET /messages/:id/edits -- **Purpose:** Bearer required. A moderator lists `message_edit` history for a top-level shop note, newest first, including a hidden shop note. Does not change the note and does not republish Nostr. GET is not a Sunday write. Actors resolve like hide stamps (missing account keeps the id with null name/role). Public message JSON does not include `edits`. Empty history is `{ edits: [] }`. +- **Purpose:** Bearer required. A moderator lists `message_edit` history for a top-level shop note, newest first, including a hidden shop note. Does not change the note and does not republish Nostr. GET is not a Sunday write. Success is `{ edits: [{ id, createdAt, field, before, after, actor }] }`, newest first. `createdAt` is ISO. `field` is `text`, `place`, or `shopAccount` (SQL `shop_account` is published as `shopAccount`). `before` and `after` are the previous and next value: a text string, a place pin or null, or a shop account `{ id, username, name }` or null. `actor` is `{ id, name, role }`. A missing account keeps `{ id, name: null, role: null }`. Public message JSON does not include `edits`. Empty history is `{ edits: [] }`. - **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a reply, or a note that is not a shop note; 503 `{ error: 'Messages are unavailable' }`. GET history is not refused with `SUNDAY_REST`. - **Used by:** Staff shop-note history in the app forum. - **Auth:** `Authorization: Bearer` session (moderator). From 986b7c7a860d5abbce80de0611baea9482515051 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:42:41 +0200 Subject: [PATCH 13/19] Prove an omitted shop tag is not a text edit. Saving the same shop words without #21GiftsShop restores the tag and writes no history row. The existing test only sent the tag already in place. --- src/__tests__/routes/messages.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 16f494216..878a6ca3b 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -11492,6 +11492,16 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { expect(await messages.listEdits(SHOP_ID)).toEqual([]); }); + it('does not append history when the client omits the shop tag', async () => { + const auth = await staffStore('Ada'); + const messages = new InMemoryMessageStore(); + await shopNote(messages); + const res = await patchText(auth, SHOP_ID, { text: 'Cafe' }, messages); + expect(res.status).toBe(200); + expect(((await res.json()) as { text: string }).text).toBe('Cafe\n\n#21GiftsShop'); + expect(await messages.listEdits(SHOP_ID)).toEqual([]); + }); + it('edits an external shop note and a note whose author account is gone', async () => { const auth = await staffStore('Ada'); const external = new InMemoryMessageStore(); From c25d86f7adca1da265de818cc8b7c9ef86a0a967 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:25:35 +0200 Subject: [PATCH 14/19] Name the non-UUID shop text id as not found. A bad id is 404, not 400. The test name said 400 and never checked the error text. --- src/__tests__/routes/messages.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 878a6ca3b..29ddd0195 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -11388,10 +11388,11 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { expect(listed.status).toBe(403); }); - it('returns 400 for a bad id, a bad body, and text that cannot be stored', async () => { + it('returns 404 for a non-UUID id and 400 for a bad body or text that cannot be stored', async () => { const auth = await staffStore('Ada'); const badId = await patchText(auth, 'nope', { text: 'Cafe' }); expect(badId.status).toBe(404); + expect(await badId.json()).toEqual({ error: 'Not found' }); const missingText = await patchText(auth, SHOP_ID, {}); expect(missingText.status).toBe(400); expect(await missingText.json()).toEqual({ error: 'Invalid body' }); From 37d4d40c394483ab722da9b836252d9b221775d9 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:20:47 +0200 Subject: [PATCH 15/19] Pin the shop edit history delete rule. Postgres removes edit rows by cascading from the note. The schema test named the table and never locked that rule. --- src/__tests__/lib/message-store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 2d374a8a1..4bde262c2 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -277,6 +277,7 @@ describe('MESSAGE_SCHEMA_SQL', () => { expect(MESSAGE_SCHEMA_SQL[96]).toMatch( /CONSTRAINT message_edit_field_chk CHECK \(field IN \('text', 'place', 'shop_account'\)\)/, ); + expect(MESSAGE_SCHEMA_SQL[96]).toMatch(/REFERENCES message \(id\) ON DELETE CASCADE/); expect(MESSAGE_SCHEMA_SQL[96]).toMatch(/before jsonb NOT NULL/); expect(MESSAGE_SCHEMA_SQL[96]).toMatch(/after jsonb NOT NULL/); expect(MESSAGE_SCHEMA_SQL[97]).toMatch( From 6216c0e1d993b0372d7830ac248439748103b502 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:26:32 +0200 Subject: [PATCH 16/19] State the shop edit errors in the order the route uses. A missing note is not found before a shop name is looked up. Create, photos, and history say the same. --- SPEC.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/SPEC.md b/SPEC.md index 088abd569..d393b3d04 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3737,7 +3737,7 @@ Post to the public member forum. Bearer session required. JSON body (not multipart) with text and/or one photo, optional `photos` (array, max 10, each `{ contentType, data, takenAt? }` same shape as singular `photo`), an optional parent UUID, and optional `goalSats` (positive integer 1..10_000_000 -on a top-level note only). Optional `shopUsername` on a shop note stores that account at create and does not write `message_edit`. A bad username is 400 `Username is not valid`. An unknown username is 404 `No account with that username`. A non-shop note with a username is 400 `Only a shop note can set a shop account`. Blank, null, or `@` alone stores nothing. Optional `takenAt` is `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` +on a top-level note only). Optional `shopUsername` on a shop note stores that account at create and does not write `message_edit`. A bad username is 400 `Username is not valid`. An unknown username, or a stored username that is null or blank, is 404 `No account with that username`. A reply, or a note that is not a shop, with a non-blank username is 400 `Only a shop note can set a shop account`. Blank, null, or `@` alone stores nothing. A media replay of an existing live note does not change its shop account. Optional `takenAt` is `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1. `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not return 400: @@ -4494,12 +4494,13 @@ must match `MESSAGE_ID_RE` or the response is **404**. Body is JSON via **400** `{ "error": "Invalid body" }`. `username: null` clears. A string is trimmed, one leading `@` is stripped, then `normalizeUsername`. An invalid username is **400** `{ "error": "Username is not valid" }`. -Unknown username, or a stored username that is null or blank, is **404** -`{ "error": "No account with that username" }`. Missing or hidden row → -**404** and no write. A reply → **400** +Missing or hidden row → **404** `{ "error": "Not found" }` and no write, +before the handle is looked up. A reply → **400** `{ "error": "A reply cannot include a shop account" }`. A non-shop top-level note → **400** -`{ "error": "Only a shop note can set a shop account" }`. +`{ "error": "Only a shop note can set a shop account" }`. On a live shop +note, an unknown username, or a stored username that is null or blank, +is **404** `{ "error": "No account with that username" }`. `setShopAccount` false, or a row that disappears before reload, → **404**. Store throw → **503** `{ "error": "Messages are unavailable" }` and `messages.shop_account.failed`. @@ -4516,11 +4517,11 @@ Staff replacement of the body of a live top-level shop note (`#21GiftsShop`). Be ### `PATCH /messages/:id/photos` -Staff replacement of the stills on a live top-level shop note. Bearer session required. Live role must be at least `moderator`. Body `{ "photos": [{ "contentType", "data", "takenAt?" }] }` with at most 10 items. An empty list clears stills. A video on the note stays. This write does not append `message_edit`. Success is **200** public message JSON. A reply is **400** `{ "error": "A reply cannot be edited" }`. A non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. A bad photo is **400** `{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }`. Store throw → **503**. +Staff replacement of the stills on a live top-level shop note. Bearer session required. Live role must be at least `moderator`. `:id` must match `MESSAGE_ID_RE`, and a missing or hidden row is **404** `{ "error": "Not found" }`. Body `{ "photos": [{ "contentType", "data", "takenAt?" }] }` with at most 10 items. An empty list clears stills. A video on the note stays. This write does not append `message_edit`. Success is **200** public message JSON. A reply is **400** `{ "error": "A reply cannot be edited" }`. A non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. A bad photo is **400** `{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }`. Store throw → **503**. ### `GET /messages/:id/edits` -Staff history for a top-level shop note, newest first, including a hidden shop note. Bearer session required. Live role must be at least `moderator`. GET is not a Sunday write. Success is **200** `{ "edits": [{ "id", "createdAt", "field", "before", "after", "actor" }] }`. `createdAt` is ISO. `field` is `"text"`, `"place"`, or `"shopAccount"` (SQL `shop_account` is published as `shopAccount`). `before` and `after` are the previous and next value: a text string, a place pin or null, or a shop account `{ "id", "username", "name" }` or null. `actor` is `{ "id", "name", "role" }`. A missing account keeps `{ "id", "name": null, "role": null }`. Empty history is `{ "edits": [] }`. A reply or a non-shop note is **404**. Public message JSON does not include `edits`. +Staff history for a top-level shop note, newest first, including a hidden shop note. Bearer session required. Live role must be at least `moderator`. GET is not a Sunday write. Success is **200** `{ "edits": [{ "id", "createdAt", "field", "before", "after", "actor" }] }`. `createdAt` is ISO. `field` is `"text"`, `"place"`, or `"shopAccount"` (SQL `shop_account` is published as `shopAccount`). `before` and `after` are the previous and next value: a text string, a place pin or null, or a shop account `{ "id", "username", "name" }` or null. `actor` is `{ "id", "name", "role" }`. A missing account keeps `{ "id", "name": null, "role": null }`. Empty history `{ "edits": [] }` is only for an existing top-level shop note, including a hidden one. A non-UUID `:id`, a missing row, a reply, or a non-shop note is **404** `{ "error": "Not found" }`. Public message JSON does not include `edits`. ### `GET /messages/hidden` From 96b6f1a05dd8282881571ea10f76b6db1f6bd42a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:30:21 +0200 Subject: [PATCH 17/19] Keep a shop account on the note insert and record a real change in the same write. --- SPEC.md | 6 +- docs/handbook/endpoints.md | 6 +- docs/handbook/functions.md | 4 +- src/__tests__/lib/message-store.test.ts | 262 ++++++++++++++++++--- src/__tests__/routes/messages.test.ts | 80 +++---- src/lib/message-store.ts | 292 ++++++++++++++++++------ src/routes/messages.ts | 75 +++--- 7 files changed, 542 insertions(+), 183 deletions(-) diff --git a/SPEC.md b/SPEC.md index d393b3d04..0b4850427 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3737,7 +3737,7 @@ Post to the public member forum. Bearer session required. JSON body (not multipart) with text and/or one photo, optional `photos` (array, max 10, each `{ contentType, data, takenAt? }` same shape as singular `photo`), an optional parent UUID, and optional `goalSats` (positive integer 1..10_000_000 -on a top-level note only). Optional `shopUsername` on a shop note stores that account at create and does not write `message_edit`. A bad username is 400 `Username is not valid`. An unknown username, or a stored username that is null or blank, is 404 `No account with that username`. A reply, or a note that is not a shop, with a non-blank username is 400 `Only a shop note can set a shop account`. Blank, null, or `@` alone stores nothing. A media replay of an existing live note does not change its shop account. Optional `takenAt` is `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` +on a top-level note only). Optional `shopUsername` (JSON string or multipart field) on a shop note stores that account on the same insert as the note and does not write `message_edit`. Omitted, null, blank, or `@` alone stores nothing. A non-string is 400 `Username is not valid`. A reply, or a note that is not a shop, with a non-blank username is 400 `Only a shop note can set a shop account` before the handle is normalised or looked up. After that, a handle `normalizeUsername` rejects is 400 `Username is not valid`. An unknown username, or a stored username that is null or blank, is 404 `No account with that username`. A media replay of an existing live note does not change its shop account. Optional `takenAt` is `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1. `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not return 400: @@ -4513,11 +4513,11 @@ unchanged. The write stores only `shop_account_id`. A real change appends `messa ### `PATCH /messages/:id/text` -Staff replacement of the body of a live top-level shop note (`#21GiftsShop`). Bearer session required. Live role must be at least `moderator`. `:id` must match `MESSAGE_ID_RE` or the response is **404**. Body is JSON; a non-object or a missing string `text` is **400** `{ "error": "Invalid body" }`. The shop tag is kept or restored. An unchanged body is **200** without `message_edit`. A real change appends `message_edit` and is **200** public message JSON. A reply is **400** `{ "error": "A reply cannot be edited" }`. A non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. Missing or hidden row → **404**. Store throw → **503** `{ "error": "Messages are unavailable" }`. +Staff replacement of the body of a live top-level shop note (`#21GiftsShop`). Bearer session required. Live role must be at least `moderator`. Checks run in this order: `:id` must match `MESSAGE_ID_RE` or the response is **404**; a non-object body or a missing string `text` is **400** `{ "error": "Invalid body" }`; text outside 1–8000 characters is **400** before the row is read; a missing or hidden row is **404** `{ "error": "Not found" }`; a reply is **400** `{ "error": "A reply cannot be edited" }`; a non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. The shop tag is kept or restored. An unchanged body is **200** without `message_edit`. A real change writes the body and `message_edit` together and is **200** public message JSON. Store throw → **503** `{ "error": "Messages are unavailable" }` and leaves the previous body with no new history row. ### `PATCH /messages/:id/photos` -Staff replacement of the stills on a live top-level shop note. Bearer session required. Live role must be at least `moderator`. `:id` must match `MESSAGE_ID_RE`, and a missing or hidden row is **404** `{ "error": "Not found" }`. Body `{ "photos": [{ "contentType", "data", "takenAt?" }] }` with at most 10 items. An empty list clears stills. A video on the note stays. This write does not append `message_edit`. Success is **200** public message JSON. A reply is **400** `{ "error": "A reply cannot be edited" }`. A non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. A bad photo is **400** `{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }`. Store throw → **503**. +Staff replacement of the stills on a live top-level shop note. Bearer session required. Live role must be at least `moderator`. Checks run in this order: `:id` must match `MESSAGE_ID_RE` or the response is **404**; the body must be `{ "photos": [{ "contentType", "data", "takenAt?" }] }` with at most 10 items, and a bad photo is **400** `{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }`, before the row is read (a missing or hidden row with a bad photo is still that **400**); a missing or hidden row is then **404** `{ "error": "Not found" }`; a reply is **400** `{ "error": "A reply cannot be edited" }`; a non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. An empty list clears stills. A video on the note stays. The stills are replaced in one write. This write does not append `message_edit`. Success is **200** public message JSON. Store throw → **503**. ### `GET /messages/:id/edits` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 3d72e0789..f4fdf559b 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -52,14 +52,14 @@ ## Endpoint: PATCH /messages/:id/text - **Purpose:** Bearer required. A moderator changes the text of a live top-level shop note (`#21GiftsShop`) via `MessageStore.updateText`. The shop tag is kept or restored. Does not republish Nostr, notify, or change sats, media, author, mentions, event ids, or publish state. An unchanged body is 200 without a history row. A real change appends `message_edit`. Success is the live public message JSON (reply count, no hide stamps, no `edits` field). -- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a hidden row, or `updateText` undefined; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or `text` is missing or not a string; 400 `{ error: 'Text must be 1–8000 characters' }`; 400 `{ error: 'Text must be 1–8000 characters or include a photo' }`; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). +- **Errors:** In order: 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or `text` is missing or not a string; 400 `{ error: 'Text must be 1–8000 characters' }` before the row is read; 404 `{ error: 'Not found' }` for a missing row, a hidden row, or `updateText` undefined; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 400 `{ error: 'Text must be 1–8000 characters or include a photo' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** Staff shop-note text edit in the app forum. - **Auth:** `Authorization: Bearer` session (moderator). ## Endpoint: PATCH /messages/:id/photos - **Purpose:** Bearer required. A moderator replaces the stills on a live top-level shop note (`#21GiftsShop`) via `MessageStore.replacePhotos`. Body `{ photos: { contentType, data, takenAt? }[] }` (at most 10; empty clears stills). Does not republish Nostr, write `message_edit`, or change text, video, sats, author, or event ids. A video note keeps its video. Success is the live public message JSON. -- **Errors:** 401 `{ error: 'Unauthorized' }`; 403 `{ error: 'Forbidden' }` below moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a hidden row, or `replacePhotos` undefined; 400 `{ error: 'Invalid body' }`; 400 `{ error: 'At most 10 photos' }`; 400 `{ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }`; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). +- **Errors:** In order: 401 `{ error: 'Unauthorized' }`; 403 `{ error: 'Forbidden' }` below moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`; 400 `{ error: 'Invalid body' }`; 400 `{ error: 'At most 10 photos' }`; 400 `{ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }` before the row is read, including when that row is missing or hidden; 404 `{ error: 'Not found' }` for a missing row, a hidden row, or `replacePhotos` undefined; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** The shop-note pencil wizard in the app. - **Auth:** `Authorization: Bearer` session (moderator). @@ -583,7 +583,7 @@ ## Endpoint: POST /messages -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + username + Lightning Address; skip timestamps do not satisfy; username cannot be skipped). JSON `{ text?, photo?: { contentType, data, takenAt? }, photos?: { contentType, data, takenAt? }[], inReplyTo?, goalSats?, goalCurrency?, goalAmount?, goalRepayable?, goalTermDays? }` (`takenAt` is optional `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1; `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not 400) (`photos` max 10; non-empty `photos` wins over singular `photo`; dual-send uses `photos`) (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), optional JPEG/PNG/WebP `poster`, and optional `goalSats` (string form field) or both `goalCurrency` and `goalAmount` (JSON accepts that same pair: both or neither, not mixed with `goalSats`, not half a pair), plus optional `goalRepayable` (multipart `"true"`; an empty field is absent) and optional `goalTermDays` (multipart digits from 1 to 3650; an empty field is absent). A currency ask freezes `goal_sats` plus `goal_currency`, `goal_amount`, and four `goal_fiat_*` snapshots from the latest gift-day with sats > 0 (same path as `GET /gifts/stats`). Legacy `goalSats` leaves those columns null. JSON omits the new keys when `goalCurrency` is null. Progress fiat is the sum of per-payment snapshots; a null delta does not wipe a stored total. One-time/Daily is not stored. Optional `goalSats` is a whole-sat ask on a top-level note (JSON number; multipart string). Omitted, JSON `null`, or a multipart empty/missing field means no goal. Max 10_000_000; above max is rejected, not clamped. 200 JSON may include `goalSats` or omit the key. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only (singular or `photos`) or video-only allowed; at least one of non-empty trimmed text, photo, non-empty `photos`, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `photoCount` (0–10), `photoTakenAts` (always; length equals `photoCount`; null when unknown; `[]` when there are no stills), `photoTakenAt` only when `photoCount === 1`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped; never `contentFp`). Identical live photo/video from the same account+parent (same normalised text + same media bytes) and the same pin returns the existing row (200, same id) without consuming the 1/10s burst limiter and without a second push; the same media with a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only is unchanged (new row + burst). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates call `notifyForumPost` (kind `forum_post`, tag `forum_post:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (Web Push still only to bell subscribers, same filter). After a new top-level persist, a note with `#21GiftsShop` and a pin posts once to `POST /map/places` when `mapPush` is set. Boot leaves it unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A blank URL or token also keeps the forum pin, and a failed post logs `ocp.place.failed` and still returns 200. A replace is not this path. After a new top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including the About-me note, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independent of `eligibleToday` (the new row need not itself have media; Spend pays once per Lightning Address; this API may ping again). Replies, and any role other than `verified`, do not welcome-ping. Replies do not ping. A reply calls `notifyForumReply` (kind `forum_reply`, tag `forum_reply:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` — Damus-only parents still fan out; a self-reply skips only the actor. The booted process always has those stores (in-memory without `DATABASE_URL`, Postgres when it is set). Photo-only empty text still notifies; missing `pushStore` still writes in-app rows; notification or push failure still returns 200. It does not copy into the member↔member inbox. Unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on the platform profile note. `verified` stays unpaid-write exempt. Optional JSON `place` `{ lat, lng, label? }` and multipart fields `placeLat`, `placeLng`, `placeLabel`. Omitted place stores nothing and the 200 JSON omits `place`. Invalid place is 400 with the normalizePlace error. A reply with a non-null place is 400 `A reply cannot include a place`. Multipart with exactly one of placeLat/placeLng is 400 `Place must be a latitude and longitude`. Optional `shopUsername` (JSON string or multipart field) assigns a 21.gifts account on a new top-level shop note (`#21GiftsShop`). Omitted, null, blank, or only `@` stores no shop account. A non-string, or a handle `normalizeUsername` rejects, is 400 `Username is not valid`. Unknown username, or a stored username that is missing or blank, is 404 `No account with that username` before the row is created. A reply or a note that is not a shop is 400 `Only a shop note can set a shop account` when the handle is non-blank. After create, `setShopAccount` runs and the 200 JSON includes `shopAccount`; that first assignment does not write `message_edit`. A media replay of an existing live note does not change its shop account. Any account that can post the shop may set it. Later changes stay on staff `PATCH /messages/:id/shop-account`. +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + username + Lightning Address; skip timestamps do not satisfy; username cannot be skipped). JSON `{ text?, photo?: { contentType, data, takenAt? }, photos?: { contentType, data, takenAt? }[], inReplyTo?, goalSats?, goalCurrency?, goalAmount?, goalRepayable?, goalTermDays? }` (`takenAt` is optional `YYYY-MM-DDTHH:MM:SS` with an optional `±HH:MM` offset, a real calendar date, and a year from 1990 through the current UTC year + 1; `Z`, a fractional second, a leap second, a non-string, or a missing value is stored null and does not 400) (`photos` max 10; non-empty `photos` wins over singular `photo`; dual-send uses `photos`) (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), optional JPEG/PNG/WebP `poster`, and optional `goalSats` (string form field) or both `goalCurrency` and `goalAmount` (JSON accepts that same pair: both or neither, not mixed with `goalSats`, not half a pair), plus optional `goalRepayable` (multipart `"true"`; an empty field is absent) and optional `goalTermDays` (multipart digits from 1 to 3650; an empty field is absent). A currency ask freezes `goal_sats` plus `goal_currency`, `goal_amount`, and four `goal_fiat_*` snapshots from the latest gift-day with sats > 0 (same path as `GET /gifts/stats`). Legacy `goalSats` leaves those columns null. JSON omits the new keys when `goalCurrency` is null. Progress fiat is the sum of per-payment snapshots; a null delta does not wipe a stored total. One-time/Daily is not stored. Optional `goalSats` is a whole-sat ask on a top-level note (JSON number; multipart string). Omitted, JSON `null`, or a multipart empty/missing field means no goal. Max 10_000_000; above max is rejected, not clamped. 200 JSON may include `goalSats` or omit the key. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only (singular or `photos`) or video-only allowed; at least one of non-empty trimmed text, photo, non-empty `photos`, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `photoCount` (0–10), `photoTakenAts` (always; length equals `photoCount`; null when unknown; `[]` when there are no stills), `photoTakenAt` only when `photoCount === 1`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped; never `contentFp`). Identical live photo/video from the same account+parent (same normalised text + same media bytes) and the same pin returns the existing row (200, same id) without consuming the 1/10s burst limiter and without a second push; the same media with a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only is unchanged (new row + burst). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates call `notifyForumPost` (kind `forum_post`, tag `forum_post:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (Web Push still only to bell subscribers, same filter). After a new top-level persist, a note with `#21GiftsShop` and a pin posts once to `POST /map/places` when `mapPush` is set. Boot leaves it unset while `SHOP_PLACE_PUSH_ENABLED` is false, so a set URL or token does not post. A blank URL or token also keeps the forum pin, and a failed post logs `ocp.place.failed` and still returns 200. A replace is not this path. After a new top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including the About-me note, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independent of `eligibleToday` (the new row need not itself have media; Spend pays once per Lightning Address; this API may ping again). Replies, and any role other than `verified`, do not welcome-ping. Replies do not ping. A reply calls `notifyForumReply` (kind `forum_reply`, tag `forum_reply:`, url `/messages/`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` — Damus-only parents still fan out; a self-reply skips only the actor. The booted process always has those stores (in-memory without `DATABASE_URL`, Postgres when it is set). Photo-only empty text still notifies; missing `pushStore` still writes in-app rows; notification or push failure still returns 200. It does not copy into the member↔member inbox. Unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on the platform profile note. `verified` stays unpaid-write exempt. Optional JSON `place` `{ lat, lng, label? }` and multipart fields `placeLat`, `placeLng`, `placeLabel`. Omitted place stores nothing and the 200 JSON omits `place`. Invalid place is 400 with the normalizePlace error. A reply with a non-null place is 400 `A reply cannot include a place`. Multipart with exactly one of placeLat/placeLng is 400 `Place must be a latitude and longitude`. Optional `shopUsername` (JSON string or multipart field) assigns a 21.gifts account on a new top-level shop note (`#21GiftsShop`). Omitted, null, blank, or only `@` stores no shop account. A non-string is 400 `Username is not valid`. A reply or a note that is not a shop is 400 `Only a shop note can set a shop account` when the handle is non-blank, before the handle is normalised. A handle `normalizeUsername` rejects is then 400 `Username is not valid`. Unknown username, or a stored username that is missing or blank, is 404 `No account with that username` before the row is created. The account id is stored on the same insert as the note, and the 200 JSON includes `shopAccount`; that first assignment does not write `message_edit`. A media replay of an existing live note does not change its shop account. Any account that can post the shop may set it. Later changes stay on staff `PATCH /messages/:id/shop-account`. - **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, username, and/or Lightning Address are missing (order `rules`, then `name`, then `username`, then `lightning-address`); 400 Expected a JSON body with text and/or photo (including JSON `goalSats` type/range errors); 400 Text must be 1–8000 characters; 400 Text must be 1–8000 characters or include a photo; 400 Text must be 1–8000 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 `{ error: 'At most 10 photos' }` when `photos.length > 10`; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 400 `{ error: 'A reply cannot ask for a goal' }` when `inReplyTo` is set and the body sends `goalSats`, `goalCurrency`, `goalAmount`, `goalRepayable`, or `goalTermDays`; 400 `{ error: 'Ask obligation must be true' }` when `goalRepayable` is present and not JSON `true` or multipart `"true"` (JSON `""` is rejected; a multipart empty field is absent); 400 `{ error: 'A repayment obligation needs an ask' }` when `goalRepayable` is true without an ask; 400 `{ error: 'Ask term must be a whole number of days from 1 to 3650' }` when `goalTermDays` is present and outside that range; 400 `{ error: 'A repayment term needs a repayable ask' }` when a term is sent without `goalRepayable: true`; 400 `{ error: 'A repayable ask needs a term in days' }` when `goalRepayable` is true and the term is absent; JSON includes `goalRepayable` only when true (never false) and `goalTermDays` only when set; 400 `{ error: 'Goal must be a positive whole-sat amount' }` when a multipart `goalSats` is present and not `/^\d+$/` or not an integer 1..10_000_000, and when a BTC `goalAmount` is not an integer 1..10_000_000; 400 `{ error: 'Send either goalSats or both goalCurrency and goalAmount' }` when both styles or only one of the new pair is sent, or `goalAmount` is not a canonical decimal string; 400 `{ error: 'Ask amount is unavailable' }` when a fiat ask has no usable gift-day quote or the frozen sats fall outside 1..10_000_000; 503 `{ error: 'Messages are unavailable' }` when the gift-day loader throws for a fiat ask (a BTC ask still stores the typed sats); 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, soft-hidden (`deletedAt` set), or the parent is itself a reply (`parentId !== null`); 403 `{ error: 'A post needs a Bitcoin payment' }` or `{ error: 'A reply needs a Bitcoin payment' }` when the caller is below `verified` (including the parent author) and the body is text-only; photo or video posts and replies from basis are 200; pay 1 sat to 21.gifts via `GET /messages/compose-target` then `POST /messages/:id/invoice` on that note; 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). 409 `{ error: 'A live note with this media already exists' }` when the same account, parent, and live media fingerprint already exists with a different pin. 400 `{ error: 'Place must be a latitude and longitude' }` when JSON `place` is invalid or multipart has exactly one of placeLat/placeLng; 400 `{ error: 'Place label must be at most 80 characters' }` when the label fails normalizePlace; 400 `{ error: 'A reply cannot include a place' }` when `inReplyTo` is set and place is non-null. 400 `{ error: 'Username is not valid' }` when `shopUsername` is not a usable handle; 404 `{ error: 'No account with that username' }` when that handle matches no account or the stored username is missing or blank; 400 `{ error: 'Only a shop note can set a shop account' }` when a non-blank `shopUsername` is sent on a reply or a note without `#21GiftsShop`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). Pay links, the till, contact, and ordinary private threads are not this error. - **Used by:** App forum composer and reply composer. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index b35e34607..dd184387b 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -346,7 +346,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). Nullable `goal_sats` (optional whole-sat ask; SQL null means no goal), nullable `goal_repayable` (`true` or SQL null, never false), and nullable `goal_term_days` (a whole number from 1 to 3650, or SQL null), plus nullable `goal_currency`, `goal_amount`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php` (null on a reply and on a legacy sats-only ask). `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set. Nullable `place_lat` / `place_lng` / `place_label` (both coordinates or neither; a reply stores null coordinates). `listPlaces` returns live top-level rows that have both coordinates, newest first. Nullable place columns are selected with the other message columns and inserted on both `create` INSERT shapes (top-level `VALUES` and reply `INSERT … SELECT … WHERE EXISTS`); a non-null `parentId` binds `goal_sats`, `goal_repayable`, and `goal_term_days` SQL null even if the row carried a positive `goalSats`, `goalRepayable` true, or a term; `mapMessageRow` maps it to `goalSats` (`null` when SQL null). `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. `markDeleted` soft-hides via a single UPDATE CTE (`deleted_at` / `deleted_by` on the untagged target and untagged direct replies; never `DELETE FROM message`). `markUndeleted` unhides via a single UPDATE CTE (clears `deleted_at` / `deleted_by` on the hidden target and stamp-matched direct replies; already-live target is a no-op for children; never `DELETE FROM message`). Live-only lists/claims require `deleted_at IS NULL`: `listLatest` is **top-level only** (`WHERE parent_id IS NULL AND deleted_at IS NULL`) with subquery `replyCount` (live attributed direct children, `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo`, `deleted_at`, `deleted_by`, and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listFeed` is the GET `/messages` keyset page (`mode` all/active/unpaid/popular, exclusive cursor, optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount`; WHERE also has the name-copy NOT EXISTS (no photo, no video content type, no extra still, non-empty trim, case-insensitive equality with account.name or message.name), not every profile note; a real About me stays; `active` is paid rows, staff unpaid rows, or `COALESCE(goal_sats, 0) > 0`; `popular` is sats-desc); `listReplies` is oldest-first attributed children (`WHERE parent_id = $1` plus the account-or-zapper predicate; `deleted_at IS NULL` unless `includeHidden === true`); `listChildIds` is `SELECT id FROM message WHERE parent_id = $1` (any `deleted_at`); `listDebug` is operator newest-first **all** rows (`SELECT … FROM message ORDER BY created_at DESC, id DESC LIMIT $1`, no `deleted_at` / `parent_id` filter; never `photo` bytea); `postCountsByUtcDay` groups living rows (`deleted_at IS NULL`) by UTC day, notes and replies together (no `parent_id` filter), omits days with no rows, and returns no media bytes; `listHidden` is staff newest-hidden-first **soft-hidden** rows (`SELECT … FROM message WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT $1`; never `photo` bytea); `listDirectChildren` is every direct child including hidden (`SELECT … FROM message WHERE parent_id = $1 ORDER BY created_at ASC, id ASC`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `findLiveByAccountContent` returns the oldest live row for account+parent+`content_fp`; `accountHasLiveTopLevelPost` (`parent_id IS NULL`, exclude profile id, replies do not count); `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video); `latestLiveTopLevelMediaId` (newest live top-level photo or video id, including About me, `ORDER BY created_at DESC, id DESC LIMIT 1`; an empty id is null); `countByAccount` is one `COUNT(*) FILTER` query of live posts (`parent_id IS NULL`) vs replies (`parent_id IS NOT NULL`) for `account_id = $1` and `deleted_at IS NULL` (uncapped; not derived from a list); `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown); `listPostsByAccount` is newest-first live top-level notes for one account (`WHERE parent_id IS NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, subquery `replyCount` of live direct children matching `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`); `listRepliesByAccount` is newest-first live replies for one account (`WHERE parent_id IS NOT NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, no `replyCount`); `create(row, photo?, video?, extraPhotos?)` inserts optional photo bytes, optional extra stills into `message_extra_photo` (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty), optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure), and `content_fp` when media is present and `account_id` is not null; `photoCount` is (photo 0 ? 1 : 0) + extras length; a non-null `parent_id` requires a live parent (`deleted_at` null) via `INSERT … SELECT … WHERE EXISTS`; a 0-row insert calls `getById` and returns that row when the id already exists (gift-reply retry after the parent was later deleted), otherwise throws without inserting; on unique violation `23505` it returns the existing row when `getById` matches the inserted id (no video unlink; gift-reply retry), otherwise unlinks the new video and returns the existing live row from `findLiveByAccountContent` when the pin matches; a different pin throws `place conflicts with live media` (the route maps that to 409); `getPhoto` loads bytes by id; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` load extras from `message_extra_photo`; `getById` / `getByEventId` still return soft-hidden rows; `listIdsByPrefix(prefix)` returns at most two stored ids whose lowercase text starts with the prefix (prefix lowercased, not trimmed), including soft-hidden rows, `SELECT id::text AS id … WHERE lower(id::text) LIKE $1 || '%' LIMIT 2`, never photo bytes; `claimUnsigned`/`claimUnpublished` lease live rows (`deleted_at IS NULL`; `claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns live 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** live rows (`parent_id IS NULL`, `deleted_at 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`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, clears the epoch, increments `nostr_attempts`, and stamps `nostr_first_attempt_at` once, 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); `listRecentOkInvoiceAttempts` (`result = 'ok'` and `created_at >= $1`, same `ORDER BY created_at DESC, id DESC` and `LIMIT` as `listInvoiceAttempts`); `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result = 'ok'` row; description is one message plus the stored invoice description); `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted); `sumUnassignedCreditSats` (sats with no payer account); `listRepayments` and `markRepaymentPaid` (table `message_repayment`; a repeat of the same day and giver is a no-op and does not change `message.sats`); `addSats` and `recordZapReceipt` set `goal_funded_at` once, when a repayable ask with `goal_sats` first reaches that ask, and the schema stamps `goal_funded_at = now()` on an ask that is already full when the column is added; `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice so the same event id may repeat; SQL requires non-null `conversation_id` and `conversation_message_id` and `NOT EXISTS` on `conversation_message`); `updateZapReceiptGift` (`UPDATE nostr_zap_receipt` payer / gift-reply / `comment` columns; omitted patch fields are left unchanged; missing event id is a no-op); `getZapReceiptGift` (one receipt by `event_id`); `listZapReceiptsAwaitingGiftReply` (`(payer_account_id IS NOT NULL OR payer_pubkey IS NOT NULL) AND gift_reply_id IS NULL`, `ORDER BY event_id ASC`, includes `comment`); `recordZapIngest` / `listZapIngests`; `listInvoiceAttemptsForPayer` (uncapped `WHERE payer_account_id = $1`, newest-first); `listIndexedZapIngests` (uncapped `WHERE outcome = 'indexed'`); `updateText` (`UPDATE message SET text = $2 WHERE id = $1 RETURNING …`; sats / photos / event ids unchanged; missing id → no row); `listAuthoredMessages` (`WHERE account_id = $1`, including hidden, no LIMIT). `mapMessageRow` keeps `nostr_publish_state` `skipped` (gift-only replies). +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). Nullable `goal_sats` (optional whole-sat ask; SQL null means no goal), nullable `goal_repayable` (`true` or SQL null, never false), and nullable `goal_term_days` (a whole number from 1 to 3650, or SQL null), plus nullable `goal_currency`, `goal_amount`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php` (null on a reply and on a legacy sats-only ask). `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set. Nullable `place_lat` / `place_lng` / `place_label` (both coordinates or neither; a reply stores null coordinates). `listPlaces` returns live top-level rows that have both coordinates, newest first. Nullable place columns are selected with the other message columns and inserted on both `create` INSERT shapes (top-level `VALUES` and reply `INSERT … SELECT … WHERE EXISTS`); a non-null `parentId` binds `goal_sats`, `goal_repayable`, and `goal_term_days` SQL null even if the row carried a positive `goalSats`, `goalRepayable` true, or a term; `mapMessageRow` maps it to `goalSats` (`null` when SQL null). `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. `markDeleted` soft-hides via a single UPDATE CTE (`deleted_at` / `deleted_by` on the untagged target and untagged direct replies; never `DELETE FROM message`). `markUndeleted` unhides via a single UPDATE CTE (clears `deleted_at` / `deleted_by` on the hidden target and stamp-matched direct replies; already-live target is a no-op for children; never `DELETE FROM message`). Live-only lists/claims require `deleted_at IS NULL`: `listLatest` is **top-level only** (`WHERE parent_id IS NULL AND deleted_at IS NULL`) with subquery `replyCount` (live attributed direct children, `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo`, `deleted_at`, `deleted_by`, and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listFeed` is the GET `/messages` keyset page (`mode` all/active/unpaid/popular, exclusive cursor, optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount`; WHERE also has the name-copy NOT EXISTS (no photo, no video content type, no extra still, non-empty trim, case-insensitive equality with account.name or message.name), not every profile note; a real About me stays; `active` is paid rows, staff unpaid rows, or `COALESCE(goal_sats, 0) > 0`; `popular` is sats-desc); `listReplies` is oldest-first attributed children (`WHERE parent_id = $1` plus the account-or-zapper predicate; `deleted_at IS NULL` unless `includeHidden === true`); `listChildIds` is `SELECT id FROM message WHERE parent_id = $1` (any `deleted_at`); `listDebug` is operator newest-first **all** rows (`SELECT … FROM message ORDER BY created_at DESC, id DESC LIMIT $1`, no `deleted_at` / `parent_id` filter; never `photo` bytea); `postCountsByUtcDay` groups living rows (`deleted_at IS NULL`) by UTC day, notes and replies together (no `parent_id` filter), omits days with no rows, and returns no media bytes; `listHidden` is staff newest-hidden-first **soft-hidden** rows (`SELECT … FROM message WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT $1`; never `photo` bytea); `listDirectChildren` is every direct child including hidden (`SELECT … FROM message WHERE parent_id = $1 ORDER BY created_at ASC, id ASC`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `findLiveByAccountContent` returns the oldest live row for account+parent+`content_fp`; `accountHasLiveTopLevelPost` (`parent_id IS NULL`, exclude profile id, replies do not count); `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video); `latestLiveTopLevelMediaId` (newest live top-level photo or video id, including About me, `ORDER BY created_at DESC, id DESC LIMIT 1`; an empty id is null); `countByAccount` is one `COUNT(*) FILTER` query of live posts (`parent_id IS NULL`) vs replies (`parent_id IS NOT NULL`) for `account_id = $1` and `deleted_at IS NULL` (uncapped; not derived from a list); `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown); `listPostsByAccount` is newest-first live top-level notes for one account (`WHERE parent_id IS NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, subquery `replyCount` of live direct children matching `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`); `listRepliesByAccount` is newest-first live replies for one account (`WHERE parent_id IS NOT NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, no `replyCount`); `create(row, photo?, video?, extraPhotos?)` inserts optional photo bytes, optional extra stills into `message_extra_photo` (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty), optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure), and `content_fp` when media is present and `account_id` is not null; `photoCount` is (photo 0 ? 1 : 0) + extras length; a non-null `parent_id` requires a live parent (`deleted_at` null) via `INSERT … SELECT … WHERE EXISTS`; a 0-row insert calls `getById` and returns that row when the id already exists (gift-reply retry after the parent was later deleted), otherwise throws without inserting; on unique violation `23505` it returns the existing row when `getById` matches the inserted id (no video unlink; gift-reply retry), otherwise unlinks the new video and returns the existing live row from `findLiveByAccountContent` when the pin matches; a different pin throws `place conflicts with live media` (the route maps that to 409); `getPhoto` loads bytes by id; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` load extras from `message_extra_photo`; `getById` / `getByEventId` still return soft-hidden rows; `listIdsByPrefix(prefix)` returns at most two stored ids whose lowercase text starts with the prefix (prefix lowercased, not trimmed), including soft-hidden rows, `SELECT id::text AS id … WHERE lower(id::text) LIKE $1 || '%' LIMIT 2`, never photo bytes; `claimUnsigned`/`claimUnpublished` lease live rows (`deleted_at IS NULL`; `claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns live 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** live rows (`parent_id IS NULL`, `deleted_at 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`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, clears the epoch, increments `nostr_attempts`, and stamps `nostr_first_attempt_at` once, 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); `listRecentOkInvoiceAttempts` (`result = 'ok'` and `created_at >= $1`, same `ORDER BY created_at DESC, id DESC` and `LIMIT` as `listInvoiceAttempts`); `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result = 'ok'` row; description is one message plus the stored invoice description); `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted); `sumUnassignedCreditSats` (sats with no payer account); `listRepayments` and `markRepaymentPaid` (table `message_repayment`; a repeat of the same day and giver is a no-op and does not change `message.sats`); `addSats` and `recordZapReceipt` set `goal_funded_at` once, when a repayable ask with `goal_sats` first reaches that ask, and the schema stamps `goal_funded_at = now()` on an ask that is already full when the column is added; `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice so the same event id may repeat; SQL requires non-null `conversation_id` and `conversation_message_id` and `NOT EXISTS` on `conversation_message`); `updateZapReceiptGift` (`UPDATE nostr_zap_receipt` payer / gift-reply / `comment` columns; omitted patch fields are left unchanged; missing event id is a no-op); `getZapReceiptGift` (one receipt by `event_id`); `listZapReceiptsAwaitingGiftReply` (`(payer_account_id IS NOT NULL OR payer_pubkey IS NOT NULL) AND gift_reply_id IS NULL`, `ORDER BY event_id ASC`, includes `comment`); `recordZapIngest` / `listZapIngests`; `listInvoiceAttemptsForPayer` (uncapped `WHERE payer_account_id = $1`, newest-first); `listIndexedZapIngests` (uncapped `WHERE outcome = 'indexed'`); `updateText` (`UPDATE message SET text = $2 WHERE id = $1 RETURNING …`; sats / photos / event ids unchanged; missing id → no row; an optional history row locks the message and inserts `message_edit` in that same statement only when the text differs); `setPlace` and `setShopAccount` do the same for a pin and `shop_account_id`; `appendEdit` inserts one `message_edit` row and does not change the message; `listEdits` returns that message's rows newest `created_at`, then `id`; `replacePhotos` replaces stills in one data-modifying CTE (primary photo update, video columns untouched, up to nine extra upserts with null bytea slots skipped, then delete extras whose `idx` is above the new count minus one) and returns the row only when the primary update matched; `create` binds `shop_account_id` on both INSERT shapes and binds null for a reply; `listAuthoredMessages` (`WHERE account_id = $1`, including hidden, no LIMIT). `mapMessageRow` keeps `nostr_publish_state` `skipped` (gift-only replies). - **External-zapper storage:** `nostr_zap_receipt` adds nullable `payer_pubkey text` and `zap_request_id text`, with partial unique index `nostr_zap_receipt_request_uidx` on `zap_request_id WHERE zap_request_id IS NOT NULL`. `nostr_zapper` stores durable visibility entitlement as `pubkey` (primary key), `receipt_event_id`, and `created_at`; it is independent of receipt queue state and is not cleared by `deleteById`. `nostr_blocked_pubkey` is the staff kill-switch table with `pubkey` (primary key), `blocked_at`, `blocked_by`, and `message_id`. - **External-zapper methods:** `attributeZapReceipt(receiptEventId, { payerPubkey, zapRequestId, comment })` lowercases and stores the payer pubkey, request id, and comment only when the receipt exists, its current request id is null or the same id, and a `NOT EXISTS` check finds no other receipt with that request id. A retry with the same request id on the same receipt is idempotent `true`; a different request id on an already-attributed receipt, reuse by another receipt, or a concurrent partial-index unique violation returns `false`. `recordZapper(pubkey, receiptEventId, at)` lowercases and inserts an entitlement with `ON CONFLICT (pubkey) DO NOTHING`; `listZapperPubkeys()` returns every entitled pubkey; `listZappers(limit)` returns entitlement rows by `created_at DESC, pubkey DESC`. `blockPubkeyAndHideRows(pubkey, at, byAccountId, messageId)` performs that insert-or-skip and case-insensitively updates every live null-account row from the pubkey in one data-modifying CTE query, returning the number hidden; `unblockPubkeyByMessage(messageId)` deletes block rows with that `message_id` and reports whether any row was deleted; `isPubkeyBlocked(pubkey)` lowercases its input and performs a single-row `SELECT 1` lookup; `isZapperPubkey(pubkey)` lowercases its input and performs a single-row `SELECT 1 FROM nostr_zapper` lookup; `listBlockedPubkeys()` returns every blocked pubkey; `listBlockedPubkeyRows(limit)` returns block rows by `blocked_at DESC, pubkey DESC`. `listUnattributedIndexedReceipts(limit, before?)` joins each otherwise-unattributed receipt to its newest indexed `nostr_zap_ingest` frame (`payer_account_id`, `payer_pubkey`, `zap_request_id`, and `gift_reply_id` all null), orders by immutable ingest `created_at DESC, event_id DESC`, and applies an optional strict `{ createdAt, eventId }` keyset cursor. Unlike an `OFFSET` over a result set whose membership changes as receipts are attributed, the cursor cannot skip or repeat rows for that reason. - **Payment claims:** `claimZapPayment` inserts into `nostr_zap_payment` with `ON CONFLICT (payment_hash) DO NOTHING` and then compares the stored `receipt_event_id`: a new row or the same owner returns `true`, another owner `false`. The table has no foreign key to `message` and is not part of the `deleteById` statement, so the claim outlives the forum row. Insert and lookup failures propagate. @@ -750,7 +750,7 @@ ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Optional `place` is `{ lat, lng, label }` or null; a reply stores `place: null`. A live media match with the same pin returns the existing row; a different pin throws `place conflicts with live media` (the route maps that to 409). `listPlaces` returns live top-level rows that have both coordinates, newest first. Photos live in a private map, not on listed rows. Extra stills (indices 1–9) live in a second private map (`getExtraPhoto` / `listExtraPhotos`); `create(row, photo?, video?, extraPhotos?)` stores extras (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty); `photoCount` is (photo 0 ? 1 : 0) + extras length. Same port as Postgres: `getById` (still returns soft-hidden rows), `listIdsByPrefix(prefix)` (at most two stored ids whose lowercase form starts with the prefix, prefix not trimmed, including soft-hidden rows), `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos, and edit history for those ids), `markDeleted` (stamps `deletedAt` / `deletedBy` on the target and untagged direct replies; never removes media/invoices), `markUndeleted` (clears `deletedAt` / `deletedBy` on the hidden target and stamp-matched direct children; already-live is a no-op for children; never removes media/invoices), `listDirectChildren` (direct children including hidden, createdAt then id), `getByEventId`, `findLiveByAccountContent` (oldest live account+parent+`contentFp`), `accountHasLiveTopLevelPost` (`parentId === null`, exclude profile id, replies do not count), `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video; seed `hasPhoto: true` alone is not media), `latestLiveTopLevelMediaId` (newest live top-level id with a stored photo, extra stills, or video, including About me; replies, hidden rows, and other accounts do not count), live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listFeed` (GET `/messages` keyset page, optional `hashtag`; `createApp` calls `useProfileNoteIds` so `listFeed` omits name-copy ids returned by that provider (a real About me id is not included; `account.profileMessageId`, trimmed, blank ignored); a store with no provider does not omit them), `listReplies` (children with an account or a recorded zapper pubkey; live-only unless `includeHidden === true`), `listChildIds` (direct child ids, any `deletedAt`), `countByAccount` (uncapped live post/reply totals for one account), `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown), live-only `listPostsByAccount` (newest-first top-level for one account, cap, live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listRepliesByAccount` (newest-first replies for one account, cap, no `replyCount`), `listDebug` (operator newest-first **all** rows: top-level and replies, live and soft-hidden), `postCountsByUtcDay` (living rows only, `deletedAt` null, notes and replies together, grouped by UTC day, days with no rows omitted, no media bytes), `listHidden` (staff newest-hidden-first hidden rows only, `deletedAt` desc then `id` desc), `listDirectChildren` (direct children including hidden, createdAt then id), live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `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`), live-only `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, video rows excluded so posters are not treated as missing photos), live-only `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded), live-only `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, clears `nostrPublishEpoch`, increments `nostrAttempts`, and stamps `nostrFirstAttemptAt` once, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set; `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), `listRecentOkInvoiceAttempts` (same filter and order as the Postgres query: `result === 'ok'` and `createdAt >= since`, newest-first with `id` descending tie-break), `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result === 'ok'` by payment hash, BOLT11 `pr`, or one message plus description), `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted), `sumUnassignedCreditSats` (sats with no payer account), `listRepayments` and `markRepaymentPaid` (in-memory shares; a repeat of the same day and giver is a no-op and does not change `sats`), and `goalFundedAt` set once when a repayable ask first reaches `goalSats`, `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice with both conversation id and conversation message id so the same event id may repeat; no `conversation_message` join — existence filter is in `indexOpenZapReceipts`), `updateZapReceiptGift` (patch payer / gift-reply id / comment; missing receipt is a no-op; omitted patch fields stay), `getZapReceiptGift` (one receipt including comment and gift-reply id), `listZapReceiptsAwaitingGiftReply` (`payerAccountId` or `payerPubkey` set and no gift reply yet, cap, `receiptEventId` ASC, includes `comment`), `recordZapIngest` / `listZapIngests`, `listInvoiceAttemptsForPayer` (uncapped payer filter, newest-first), `listIndexedZapIngests` (uncapped, `outcome = indexed` only), `listAuthoredMessages` (all rows for one account including hidden, no cap), `updateText(id, text)` (mutates `text` only and returns a copy; sats / photos / event ids unchanged; missing id → `undefined`); `create` returns the existing row when `id` is already stored (including after that row's parent was later deleted); a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats`, `goalRepayable`, `goalTermDays`, `goalCurrency`, `goalAmount`, and the four goal fiat snapshots null even if the row carried an ask, and throws without appending when the parent is missing or soft-hidden; `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. Optional `place` is `{ lat, lng, label }` or null; a reply stores `place: null`. A live media match with the same pin returns the existing row; a different pin throws `place conflicts with live media` (the route maps that to 409). `listPlaces` returns live top-level rows that have both coordinates, newest first. Photos live in a private map, not on listed rows. Extra stills (indices 1–9) live in a second private map (`getExtraPhoto` / `listExtraPhotos`); `create(row, photo?, video?, extraPhotos?)` stores extras (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty); `photoCount` is (photo 0 ? 1 : 0) + extras length. Same port as Postgres: `getById` (still returns soft-hidden rows), `listIdsByPrefix(prefix)` (at most two stored ids whose lowercase form starts with the prefix, prefix not trimmed, including soft-hidden rows), `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos, and edit history for those ids), `markDeleted` (stamps `deletedAt` / `deletedBy` on the target and untagged direct replies; never removes media/invoices), `markUndeleted` (clears `deletedAt` / `deletedBy` on the hidden target and stamp-matched direct children; already-live is a no-op for children; never removes media/invoices), `listDirectChildren` (direct children including hidden, createdAt then id), `getByEventId`, `findLiveByAccountContent` (oldest live account+parent+`contentFp`), `accountHasLiveTopLevelPost` (`parentId === null`, exclude profile id, replies do not count), `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video; seed `hasPhoto: true` alone is not media), `latestLiveTopLevelMediaId` (newest live top-level id with a stored photo, extra stills, or video, including About me; replies, hidden rows, and other accounts do not count), live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listFeed` (GET `/messages` keyset page, optional `hashtag`; `createApp` calls `useProfileNoteIds` so `listFeed` omits name-copy ids returned by that provider (a real About me id is not included; `account.profileMessageId`, trimmed, blank ignored); a store with no provider does not omit them), `listReplies` (children with an account or a recorded zapper pubkey; live-only unless `includeHidden === true`), `listChildIds` (direct child ids, any `deletedAt`), `countByAccount` (uncapped live post/reply totals for one account), `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown), live-only `listPostsByAccount` (newest-first top-level for one account, cap, live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listRepliesByAccount` (newest-first replies for one account, cap, no `replyCount`), `listDebug` (operator newest-first **all** rows: top-level and replies, live and soft-hidden), `postCountsByUtcDay` (living rows only, `deletedAt` null, notes and replies together, grouped by UTC day, days with no rows omitted, no media bytes), `listHidden` (staff newest-hidden-first hidden rows only, `deletedAt` desc then `id` desc), `listDirectChildren` (direct children including hidden, createdAt then id), live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `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`), live-only `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, video rows excluded so posters are not treated as missing photos), live-only `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded), live-only `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, clears `nostrPublishEpoch`, increments `nostrAttempts`, and stamps `nostrFirstAttemptAt` once, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set; `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), `listRecentOkInvoiceAttempts` (same filter and order as the Postgres query: `result === 'ok'` and `createdAt >= since`, newest-first with `id` descending tie-break), `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result === 'ok'` by payment hash, BOLT11 `pr`, or one message plus description), `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted), `sumUnassignedCreditSats` (sats with no payer account), `listRepayments` and `markRepaymentPaid` (in-memory shares; a repeat of the same day and giver is a no-op and does not change `sats`), and `goalFundedAt` set once when a repayable ask first reaches `goalSats`, `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice with both conversation id and conversation message id so the same event id may repeat; no `conversation_message` join — existence filter is in `indexOpenZapReceipts`), `updateZapReceiptGift` (patch payer / gift-reply id / comment; missing receipt is a no-op; omitted patch fields stay), `getZapReceiptGift` (one receipt including comment and gift-reply id), `listZapReceiptsAwaitingGiftReply` (`payerAccountId` or `payerPubkey` set and no gift reply yet, cap, `receiptEventId` ASC, includes `comment`), `recordZapIngest` / `listZapIngests`, `listInvoiceAttemptsForPayer` (uncapped payer filter, newest-first), `listIndexedZapIngests` (uncapped, `outcome = indexed` only), `listAuthoredMessages` (all rows for one account including hidden, no cap), `updateText(id, text)` (mutates `text` only and returns a copy; sats / photos / event ids unchanged; missing id → `undefined`); `create` returns the existing row when `id` is already stored (including after that row's parent was later deleted); a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats`, `goalRepayable`, `goalTermDays`, `goalCurrency`, `goalAmount`, and the four goal fiat snapshots null even if the row carried an ask, and throws without appending when the parent is missing or soft-hidden; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). `create` keeps a top-level `shopAccount` and stores null on a reply. `updateText`, `setPlace`, and `setShopAccount` take an optional history row and push it only when the value changes, before they return. `appendEdit` stores a copy and does not change the message. `listEdits` returns copies, newest `createdAt` then `id`, or `[]` when the message has none. `replacePhotos` builds the next stills and then swaps the private maps before it returns, so a failure leaves the previous stills. - **External-zapper methods:** `attributeZapReceipt(receiptEventId, { payerPubkey, zapRequestId, comment })` returns `false` when the receipt is missing, when that receipt already has a different request id, or when another receipt in the map already has that request id. A retry with the same request id on the same receipt is idempotent `true`; otherwise it lowercases and stores the payer pubkey, request id, and comment. `recordZapper(pubkey, receiptEventId, at)` lowercases the pubkey and stores the first row in a private map that `deleteById` and receipt queue updates do not clear; `listZapperPubkeys()` returns its keys; `listZappers(limit)` sorts copied rows by `createdAt DESC, pubkey DESC` and caps them. `blockPubkeyAndHideRows(pubkey, at, byAccountId, messageId)` performs the same insert-or-skip and synchronously scans every live null-account row for a case-insensitive author match, stamps it, and returns the hidden count as one store operation; `unblockPubkeyByMessage(messageId)` removes the first matching map entry and reports whether one was found; `isPubkeyBlocked(pubkey)` lowercases its input and checks that map; `isZapperPubkey(pubkey)` lowercases its input and checks the zapper map; `listBlockedPubkeys()` returns the map keys; `listBlockedPubkeyRows(limit)` sorts copied rows by `blockedAt DESC, pubkey DESC` and caps them. `listUnattributedIndexedReceipts(limit, before?)` returns one row per receipt-map entry whose `payerAccountId`, `payerPubkey`, `zapRequestId`, and `giftReplyId` are all null, paired with its newest indexed ingest frame (`createdAt` DESC, then `id` DESC, matching the SQL `JOIN LATERAL … LIMIT 1`), sorts by immutable ingest `createdAt DESC, receiptEventId DESC`, applies an optional strict `{ createdAt, eventId }` keyset cursor and the cap, and returns copies. Unlike an offset over a changing unattributed set, the cursor cannot skip or repeat rows as attribution removes entries. - **Payment claims:** `claimZapPayment` keeps one owner receipt id per lowercase payment hash in a process-local map. The same receipt id may claim again; another id is refused. `deleteById` does not remove the claim, so a re-created message id cannot be credited twice for one payment. - **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false; missing `deletedAt` / `deletedBy` become null). Operator dump: `listExtraPhotoMeta(limit)`, `listZapReceipts(limit)`, and `listZapPayments(limit)` newest-first (cap 200). `listLatest(limit)` is live top-level only with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper. `listFeed(query)` is a live top-level keyset page (`mode` / `limit` / exclusive `cursor` / `staffAccountIds` (`active` only) / optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount` as `listLatest`; `createApp` calls `useProfileNoteIds` so `listFeed` omits name-copy ids returned by that provider (a real About me id is not included; `account.profileMessageId`, trimmed, blank ignored). A store with no provider does not omit them. `active` keeps paid rows, staff unpaid rows, and top-level rows with `goalSats` > 0). `listReplies(parentId, limit?, includeHidden?)` is oldest-first children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (default 200; live-only unless `includeHidden === true`). `listChildIds(parentId)` returns direct child ids (any `deletedAt`). `countByAccount(accountId)` is uncapped live `{ postCount, replyCount }` for that author. `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown). `listPostsByAccount(accountId, limit)` is newest-first live top-level for that author with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (cap). `listRepliesByAccount(accountId, limit)` is newest-first live replies for that author (cap, no `replyCount`). `listDebug(limit)` is newest-first all rows including hidden and replies. `postCountsByUtcDay()` groups living rows (`deletedAt` null), notes and replies together, by UTC day and omits empty days. `listHidden(limit)` is newest-hidden-first hidden rows only (`deletedAt` desc, then `id` desc). `listPublishedEventIds(limit)` is newest-first non-null live top-level `eventId`s. `create(row, photo?, video?, extraPhotos?)` returns the stored row when `id` is already present (no append, no second video write) even if that row's parent was later deleted; a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats`, `goalRepayable`, `goalTermDays`, `goalCurrency`, `goalAmount`, and the four goal fiat snapshots null even if the row carried an ask, and throws without appending when the parent is missing or soft-hidden; otherwise appends a copy, or returns the existing live media match without a second video write when the pin matches; a different pin throws `place conflicts with live media`; extras indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty; `getPhoto(id)` returns a photo copy or null; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` return extra stills from the private map; `photoCount` is (photo 0 ? 1 : 0) + extras length; `markDeleted(id, at, byAccountId)` returns false when missing; `markUndeleted(id)` returns false when missing. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 4bde262c2..6bdc4d7b5 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -1069,7 +1069,7 @@ describe('InMemoryMessageStore', () => { expect(await store.listEdits('m2')).toHaveLength(1); }); - it('create leaves shopAccount null and copyRow copies a snapshot', async () => { + it('create keeps a top-level shop account and copyRow copies a snapshot', async () => { const seeded = new InMemoryMessageStore([ EARLY, { @@ -1094,7 +1094,12 @@ describe('InMemoryMessageStore', () => { id: 'created-shop', shopAccount: { id: 'a', username: 'ada', name: 'Ada' }, }); - expect((await store.getById('created-shop'))?.shopAccount).toBeNull(); + const createdShop = await store.getById('created-shop'); + expect(createdShop?.shopAccount).toEqual({ id: 'a', username: 'ada', name: 'Ada' }); + if (createdShop?.shopAccount) { + createdShop.shopAccount.name = 'mutated'; + } + expect((await store.getById('created-shop'))?.shopAccount?.name).toBe('Ada'); }); it('replyCount and worker scans omit soft-deleted rows', async () => { @@ -2537,6 +2542,76 @@ describe('InMemoryMessageStore', () => { expect(await store.listExtraPhotos('a')).toEqual([]); }); + it('create keeps a top-level shop account and clears one on a reply', async () => { + const store = new InMemoryMessageStore(); + const account = { id: 'shop-acc', username: 'luna', name: 'Luna' }; + const shop = await store.create({ + ...EARLY, + id: 'shop', + text: 'Cafe #21GiftsShop', + shopAccount: account, + }); + expect(shop.shopAccount).toEqual(account); + const reply = await store.create({ + ...EARLY, + id: 'reply', + parentId: 'shop', + shopAccount: account, + }); + expect(reply.shopAccount).toBeNull(); + }); + + it('writes text, place, and shop-account history only when the value changes', async () => { + const store = new InMemoryMessageStore(); + await store.create({ ...EARLY, text: 'Cafe #21GiftsShop' }); + const textEdit = { + id: 'e-text', + messageId: 'a', + actorId: 'staff', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + field: 'text' as const, + before: 'Cafe #21GiftsShop', + after: 'Cafe Sol #21GiftsShop', + }; + expect(await store.updateText('missing', 'x', textEdit)).toBeUndefined(); + expect(await store.listEdits('a')).toEqual([]); + await store.updateText('a', 'Cafe #21GiftsShop', textEdit); + expect(await store.listEdits('a')).toEqual([]); + await store.updateText('a', 'Cafe Sol #21GiftsShop', textEdit); + expect((await store.listEdits('a')).map((item) => item.id)).toEqual(['e-text']); + const placeEdit = { + id: 'e-place', + messageId: 'a', + actorId: 'staff', + createdAt: new Date('2026-08-03T00:00:00.000Z'), + field: 'place' as const, + before: null, + after: { lat: 1, lng: 2, label: 'Luna' }, + }; + expect(await store.setPlace('missing', null, placeEdit)).toBe(false); + await store.setPlace('a', null, placeEdit); + expect(await store.listEdits('a')).toHaveLength(1); + await store.setPlace('a', { lat: 1, lng: 2, label: 'Luna' }, placeEdit); + const account = { id: 'shop-acc', username: 'luna', name: 'Luna' }; + const accountEdit = { + id: 'e-shop', + messageId: 'a', + actorId: 'staff', + createdAt: new Date('2026-08-04T00:00:00.000Z'), + field: 'shop_account' as const, + before: null, + after: account, + }; + expect(await store.setShopAccount('missing', account, accountEdit)).toBe(false); + await store.setShopAccount('a', null, accountEdit); + await store.setShopAccount('a', account, accountEdit); + expect((await store.listEdits('a')).map((item) => item.field)).toEqual([ + 'shop_account', + 'place', + 'text', + ]); + }); + it('pads a listed photo that has no stored capture time', async () => { const store = new InMemoryMessageStore([{ ...EARLY, hasPhoto: true }]); expect((await store.getById('a'))?.photoTakenAts).toEqual([null]); @@ -5057,10 +5132,10 @@ describe('PostgresMessageStore', () => { }; const created = await store.create(row); expect(sql.executes[0]?.text).toMatch( - /INSERT INTO message \(\s*id, account_id, name, text, photo, photo_content_type, video_content_type, created_at,\s*nostr_publish_state, sats, parent_id, author_pubkey, event_id, nostr_event, content_fp, goal_sats,\s*fiat_usd, fiat_chf, fiat_eur, fiat_php, photo_taken_at, video_taken_at,\s*place_lat, place_lng, place_label,\s*goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days\s*\)/, + /INSERT INTO message \(\s*id, account_id, name, text, photo, photo_content_type, video_content_type, created_at,\s*nostr_publish_state, sats, parent_id, author_pubkey, event_id, nostr_event, content_fp, goal_sats,\s*fiat_usd, fiat_chf, fiat_eur, fiat_php, photo_taken_at, video_taken_at,\s*place_lat, place_lng, place_label,\s*goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days,\s*shop_account_id\s*\)/, ); expect(sql.executes[0]?.text).toMatch( - /\$14::jsonb,\$15,\$16,\s*\$17::numeric,\$18::numeric,\$19::numeric,\$20::numeric,\$21,\$22,\$23,\$24,\$25,\s*\$26,\$27::numeric,\$28::numeric,\$29::numeric,\$30::numeric,\$31::numeric,\$32,\$33/, + /\$14::jsonb,\$15,\$16,\s*\$17::numeric,\$18::numeric,\$19::numeric,\$20::numeric,\$21,\$22,\$23,\$24,\$25,\s*\$26,\$27::numeric,\$28::numeric,\$29::numeric,\$30::numeric,\$31::numeric,\$32,\$33,\$34/, ); expect(sql.executes[0]?.text).not.toMatch(/ON CONFLICT/i); expect(sql.executes[0]?.params).toEqual([ @@ -5097,8 +5172,9 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); - expect(sql.executes[0]?.params).toHaveLength(33); + expect(sql.executes[0]?.params).toHaveLength(34); expect(created.id).toBe(row.id); expect(created.hasVideo).toBe(false); expect(created.goalSats).toBeNull(); @@ -5134,6 +5210,7 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); }); @@ -5170,6 +5247,7 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); expect(created.goalSats).toBe(21000); expect(created.goalRepayable).toBeNull(); @@ -5315,10 +5393,10 @@ describe('PostgresMessageStore', () => { const created = await store.create(row); expect(sql.executes).toEqual([]); expect(sql.queries[0]?.text).toMatch( - /INSERT INTO message \(\s*id, account_id, name, text, photo, photo_content_type, video_content_type, created_at,\s*nostr_publish_state, sats, parent_id, author_pubkey, event_id, nostr_event, content_fp, goal_sats,\s*fiat_usd, fiat_chf, fiat_eur, fiat_php, photo_taken_at, video_taken_at,\s*place_lat, place_lng, place_label,\s*goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days\s*\)/, + /INSERT INTO message \(\s*id, account_id, name, text, photo, photo_content_type, video_content_type, created_at,\s*nostr_publish_state, sats, parent_id, author_pubkey, event_id, nostr_event, content_fp, goal_sats,\s*fiat_usd, fiat_chf, fiat_eur, fiat_php, photo_taken_at, video_taken_at,\s*place_lat, place_lng, place_label,\s*goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days,\s*shop_account_id\s*\)/, ); expect(sql.queries[0]?.text).toMatch( - /SELECT \$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8,\$9,\$10,\$11,\$12,\$13,\$14::jsonb,\$15,\$16,\s*\$17::numeric,\$18::numeric,\$19::numeric,\$20::numeric,\$21,\$22,\$23,\$24,\$25,\s*\$26,\$27::numeric,\$28::numeric,\$29::numeric,\$30::numeric,\$31::numeric,\$32,\$33/, + /SELECT \$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8,\$9,\$10,\$11,\$12,\$13,\$14::jsonb,\$15,\$16,\s*\$17::numeric,\$18::numeric,\$19::numeric,\$20::numeric,\$21,\$22,\$23,\$24,\$25,\s*\$26,\$27::numeric,\$28::numeric,\$29::numeric,\$30::numeric,\$31::numeric,\$32,\$33,\$34/, ); expect(sql.queries[0]?.text).toMatch( /WHERE EXISTS \(SELECT 1 FROM message p WHERE p\.id = \$11 AND p\.deleted_at IS NULL\)/, @@ -5359,8 +5437,9 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); - expect(sql.queries[0]?.params).toHaveLength(33); + expect(sql.queries[0]?.params).toHaveLength(34); expect(created.id).toBe('child-1'); expect(created.parentId).toBe('parent-1'); }); @@ -5400,6 +5479,7 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); expect(created.goalSats).toBeNull(); expect(created.goalRepayable).toBeNull(); @@ -5437,6 +5517,7 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); expect(created.place).toEqual({ lat: 47.3, lng: 8.5, label: 'Zürich' }); }); @@ -5475,6 +5556,7 @@ describe('PostgresMessageStore', () => { null, null, null, + null, ]); expect(created.place).toBeNull(); }); @@ -6792,6 +6874,114 @@ describe('PostgresMessageStore', () => { expect(sql.queries[0]?.params).toEqual(['m1', 'bio']); sql.nextRows = []; expect(await store.updateText('missing', 'x')).toBeUndefined(); + sql.nextRows = [ + { + id: 'm1', + account_id: 'acc', + name: 'Ada', + text: 'next', + created_at: new Date(0), + has_photo: false, + sats: 21, + }, + ]; + const edited = await store.updateText('m1', 'next', { + id: 'e1', + messageId: 'm1', + actorId: 'staff', + createdAt: new Date(0), + field: 'text', + before: 'bio', + after: 'next', + }); + expect(edited?.text).toBe('next'); + const history = sql.queries.at(-1); + expect(history?.text).toMatch(/FOR UPDATE/); + expect(history?.text).toMatch(/locked\.text IS DISTINCT FROM \$2/); + expect(history?.text).toMatch(/INSERT INTO message_edit/); + expect(history?.params?.[2]).toBe('e1'); + expect(sql.queries).toHaveLength(3); + expect(sql.executes).toEqual([]); + sql.nextRows = []; + expect( + await store.updateText('missing', 'gone', { + id: 'e-miss', + messageId: 'missing', + actorId: 'staff', + createdAt: new Date(0), + field: 'text', + before: 'bio', + after: 'gone', + }), + ).toBeUndefined(); + }); + + it('setPlace and setShopAccount write history in the same statement', async () => { + const sql = new MockSql(); + sql.nextRows = [{ id: 'm1' }]; + const store = new PostgresMessageStore(sql); + const changed = await store.setPlace( + 'm1', + { lat: 1, lng: 2, label: null }, + { + id: 'e-place', + messageId: 'm1', + actorId: 'staff', + createdAt: new Date(0), + field: 'place', + before: null, + after: { lat: 1, lng: 2, label: null }, + }, + ); + expect(changed).toBe(true); + expect(sql.queries[0]?.text).toMatch(/place_lat IS DISTINCT FROM \$2/); + expect(sql.queries[0]?.text).toMatch(/INSERT INTO message_edit/); + expect(sql.executes).toEqual([]); + sql.nextRows = []; + expect( + await store.setShopAccount('missing', null, { + id: 'e-shop', + messageId: 'missing', + actorId: 'staff', + createdAt: new Date(0), + field: 'shop_account', + before: { id: 'shop-acc', username: 'luna', name: 'Luna' }, + after: null, + }), + ).toBe(false); + expect(sql.queries.at(-1)?.text).toMatch(/shop_account_id IS DISTINCT FROM \$2/); + expect(sql.queries.at(-1)?.text).toMatch(/INSERT INTO message_edit/); + }); + + it('create binds shop_account_id on a top-level note and null on a reply', async () => { + const sql = new MockSql(); + const store = new PostgresMessageStore(sql); + const account = { id: 'shop-acc', username: 'luna', name: 'Luna' }; + await store.create({ + id: 'm1', + accountId: 'acc', + name: 'Ada', + text: 'Cafe #21GiftsShop', + createdAt: new Date('2026-08-28T12:00:00.000Z'), + hasPhoto: false, + ...unsignedNostrDefaults(), + shopAccount: account, + }); + expect(sql.executes[0]?.text).toMatch(/shop_account_id/); + expect(sql.executes[0]?.params?.[33]).toBe('shop-acc'); + sql.nextRows = [{ id: 'child-shop' }]; + await store.create({ + id: 'child-shop', + accountId: 'acc', + name: 'Ada', + text: 'reply', + createdAt: new Date('2026-08-28T12:00:00.000Z'), + hasPhoto: false, + ...unsignedNostrDefaults(), + parentId: 'parent-1', + shopAccount: account, + }); + expect(sql.queries.at(-1)?.params?.[33]).toBeNull(); }); it('updatePhoto issues UPDATE … RETURNING and maps the row', async () => { @@ -6846,7 +7036,7 @@ describe('PostgresMessageStore', () => { expect(await store.updatePhoto('missing', JPEG)).toBeUndefined(); }); - it('replacePhotos writes the new stills before it drops leftover extras', async () => { + it('replacePhotos writes the new stills and drops leftover extras in one statement', async () => { const sql = new MockSql(); const row = { id: 'm1', @@ -6860,7 +7050,7 @@ describe('PostgresMessageStore', () => { nostr_publish_state: 'published', sats: 21, }; - sql.queryQueue = [[row], [row], [row]]; + sql.queryQueue = [[row]]; const store = new PostgresMessageStore(sql); const updated = await store.replacePhotos('m1', [ { ...JPEG, takenAt: '2020-01-01T00:00:00+00:00' }, @@ -6868,33 +7058,47 @@ describe('PostgresMessageStore', () => { { ...JPEG2, takenAt: '2020-01-02T00:00:00+00:00' }, ]); expect(updated?.id).toBe('m1'); - expect(sql.queries[1]?.text).toMatch(/UPDATE message SET photo/); - expect(sql.queries[1]?.params?.[3]).toBe('2020-01-01T00:00:00+00:00'); - expect(sql.executes[0]?.text).toMatch(/ON CONFLICT \(message_id, idx\) DO UPDATE/); - expect(sql.executes[0]?.params?.[4]).toBeNull(); - expect(sql.executes[1]?.params?.[4]).toBe('2020-01-02T00:00:00+00:00'); - expect(sql.executes[2]?.text).toMatch(/idx > \$2/); - expect(sql.executes[2]?.params).toEqual(['m1', 2]); - sql.queryQueue = [[row], [row], []]; - const fallenBack = await store.replacePhotos('m1', [JPEG]); - expect(fallenBack?.id).toBe('m1'); - expect(sql.queries.at(-2)?.params?.[3]).toBeNull(); - expect(sql.executes.at(-1)?.params).toEqual(['m1', 0]); + expect(sql.executes).toEqual([]); + expect(sql.queries).toHaveLength(1); + const statement = sql.queries[0]?.text ?? ''; + expect(statement).toMatch(/UPDATE message\s+SET photo = \$2/); + expect(statement.slice(0, statement.indexOf('SELECT id, account_id'))).not.toMatch( + /video_content_type/, + ); + expect(statement).toMatch(/ON CONFLICT \(message_id, idx\) DO UPDATE/); + expect(statement).toMatch(/extra\.idx > \$32/); + expect(statement.indexOf('UPDATE message')).toBeLessThan( + statement.indexOf('INSERT INTO message_extra_photo'), + ); + expect(statement.indexOf('INSERT INTO message_extra_photo')).toBeLessThan( + statement.indexOf('DELETE FROM message_extra_photo'), + ); + expect(sql.queries[0]?.params?.[3]).toBe('2020-01-01T00:00:00+00:00'); + expect(sql.queries[0]?.params?.[6]).toBeNull(); + expect(sql.queries[0]?.params?.[9]).toBe('2020-01-02T00:00:00+00:00'); + expect(sql.queries[0]?.params?.at(-1)).toBe(2); + sql.queryQueue = [[row]]; + const one = await store.replacePhotos('m1', [JPEG]); + expect(one?.id).toBe('m1'); + expect(sql.queries.at(-1)?.params?.[3]).toBeNull(); + expect(sql.queries.at(-1)?.params?.at(-1)).toBe(0); const missed = new MockSql(); - missed.queryQueue = [[row], []]; + missed.queryQueue = [[]]; expect(await new PostgresMessageStore(missed).replacePhotos('m1', [])).toBeUndefined(); expect(missed.executes).toEqual([]); + expect(missed.queries[0]?.params?.at(-1)).toBe(-1); + expect(missed.queries[0]?.text).toMatch(/DELETE FROM message_extra_photo/); sql.queryQueue = [[]]; expect(await store.replacePhotos('missing', [JPEG])).toBeUndefined(); const failing = new MockSql(); - failing.queryQueue = [[row], [row]]; - failing.executeError = new Error('extra'); + failing.queryError = new Error('extra'); await expect( new PostgresMessageStore(failing).replacePhotos('m1', [JPEG, JPEG2]), ).rejects.toThrow('extra'); - expect(failing.executes).toHaveLength(1); - expect(failing.executes[0]?.text).toMatch(/ON CONFLICT/); - expect(failing.executes.some((item) => item.text.includes('DELETE'))).toBe(false); + expect(failing.executes).toEqual([]); + expect(failing.queries).toHaveLength(1); + expect(failing.queries[0]?.text).toMatch(/ON CONFLICT/); + expect(failing.queries[0]?.text).toMatch(/DELETE FROM message_extra_photo/); }); it('getById maps nostr_event JSON string', async () => { @@ -8601,6 +8805,7 @@ describe('message fiat accumulator SQL', () => { null, null, null, + null, ]); sql.nextRows = [{ id: 'child-cur' }]; await store.create({ @@ -8620,6 +8825,7 @@ describe('message fiat accumulator SQL', () => { null, null, null, + null, ]); sql.nextRows = [ { diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 29ddd0195..6f990f897 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -1269,6 +1269,7 @@ describe('POST /messages', () => { const auth = await namedStore('Ada'); await withLuna(auth); const messages = new InMemoryMessageStore(); + const setShop = vi.spyOn(messages, 'setShopAccount'); const res = await mount(auth, messages).request('/messages', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, @@ -1280,6 +1281,8 @@ describe('POST /messages', () => { shopAccount?: { id: string; username: string; name: string }; }; expect(created.shopAccount).toEqual({ id: 'shop-acc', username: 'luna', name: 'Luna' }); + expect((await messages.getById(created.id))?.shopAccount).toEqual(created.shopAccount); + expect(setShop).not.toHaveBeenCalled(); expect(await messages.listEdits(created.id)).toEqual([]); }); @@ -1433,11 +1436,11 @@ describe('POST /messages', () => { expect(await messages.listEdits(firstId)).toEqual([]); }); - it('returns 503 when storing the shop account fails after create', async () => { + it('returns 503 and leaves no note when create throws', async () => { const auth = await namedStore('Ada'); await withLuna(auth); const messages = new InMemoryMessageStore(); - vi.spyOn(messages, 'setShopAccount').mockResolvedValue(false); + vi.spyOn(messages, 'create').mockRejectedValue(new Error('boom')); warn.mockClear(); const res = await mount(auth, messages).request('/messages', { method: 'POST', @@ -1449,33 +1452,7 @@ describe('POST /messages', () => { expect(parsedEvents(warn).some((event) => event['event'] === 'messages.create.failed')).toBe( true, ); - expect((await messages.listLatest(10))[0]?.shopAccount).toBeNull(); - }); - - it('returns 503 when the shop row disappears after the account write', async () => { - const auth = await namedStore('Ada'); - await withLuna(auth); - const messages = new InMemoryMessageStore(); - vi.spyOn(messages, 'getById').mockResolvedValue(undefined); - const res = await mount(auth, messages).request('/messages', { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'luna' }), - }); - expect(res.status).toBe(503); - }); - - it('returns 503 when setShopAccount throws', async () => { - const auth = await namedStore('Ada'); - await withLuna(auth); - const messages = new InMemoryMessageStore(); - vi.spyOn(messages, 'setShopAccount').mockRejectedValue(new Error('boom')); - const res = await mount(auth, messages).request('/messages', { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ text: 'Cafe #21GiftsShop', shopUsername: 'luna' }), - }); - expect(res.status).toBe(503); + expect(await messages.listLatest(10)).toHaveLength(0); }); it('assigns shopUsername from a multipart shop note', async () => { @@ -3563,8 +3540,8 @@ describe('POST /messages', () => { deleteById: (id) => base.deleteById(id), markDeleted: (id, at, by) => base.markDeleted(id, at, by), markUndeleted: (id) => base.markUndeleted(id), - setPlace: (id, place) => base.setPlace(id, place), - setShopAccount: (id, account) => base.setShopAccount(id, account), + setPlace: (...args) => base.setPlace(...args), + setShopAccount: (...args) => base.setShopAccount(...args), appendEdit: (row) => base.appendEdit(row), listEdits: (messageId) => base.listEdits(messageId), getById: (id) => base.getById(id), @@ -3578,7 +3555,7 @@ describe('POST /messages', () => { listSignedMissingVideo: (limit) => base.listSignedMissingVideo(limit), listSignedMissingHashtags: (limit) => base.listSignedMissingHashtags(limit), resetSignedEvent: (id, expected) => base.resetSignedEvent(id, expected), - updateText: (id, text) => base.updateText(id, text), + updateText: (...args) => base.updateText(...args), updatePhoto: (id, photo) => base.updatePhoto(id, photo), updateSignedEvent: (id, eventId, nostrEvent) => base.updateSignedEvent(id, eventId, nostrEvent), @@ -3687,8 +3664,8 @@ describe('POST /messages', () => { deleteById: (id) => base.deleteById(id), markDeleted: (id, at, by) => base.markDeleted(id, at, by), markUndeleted: (id) => base.markUndeleted(id), - setPlace: (id, place) => base.setPlace(id, place), - setShopAccount: (id, account) => base.setShopAccount(id, account), + setPlace: (...args) => base.setPlace(...args), + setShopAccount: (...args) => base.setShopAccount(...args), appendEdit: (row) => base.appendEdit(row), listEdits: (messageId) => base.listEdits(messageId), getById: (id) => base.getById(id), @@ -3702,7 +3679,7 @@ describe('POST /messages', () => { listSignedMissingVideo: (limit) => base.listSignedMissingVideo(limit), listSignedMissingHashtags: (limit) => base.listSignedMissingHashtags(limit), resetSignedEvent: (id, expected) => base.resetSignedEvent(id, expected), - updateText: (id, text) => base.updateText(id, text), + updateText: (...args) => base.updateText(...args), updatePhoto: (id, photo) => base.updatePhoto(id, photo), updateSignedEvent: (id, eventId, nostrEvent) => base.updateSignedEvent(id, eventId, nostrEvent), @@ -5346,8 +5323,8 @@ describe('POST /messages/:id/invoice', () => { deleteById: (id) => base.deleteById(id), markDeleted: (id, at, by) => base.markDeleted(id, at, by), markUndeleted: (id) => base.markUndeleted(id), - setPlace: (id, place) => base.setPlace(id, place), - setShopAccount: (id, account) => base.setShopAccount(id, account), + setPlace: (...args) => base.setPlace(...args), + setShopAccount: (...args) => base.setShopAccount(...args), appendEdit: (row) => base.appendEdit(row), listEdits: (messageId) => base.listEdits(messageId), getByEventId: (id) => base.getByEventId(id), @@ -11583,14 +11560,18 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { expect(parsedEvents(warn).some((event) => event['event'] === 'messages.text.failed')).toBe( true, ); + const seen: unknown[][] = []; const historyFailed = await mount( auth, throwingStore({ getById: async () => ({ ...row, text: 'Cafe\n\n#21GiftsShop' }), - updateText: async () => ({ ...row, text: 'Next\n\n#21GiftsShop' }), - appendEdit: async () => { + updateText: async (...args: unknown[]) => { + seen.push(args); throw new Error('boom'); }, + appendEdit: async () => { + throw new Error('append should not run'); + }, }), ).request('/messages/' + SHOP_ID + '/text', { method: 'PATCH', @@ -11598,6 +11579,7 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { body: JSON.stringify({ text: 'Next' }), }); expect(historyFailed.status).toBe(503); + expect(seen[0]?.[2]).toMatchObject({ field: 'text', before: row.text }); let reads = 0; const gone = await mount( auth, @@ -11716,6 +11698,16 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { }) ).status, ).toBe(400); + const missingPhoto = await patchPhotos( + auth, + '11111111-1111-4111-8111-111111111111', + { photos: [{ contentType: 'image/jpeg', data: 'not-a-photo' }] }, + messages, + ); + expect(missingPhoto.status).toBe(400); + expect(await missingPhoto.json()).toEqual({ + error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB', + }); await messages.create({ id: REPLY_ID, accountId: 'acc', @@ -11730,6 +11722,16 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { expect(reply.status).toBe(400); expect(await reply.json()).toEqual({ error: 'A reply cannot be edited' }); await messages.markDeleted(SHOP_ID, new Date(now()), 'acc'); + const hiddenPhoto = await patchPhotos( + auth, + SHOP_ID, + { photos: [{ contentType: 'image/jpeg', data: 'not-a-photo' }] }, + messages, + ); + expect(hiddenPhoto.status).toBe(400); + expect(await hiddenPhoto.json()).toEqual({ + error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB', + }); expect((await patchPhotos(auth, SHOP_ID, { photos: [] }, messages)).status).toBe(404); await messages.create({ id: PLAIN_ID, diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 679061cdb..1e91b683c 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -646,10 +646,12 @@ export interface MessageStore { * * @param id - Message id. * @param place - Pin to store, or `null` to store SQL NULL / `place: null`. + * @param edit - When set, and the pin actually changes, the history row is + * written in the same step as the pin. An unchanged pin writes no history. * @returns `false` when no row has that id; `true` when the three columns * were written. */ - setPlace(id: string, place: ForumPlace | null): Promise; + setPlace(id: string, place: ForumPlace | null, edit?: MessageEditRow): Promise; /** * Set, replace, or clear the linked 21.gifts shop account. Writes only @@ -658,11 +660,14 @@ export interface MessageStore { * * @param id - Message id. * @param account - Account snapshot to store, or `null` to clear. + * @param edit - When set, and the account actually changes, the history row + * is written in the same step. An unchanged account writes no history. * @returns `false` when no row has that id; `true` when the column was written. */ setShopAccount( id: string, account: { id: string; username: string; name: string } | null, + edit?: MessageEditRow, ): Promise; /** @@ -812,9 +817,11 @@ export interface MessageStore { * * @param id - Message id. * @param text - New body (already normalised; may be empty). + * @param edit - When set, and the body actually changes, the history row is + * written in the same step. An unchanged body writes no history. * @returns The updated row copy, or `undefined` when no row has that id. */ - updateText(id: string, text: string): Promise; + updateText(id: string, text: string, edit?: MessageEditRow): Promise; /** * Replace or clear the stored photo. Does not change text, sats, or event ids. @@ -1895,6 +1902,18 @@ function cloneEditValue(value: unknown): unknown { return JSON.parse(JSON.stringify(value)); } +/** Whether two shop-account snapshots are the same assignment. */ +function shopSnapshotsMatch( + a: { id: string; username: string; name: string } | null | undefined, + b: { id: string; username: string; name: string } | null, +): boolean { + const left = a ?? null; + if (left === null || b === null) { + return left === b; + } + return left.id === b.id && left.username === b.username && left.name === b.name; +} + /** Copy one history row (cloned `createdAt` and jsonb values). */ function copyEdit(row: MessageEditRow): MessageEditRow { return { @@ -2497,9 +2516,9 @@ export class InMemoryMessageStore implements MessageStore { }); applyStoredGoal(stored); stored.place = stored.parentId !== null ? null : (stored.place ?? null); - // Create does not assign a shop account. - stored.shopAccount = null; + // A reply never stores a shop account. A top-level note keeps the one on the row. if (stored.parentId !== null) { + stored.shopAccount = null; const parent = this.#rows.find((item) => item.id === stored.parentId); if (parent === undefined || parent.deletedAt !== null) { throw new Error('parent missing or deleted'); @@ -2951,12 +2970,16 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(); } - updateText(id: string, text: string): Promise { + updateText(id: string, text: string, edit?: MessageEditRow): Promise { const row = this.#rows.find((item) => item.id === id); if (row === undefined) { return Promise.resolve(undefined); } + const changed = row.text !== text; row.text = text; + if (edit !== undefined && changed) { + this.#edits.push(copyEdit(edit)); + } return Promise.resolve(copyRow(row)); } @@ -2987,18 +3010,21 @@ export class InMemoryMessageStore implements MessageStore { if (row === undefined) { return Promise.resolve(undefined); } - this.#photos.delete(id); - this.#extraPhotos.delete(id); - const first = photos[0]; - if (first !== undefined) { - this.#photos.set(id, copyPhoto(first)); - row.hasPhoto = true; - } else { + const kept = photos.slice(0, 10); + const first = kept[0]; + const nextPrimary = first === undefined ? undefined : copyPhoto(first); + const nextExtras = kept.slice(1).map((item) => copyPhoto(item)); + if (nextPrimary === undefined) { + this.#photos.delete(id); row.hasPhoto = false; + } else { + this.#photos.set(id, nextPrimary); + row.hasPhoto = true; } - const extras = photos.slice(1).map((item) => copyPhoto(item)); - if (extras.length > 0) { - this.#extraPhotos.set(id, extras); + if (nextExtras.length > 0) { + this.#extraPhotos.set(id, nextExtras); + } else { + this.#extraPhotos.delete(id); } const storedExtras = this.#extraPhotos.get(id) ?? []; row.photoCount = (first !== undefined ? 1 : 0) + storedExtras.length; @@ -3772,25 +3798,36 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(true); } - setPlace(id: string, place: ForumPlace | null): Promise { + setPlace(id: string, place: ForumPlace | null, edit?: MessageEditRow): Promise { const row = this.#rows.find((item) => item.id === id); if (row === undefined) { return Promise.resolve(false); } - row.place = place === null ? null : { lat: place.lat, lng: place.lng, label: place.label }; + const next = place === null ? null : { lat: place.lat, lng: place.lng, label: place.label }; + const changed = !placesMatch(row.place ?? null, next); + row.place = next; + if (edit !== undefined && changed) { + this.#edits.push(copyEdit(edit)); + } return Promise.resolve(true); } setShopAccount( id: string, account: { id: string; username: string; name: string } | null, + edit?: MessageEditRow, ): Promise { const row = this.#rows.find((item) => item.id === id); if (row === undefined) { return Promise.resolve(false); } - row.shopAccount = + const next = account === null ? null : { id: account.id, username: account.username, name: account.name }; + const changed = !shopSnapshotsMatch(row.shopAccount, next); + row.shopAccount = next; + if (edit !== undefined && changed) { + this.#edits.push(copyEdit(edit)); + } return Promise.resolve(true); } @@ -4676,7 +4713,9 @@ export class PostgresMessageStore implements MessageStore { * SQL null even when the row carried a positive `goalSats`, bind * `goal_repayable` SQL null even when the row carried `true`, bind * `goal_term_days` SQL null even when the row carried a term, and bind place - * columns SQL null even when the row carried a pin. A 0-row insert calls + * columns and `shop_account_id` SQL null even when the row carried a pin or + * a shop account. A top-level note stores `row.shopAccount`'s id on the same + * insert. A 0-row insert calls * `getById(stored.id)` and returns that row when present (gift-reply retry * after the parent was later deleted); otherwise throws, no insert. On unique * violation (`23505`), if `getById(stored.id)` matches that id, return that @@ -4738,8 +4777,10 @@ export class PostgresMessageStore implements MessageStore { }); applyStoredGoal(stored); stored.place = stored.parentId !== null ? null : (stored.place ?? null); - // Create does not assign a shop account. - stored.shopAccount = null; + // A reply never stores a shop account. A top-level note keeps the one on the row. + if (stored.parentId !== null) { + stored.shopAccount = null; + } if (video !== undefined) { await writeForumVideo(stored.id, video); } @@ -4777,6 +4818,7 @@ export class PostgresMessageStore implements MessageStore { stored.goalAmountPhp ?? null, stored.goalRepayable === true ? true : null, stored.goalTermDays ?? null, + stored.shopAccount?.id ?? null, ]; try { if (stored.parentId !== null) { @@ -4786,11 +4828,12 @@ export class PostgresMessageStore implements MessageStore { nostr_publish_state, sats, parent_id, author_pubkey, event_id, nostr_event, content_fp, goal_sats, fiat_usd, fiat_chf, fiat_eur, fiat_php, photo_taken_at, video_taken_at, place_lat, place_lng, place_label, - goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days + goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days, + shop_account_id ) SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16, $17::numeric,$18::numeric,$19::numeric,$20::numeric,$21,$22,$23,$24,$25, - $26,$27::numeric,$28::numeric,$29::numeric,$30::numeric,$31::numeric,$32,$33 + $26,$27::numeric,$28::numeric,$29::numeric,$30::numeric,$31::numeric,$32,$33,$34 WHERE EXISTS (SELECT 1 FROM message p WHERE p.id = $11 AND p.deleted_at IS NULL) RETURNING id`, params, @@ -4809,11 +4852,12 @@ export class PostgresMessageStore implements MessageStore { nostr_publish_state, sats, parent_id, author_pubkey, event_id, nostr_event, content_fp, goal_sats, fiat_usd, fiat_chf, fiat_eur, fiat_php, photo_taken_at, video_taken_at, place_lat, place_lng, place_label, - goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days + goal_currency, goal_amount, goal_fiat_usd, goal_fiat_chf, goal_fiat_eur, goal_fiat_php, goal_repayable, goal_term_days, + shop_account_id ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16, $17::numeric,$18::numeric,$19::numeric,$20::numeric,$21,$22,$23,$24,$25, - $26,$27::numeric,$28::numeric,$29::numeric,$30::numeric,$31::numeric,$32,$33 + $26,$27::numeric,$28::numeric,$29::numeric,$30::numeric,$31::numeric,$32,$33,$34 )`, params, ); @@ -4972,14 +5016,49 @@ export class PostgresMessageStore implements MessageStore { return rows[0] !== undefined; } - async setPlace(id: string, place: ForumPlace | null): Promise { + async setPlace(id: string, place: ForumPlace | null, edit?: MessageEditRow): Promise { + const lat = place === null ? null : place.lat; + const lng = place === null ? null : place.lng; + const label = place === null ? null : place.label; + if (edit === undefined) { + const rows = await this.#sql.query<{ id: string }>( + `UPDATE message SET place_lat = $2, place_lng = $3, place_label = $4 WHERE id = $1 RETURNING id`, + [id, lat, lng, label], + ); + return rows[0] !== undefined; + } const rows = await this.#sql.query<{ id: string }>( - `UPDATE message SET place_lat = $2, place_lng = $3, place_label = $4 WHERE id = $1 RETURNING id`, + `WITH locked AS ( + SELECT id, place_lat, place_lng, place_label FROM message WHERE id = $1 FOR UPDATE + ), updated AS ( + UPDATE message SET place_lat = $2, place_lng = $3, place_label = $4 + FROM locked + WHERE message.id = locked.id + AND ( + locked.place_lat IS DISTINCT FROM $2 + OR locked.place_lng IS DISTINCT FROM $3 + OR locked.place_label IS DISTINCT FROM $4 + ) + RETURNING message.id + ), inserted AS ( + INSERT INTO message_edit (id, message_id, actor_id, created_at, field, before, after) + SELECT $5, locked.id, $6, $7, $8, $9::jsonb, $10::jsonb + FROM locked + WHERE EXISTS (SELECT 1 FROM updated) + RETURNING id + ) + SELECT id FROM message WHERE id = (SELECT id FROM locked)`, [ id, - place === null ? null : place.lat, - place === null ? null : place.lng, - place === null ? null : place.label, + lat, + lng, + label, + edit.id, + edit.actorId, + edit.createdAt, + edit.field, + JSON.stringify(edit.before), + JSON.stringify(edit.after), ], ); return rows[0] !== undefined; @@ -4988,10 +5067,42 @@ export class PostgresMessageStore implements MessageStore { async setShopAccount( id: string, account: { id: string; username: string; name: string } | null, + edit?: MessageEditRow, ): Promise { + const accountId = account === null ? null : account.id; + if (edit === undefined) { + const rows = await this.#sql.query<{ id: string }>( + `UPDATE message SET shop_account_id = $2 WHERE id = $1 RETURNING id`, + [id, accountId], + ); + return rows[0] !== undefined; + } const rows = await this.#sql.query<{ id: string }>( - `UPDATE message SET shop_account_id = $2 WHERE id = $1 RETURNING id`, - [id, account === null ? null : account.id], + `WITH locked AS ( + SELECT id, shop_account_id FROM message WHERE id = $1 FOR UPDATE + ), updated AS ( + UPDATE message SET shop_account_id = $2 + FROM locked + WHERE message.id = locked.id AND locked.shop_account_id IS DISTINCT FROM $2 + RETURNING message.id + ), inserted AS ( + INSERT INTO message_edit (id, message_id, actor_id, created_at, field, before, after) + SELECT $3, locked.id, $4, $5, $6, $7::jsonb, $8::jsonb + FROM locked + WHERE EXISTS (SELECT 1 FROM updated) + RETURNING id + ) + SELECT id FROM message WHERE id = (SELECT id FROM locked)`, + [ + id, + accountId, + edit.id, + edit.actorId, + edit.createdAt, + edit.field, + JSON.stringify(edit.before), + JSON.stringify(edit.after), + ], ); return rows[0] !== undefined; } @@ -5265,10 +5376,45 @@ export class PostgresMessageStore implements MessageStore { ); } - async updateText(id: string, text: string): Promise { + async updateText( + id: string, + text: string, + edit?: MessageEditRow, + ): Promise { + if (edit === undefined) { + const rows = await this.#sql.query( + `UPDATE message SET text = $2 WHERE id = $1 RETURNING ${MESSAGE_SELECT_COLUMNS}`, + [id, text], + ); + const row = rows[0]; + return row === undefined ? undefined : mapMessageRow(row); + } const rows = await this.#sql.query( - `UPDATE message SET text = $2 WHERE id = $1 RETURNING ${MESSAGE_SELECT_COLUMNS}`, - [id, text], + `WITH locked AS ( + SELECT id, text FROM message WHERE id = $1 FOR UPDATE + ), updated AS ( + UPDATE message SET text = $2 + FROM locked + WHERE message.id = locked.id AND locked.text IS DISTINCT FROM $2 + RETURNING message.id + ), inserted AS ( + INSERT INTO message_edit (id, message_id, actor_id, created_at, field, before, after) + SELECT $3, locked.id, $4, $5, $6, $7::jsonb, $8::jsonb + FROM locked + WHERE EXISTS (SELECT 1 FROM updated) + RETURNING id + ) + SELECT ${MESSAGE_SELECT_COLUMNS} FROM message WHERE id = (SELECT id FROM locked)`, + [ + id, + text, + edit.id, + edit.actorId, + edit.createdAt, + edit.field, + JSON.stringify(edit.before), + JSON.stringify(edit.after), + ], ); const row = rows[0]; return row === undefined ? undefined : mapMessageRow(row); @@ -5289,41 +5435,57 @@ export class PostgresMessageStore implements MessageStore { } async replacePhotos(id: string, photos: readonly ForumPhoto[]): Promise { - const existing = await this.getById(id); - if (existing === undefined) { - return undefined; + const kept = photos.slice(0, 10); + const first = kept[0]; + const extras = kept.slice(1); + const params: unknown[] = [ + id, + first === undefined ? null : first.bytes, + first === undefined ? null : first.contentType, + first === undefined || typeof first.takenAt !== 'string' ? null : first.takenAt, + ]; + const slots: string[] = []; + for (let index = 0; index < 9; index += 1) { + const extra = extras[index]; + const base = 5 + index * 3; + slots.push(`(${index + 1}, $${base}::bytea, $${base + 1}::text, $${base + 2})`); + params.push( + extra === undefined ? null : extra.bytes, + extra === undefined ? null : extra.contentType, + extra === undefined || typeof extra.takenAt !== 'string' ? null : extra.takenAt, + ); } - const first = photos[0]; + params.push(kept.length - 1); const rows = await this.#sql.query( - `UPDATE message SET photo = $2, photo_content_type = $3, photo_taken_at = $4 WHERE id = $1 RETURNING ${MESSAGE_SELECT_COLUMNS}`, - [ - id, - first === undefined ? null : first.bytes, - first === undefined ? null : first.contentType, - first === undefined || typeof first.takenAt !== 'string' ? null : first.takenAt, - ], - ); - const written = rows[0]; - if (written === undefined) { - return undefined; - } - for (const [index, extra] of photos.slice(1).entries()) { - await this.#sql.execute( - `INSERT INTO message_extra_photo (message_id, idx, photo, photo_content_type, photo_taken_at) - VALUES ($1,$2,$3,$4,$5) + `WITH updated AS ( + UPDATE message + SET photo = $2, photo_content_type = $3, photo_taken_at = $4 + WHERE id = $1 + RETURNING id + ), upserted AS ( + INSERT INTO message_extra_photo (message_id, idx, photo, photo_content_type, photo_taken_at) + SELECT updated.id, v.idx, v.photo, v.content_type, v.taken_at + FROM updated + JOIN (VALUES ${slots.join(', ')}) AS v(idx, photo, content_type, taken_at) + ON v.photo IS NOT NULL ON CONFLICT (message_id, idx) DO UPDATE SET photo = EXCLUDED.photo, photo_content_type = EXCLUDED.photo_content_type, - photo_taken_at = EXCLUDED.photo_taken_at`, - [id, index + 1, extra.bytes, extra.contentType, extra.takenAt ?? null], - ); - } - await this.#sql.execute(`DELETE FROM message_extra_photo WHERE message_id = $1 AND idx > $2`, [ - id, - photos.length - 1, - ]); - const refreshed = await this.getById(id); - return refreshed ?? mapMessageRow(written); + photo_taken_at = EXCLUDED.photo_taken_at + RETURNING message_id + ), removed AS ( + DELETE FROM message_extra_photo AS extra + USING updated + WHERE extra.message_id = updated.id AND extra.idx > $32 + RETURNING extra.message_id + ) + SELECT ${MESSAGE_SELECT_COLUMNS} + FROM message + WHERE id = (SELECT id FROM updated)`, + params, + ); + const row = rows[0]; + return row === undefined ? undefined : mapMessageRow(row); } async updateSignedEvent( diff --git a/src/routes/messages.ts b/src/routes/messages.ts index a7e7ccf6e..61695e8cc 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -855,8 +855,8 @@ async function postedShopAccount( * @param place - Optional map pin for a top-level note. Default `null`. * Stored as `null` when `parentId` is set. * @param shopAccount - Optional shop assignment for a new top-level shop - * note. Default `null`. Not applied on a media replay, and not written - * as edit history. + * note. Default `null`. Stored on the same insert as the note. Not applied + * on a media replay, and not written as edit history. * @returns 200 / 403 (unpaid text-only below verified) / 409 (same live * media, different pin) / 429 / 503. */ @@ -947,6 +947,7 @@ async function persistForumPost( goalAmountEur: parentId === null ? goal.goalAmountEur : null, goalAmountPhp: parentId === null ? goal.goalAmountPhp : null, place: parentId === null ? place : null, + ...(parentId === null && shopAccount !== null ? { shopAccount } : {}), }; try { const created = @@ -1059,18 +1060,7 @@ async function persistForumPost( logEvent('messages.mention.notify.failed'); } } - let published = created; - if (!isReplay && shopAccount !== null) { - const written = await deps.store.setShopAccount(created.id, shopAccount); - if (!written) { - throw new Error('shop account was not stored'); - } - const updated = await deps.store.getById(created.id); - if (updated === undefined) { - throw new Error('shop account was not stored'); - } - published = updated; - } + const published = created; if (!isReplay) { await recordFirstShopOcpPlace({ ...(deps.mapPush === undefined ? {} : { mapPush: deps.mapPush }), @@ -1970,7 +1960,18 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Only a shop note can set a place' }, 400); } const hadPlaceBefore = row.place !== null && row.place !== undefined; - const written = await deps.store.setPlace(id, parsed.value); + const placeChanged = !placesMatch(row.place ?? null, parsed.value); + const written = placeChanged + ? await deps.store.setPlace(id, parsed.value, { + id: crypto.randomUUID(), + messageId: id, + actorId: account.id, + createdAt: new Date(deps.now()), + field: 'place', + before: row.place ?? null, + after: parsed.value, + }) + : await deps.store.setPlace(id, parsed.value); if (!written) { return c.json({ error: 'Not found' }, 404); } @@ -1978,17 +1979,6 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (updated === undefined) { return c.json({ error: 'Not found' }, 404); } - if (!placesMatch(row.place ?? null, parsed.value)) { - await deps.store.appendEdit({ - id: crypto.randomUUID(), - messageId: id, - actorId: account.id, - createdAt: new Date(deps.now()), - field: 'place', - before: row.place ?? null, - after: parsed.value, - }); - } const author = updated.accountId === null ? undefined @@ -2087,7 +2077,18 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { name: found.name ?? '', }; } - const written = await deps.store.setShopAccount(id, snapshot); + const accountChanged = !shopAccountsMatch(row.shopAccount, snapshot); + const written = accountChanged + ? await deps.store.setShopAccount(id, snapshot, { + id: crypto.randomUUID(), + messageId: id, + actorId: account.id, + createdAt: new Date(deps.now()), + field: 'shop_account', + before: row.shopAccount ?? null, + after: snapshot, + }) + : await deps.store.setShopAccount(id, snapshot); if (!written) { return c.json({ error: 'Not found' }, 404); } @@ -2095,17 +2096,6 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (updated === undefined) { return c.json({ error: 'Not found' }, 404); } - if (!shopAccountsMatch(row.shopAccount, snapshot)) { - await deps.store.appendEdit({ - id: crypto.randomUUID(), - messageId: id, - actorId: account.id, - createdAt: new Date(deps.now()), - field: 'shop_account', - before: row.shopAccount ?? null, - after: snapshot, - }); - } const author = updated.accountId === null ? undefined @@ -2188,11 +2178,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { 200, ); } - const written = await deps.store.updateText(id, ensured); - if (written === undefined) { - return c.json({ error: 'Not found' }, 404); - } - await deps.store.appendEdit({ + const written = await deps.store.updateText(id, ensured, { id: crypto.randomUUID(), messageId: id, actorId: account.id, @@ -2201,6 +2187,9 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { before: row.text, after: ensured, }); + if (written === undefined) { + return c.json({ error: 'Not found' }, 404); + } const updated = await deps.store.getById(id); if (updated === undefined) { return c.json({ error: 'Not found' }, 404); From 0f1c82a1480f3c5542fa12af6a08b4ff0f65071f Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:39:27 +0200 Subject: [PATCH 18/19] Record a shop-account change only when the account id changes, and read new stills after the write. --- SPEC.md | 2 +- docs/handbook/endpoints.md | 4 +-- docs/handbook/functions.md | 2 +- src/__tests__/lib/message-store.test.ts | 33 +++++++++++++++++-------- src/__tests__/routes/messages.test.ts | 15 +++++++++++ src/lib/message-store.ts | 32 +++++++++++++++--------- src/routes/messages.ts | 4 +-- 7 files changed, 64 insertions(+), 28 deletions(-) diff --git a/SPEC.md b/SPEC.md index 0b4850427..54bdaa588 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4509,7 +4509,7 @@ Success → **200** live public message JSON (optional `shopAccount` `{ id, username, name }`, omitted when cleared, reply count, no hide stamps). Logs `messages.shop_account.updated` with `messageId`, `accountId`, and `role` only. Text, place, and publish state are -unchanged. The write stores only `shop_account_id`. A real change appends `message_edit`. An unchanged account does not. +unchanged. The write stores only `shop_account_id`. A real change appends `message_edit`. An unchanged account does not. The same account id with a new name or username is unchanged. ### `PATCH /messages/:id/text` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index f4fdf559b..67b7a0e9e 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -44,8 +44,8 @@ ## Endpoint: PATCH /messages/:id/shop-account -- **Purpose:** Bearer required. A moderator sets, replaces, or clears the 21.gifts account on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setShopAccount`. The assignment is not the note author. Does not republish Nostr or change note text. `username: null` clears; a missing `username` key does not. A real change appends `message_edit`. Success is the live public message JSON (optional `shopAccount`, reply count, no hide stamps). -- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`, a missing row, a hidden row, or `setShopAccount` false; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or has no `username` key; 400 `{ error: 'Username is not valid' }`; 404 `{ error: 'No account with that username' }`; 400 `{ error: 'A reply cannot include a shop account' }`; 400 `{ error: 'Only a shop note can set a shop account' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). +- **Purpose:** Bearer required. A moderator sets, replaces, or clears the 21.gifts account on a live top-level shop note (`#21GiftsShop`) via `MessageStore.setShopAccount`. The assignment is not the note author. Does not republish Nostr or change note text. `username: null` clears; a missing `username` key does not. A real change appends `message_edit`. An unchanged account does not. The assignment is the account id: a new name or username on that same id is not a change. Success is the live public message JSON (optional `shopAccount`, reply count, no hide stamps). +- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`; a missing or hidden row is 404 `{ error: 'Not found' }` before the handle is searched; `setShopAccount` false is 404 `{ error: 'Not found' }`; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or has no `username` key; 400 `{ error: 'Username is not valid' }`; 404 `{ error: 'No account with that username' }` only after the row is a live shop note; 400 `{ error: 'A reply cannot include a shop account' }`; 400 `{ error: 'Only a shop note can set a shop account' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** The app shops feed. - **Auth:** `Authorization: Bearer` session (moderator). diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index dd184387b..7cf43337a 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1122,7 +1122,7 @@ ## Function: messagesRoutes -- **Purpose:** Hono sub-app for the public member forum. Public `POST /:id/translate` (`{ target }`) loads the stored `message.text`, returns a `message_translation` hit when `source_sha256` matches, otherwise one DeepL POST coalesced per (id, locale, hash), then upserts (first writer for a hash wins). Empty text 400. Same visibility as `GET /:id`. `{ translatedText, cached }`. 503 when DeepL is unset, 502 when DeepL fails. Public `GET /stats` (no session) counts living notes and replies together as `postCount`, with `postsOverTime` filled through today UTC (gap days are 0; soft-hidden rows are omitted; `posts.stats.failed` → 503). After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + username + Lightning Address), `GET /compose-target` (`forum.post`), and `POST /:id/invoice` (`forum.pay` → payer rules only). Public `GET /:id/repayment` needs no session and lists who gave and each repayment. Bearer `POST /:id/repayment` (`forum.pay`) issues the next giver share. Bearer `GET /` lists **live top-level** notes via `listFeed` (query `mode`/`limit`/`cursor`/optional `hashtag` (name without `#`; token match on `text`), default cap 200, optional `nextCursor` when the page is full; `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, live `replyCount` of children with an account or a recorded zapper pubkey); soft-hidden rows are omitted; missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video after parse/normalize/decode — JSON `photos` max 10, non-empty wins over singular `photo`, `photos.length > 10` is 400 `{ error: 'At most 10 photos' }`; optional `goalSats` alone is a legacy whole-sat ask (1..10_000_000) on a top-level note (JSON number or multipart digits; omitted/null/empty = no goal); alternatively both `goalCurrency` (`BTC`/`USD`/`CHF`/`EUR`/`PHP`) and `goalAmount` (one canonical decimal) and not `goalSats` — half a pair or both styles is 400 `{ error: 'Send either goalSats or both goalCurrency and goalAmount' }`; any goal field on a reply is 400 `{ error: 'A reply cannot ask for a goal' }`; `goalRepayable` other than JSON `true` or multipart `"true"` is 400 `{ error: 'Ask obligation must be true' }` (JSON `""` is rejected; a multipart empty field is absent); `goalRepayable` true without an ask is 400 `{ error: 'A repayment obligation needs an ask' }`; `goalTermDays` outside 1..3650 is 400 `{ error: 'Ask term must be a whole number of days from 1 to 3650' }`; a term without `goalRepayable: true` is 400 `{ error: 'A repayment term needs a repayable ask' }`; `goalRepayable` true without a term is 400 `{ error: 'A repayable ask needs a term in days' }`; `BTC` stores that whole-sat count as `goal_sats` and freezes the four fiat snapshots (`null` when there is no gift-day); fiat stores the typed amount, freezes `goal_sats` from the gift-day proportion, and the four snapshots; no usable rate or sats outside 1..10_000_000 is 400 `{ error: 'Ask amount is unavailable' }`; a thrown `goalRateDay` is 503 `{ error: 'Messages are unavailable' }` for a fiat ask, and a BTC ask still stores the typed sats; public JSON omits the key when unset; optional `place` is `{ lat, lng, label }` on a top-level note (multipart `placeLat` / `placeLng` / `placeLabel`; both empty means no pin; exactly one coordinate is 400; a reply with a place is 400 `{ error: 'A reply cannot include a place' }`; public JSON omits `place` when unset); `GET /places` lists live pins (`forum.read`, limit 1–1000) and is registered before `GET /:id`; `GET /:id/photo/:file` serves extras 1–9; identical live media from the same account+parent with the same pin collapses to the existing row (200, no limiter, no second push); a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only still uses the 1/10s burst then inserts; unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /compose-target` then `POST /:id/invoice` on the platform profile note; `verified` stays unpaid-write exempt; soft-hidden `inReplyTo` parents are 404; public `GET /:id` stays open without a session and includes `accountId` whenever the stored author id is non-null, with or without a session, and omits it for an external author on a live row (a reply with null `accountId` is 200 with `via: 'nostr'` only when `authorPubkey` is set and recorded as a zapper (`isZapperPubkey`); otherwise (no `authorPubkey`, or one that is not yet a recorded zapper) it is 404; external top-level notes stay 200); optional `?sinceSats=` (non-negative integer) long-polls until `sats` is strictly greater (timeout still 200 with the current body; invalid value 400); unsigned/non-staff GET of a hidden row is still 404 `{ error: 'Not found' }` (no `deletedAt` in the 404 body); a founder/moderator Bearer (`roleAtLeast(..., 'moderator')`, no `forum.read`) is 200 public JSON plus `deletedAt` ISO, `deletedBy.{id,name,role}`, `payable: false`, and `accountId` for 21gifts authors (skip missing-video drop; do not long-poll `sinceSats` on hidden rows); a top-level note on GET `/:id`, live or staff-hidden, includes that `replyCount`, and a reply omits `replyCount`; live public JSON still omits hide stamps; public `GET /:id/replies` lists children with an account or a recorded zapper pubkey (live replies include `accountId` whenever the stored author id is non-null, with or without a session; rows with neither identity are skipped); unsigned/non-staff 404s hidden/missing parents; staff Bearer is 200 `{ messages }` from `listReplies(id, limit, true)` including hidden attributed children with hide stamps and `payable: false` (live children stay live serialize); a child whose author lookup or serialize throws (invalid `createdAt`, author lookup) is omitted and siblings still 200 `{ messages }`; 503 `messages.replies.failed` only for `getById` / `listReplies` throws and for `dropMissingVideoRow` store/I/O (non-ENOENT video I/O or `deleteById`); missing-file drop (`null` → omit) still 200; photo/video byte routes 404 hidden ids for public/Damus (no staff bearer); founder/moderator Bearer serves hidden-row bytes with `Cache-Control: private, no-store` and `Vary: Authorization`; staff `DELETE /:id` soft-hides via `markDeleted` (moderator → 204; basis/verified → 403) then best-effort `retractHiddenForumNotes` when `nostrPublisher` and `nostrKek` are set (NIP-09 + optional Cloudflare purge; failure still 204) and best-effort retracts in-app notifications whose `parentId` or `replyId` is the note or a direct child (`listChildIds` + `deleteByMessageIds`; failure logs `messages.delete.notifications_failed` and still 204, never 503); staff `GET /hidden` lists soft-hidden notes newest-hidden-first (moderator session, not `DEBUG_TOKEN`, no `forum.read`; 200 `{ messages }` via `listHidden` / `serializeHiddenMessage`; logs `messages.hidden.listed` with `count` only); invoice returns `{ pr, amountSats }` only for NIP-57 invoices and 404s soft-hidden notes (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `notificationStore` fans out via `notifyForumPost` / `notifyForumReply` to every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (no inbox copy; missing `pushStore` still writes in-app rows; Web Push only to bell subscribers, same filter). Optional `spendPing`: after a **new** top-level persist the route POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, POST still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including About me, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independently of `eligibleToday` and of whether the new row has media (`spend.ping.failed` on throw). Replies, and any role other than `verified`, skip welcome. Replies and media replays skip. Omitted `spendPing` skips. Notification or push failure still returns 200. +- **Purpose:** Hono sub-app for the public member forum. Public `POST /:id/translate` (`{ target }`) loads the stored `message.text`, returns a `message_translation` hit when `source_sha256` matches, otherwise one DeepL POST coalesced per (id, locale, hash), then upserts (first writer for a hash wins). Empty text 400. Same visibility as `GET /:id`. `{ translatedText, cached }`. 503 when DeepL is unset, 502 when DeepL fails. Public `GET /stats` (no session) counts living notes and replies together as `postCount`, with `postsOverTime` filled through today UTC (gap days are 0; soft-hidden rows are omitted; `posts.stats.failed` → 503). After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + username + Lightning Address), `GET /compose-target` (`forum.post`), and `POST /:id/invoice` (`forum.pay` → payer rules only). Public `GET /:id/repayment` needs no session and lists who gave and each repayment. Bearer `POST /:id/repayment` (`forum.pay`) issues the next giver share. Bearer `GET /` lists **live top-level** notes via `listFeed` (query `mode`/`limit`/`cursor`/optional `hashtag` (name without `#`; token match on `text`), default cap 200, optional `nextCursor` when the page is full; `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, live `replyCount` of children with an account or a recorded zapper pubkey); soft-hidden rows are omitted; missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video after parse/normalize/decode — JSON `photos` max 10, non-empty wins over singular `photo`, `photos.length > 10` is 400 `{ error: 'At most 10 photos' }`; optional `goalSats` alone is a legacy whole-sat ask (1..10_000_000) on a top-level note (JSON number or multipart digits; omitted/null/empty = no goal); alternatively both `goalCurrency` (`BTC`/`USD`/`CHF`/`EUR`/`PHP`) and `goalAmount` (one canonical decimal) and not `goalSats` — half a pair or both styles is 400 `{ error: 'Send either goalSats or both goalCurrency and goalAmount' }`; any goal field on a reply is 400 `{ error: 'A reply cannot ask for a goal' }`; `goalRepayable` other than JSON `true` or multipart `"true"` is 400 `{ error: 'Ask obligation must be true' }` (JSON `""` is rejected; a multipart empty field is absent); `goalRepayable` true without an ask is 400 `{ error: 'A repayment obligation needs an ask' }`; `goalTermDays` outside 1..3650 is 400 `{ error: 'Ask term must be a whole number of days from 1 to 3650' }`; a term without `goalRepayable: true` is 400 `{ error: 'A repayment term needs a repayable ask' }`; `goalRepayable` true without a term is 400 `{ error: 'A repayable ask needs a term in days' }`; `BTC` stores that whole-sat count as `goal_sats` and freezes the four fiat snapshots (`null` when there is no gift-day); fiat stores the typed amount, freezes `goal_sats` from the gift-day proportion, and the four snapshots; no usable rate or sats outside 1..10_000_000 is 400 `{ error: 'Ask amount is unavailable' }`; a thrown `goalRateDay` is 503 `{ error: 'Messages are unavailable' }` for a fiat ask, and a BTC ask still stores the typed sats; public JSON omits the key when unset; optional `place` is `{ lat, lng, label }` on a top-level note (multipart `placeLat` / `placeLng` / `placeLabel`; both empty means no pin; exactly one coordinate is 400; a reply with a place is 400 `{ error: 'A reply cannot include a place' }`; public JSON omits `place` when unset); optional `shopUsername` (JSON string or multipart field) assigns a 21.gifts account on a new top-level shop note (`#21GiftsShop`): omitted, null, blank, or only `@` stores nothing; a non-string is 400 `Username is not valid`; a reply or a note that is not a shop, with a non-blank handle, is 400 `Only a shop note can set a shop account`; a handle `normalizeUsername` rejects is 400 `Username is not valid`; an unknown username, or a stored username that is missing or blank, is 404 `No account with that username`; the account id is stored on the same insert and that first assignment does not write `message_edit`; a media replay of an existing live note does not change its shop account; `GET /places` lists live pins (`forum.read`, limit 1–1000) and is registered before `GET /:id`; `GET /:id/photo/:file` serves extras 1–9; identical live media from the same account+parent with the same pin collapses to the existing row (200, no limiter, no second push); a different pin is 409 `{ error: 'A live note with this media already exists' }`; text-only still uses the 1/10s burst then inserts; unpaid text-only posts and replies from anyone below `verified` (including the parent author) are 403 (`A post needs a Bitcoin payment` / `A reply needs a Bitcoin payment`); photo or video posts and replies from basis are allowed; pay 1 sat to 21.gifts via `GET /compose-target` then `POST /:id/invoice` on the platform profile note; `verified` stays unpaid-write exempt; soft-hidden `inReplyTo` parents are 404; public `GET /:id` stays open without a session and includes `accountId` whenever the stored author id is non-null, with or without a session, and omits it for an external author on a live row (a reply with null `accountId` is 200 with `via: 'nostr'` only when `authorPubkey` is set and recorded as a zapper (`isZapperPubkey`); otherwise (no `authorPubkey`, or one that is not yet a recorded zapper) it is 404; external top-level notes stay 200); optional `?sinceSats=` (non-negative integer) long-polls until `sats` is strictly greater (timeout still 200 with the current body; invalid value 400); unsigned/non-staff GET of a hidden row is still 404 `{ error: 'Not found' }` (no `deletedAt` in the 404 body); a founder/moderator Bearer (`roleAtLeast(..., 'moderator')`, no `forum.read`) is 200 public JSON plus `deletedAt` ISO, `deletedBy.{id,name,role}`, `payable: false`, and `accountId` for 21gifts authors (skip missing-video drop; do not long-poll `sinceSats` on hidden rows); a top-level note on GET `/:id`, live or staff-hidden, includes that `replyCount`, and a reply omits `replyCount`; live public JSON still omits hide stamps; public `GET /:id/replies` lists children with an account or a recorded zapper pubkey (live replies include `accountId` whenever the stored author id is non-null, with or without a session; rows with neither identity are skipped); unsigned/non-staff 404s hidden/missing parents; staff Bearer is 200 `{ messages }` from `listReplies(id, limit, true)` including hidden attributed children with hide stamps and `payable: false` (live children stay live serialize); a child whose author lookup or serialize throws (invalid `createdAt`, author lookup) is omitted and siblings still 200 `{ messages }`; 503 `messages.replies.failed` only for `getById` / `listReplies` throws and for `dropMissingVideoRow` store/I/O (non-ENOENT video I/O or `deleteById`); missing-file drop (`null` → omit) still 200; photo/video byte routes 404 hidden ids for public/Damus (no staff bearer); founder/moderator Bearer serves hidden-row bytes with `Cache-Control: private, no-store` and `Vary: Authorization`; staff `DELETE /:id` soft-hides via `markDeleted` (moderator → 204; basis/verified → 403) then best-effort `retractHiddenForumNotes` when `nostrPublisher` and `nostrKek` are set (NIP-09 + optional Cloudflare purge; failure still 204) and best-effort retracts in-app notifications whose `parentId` or `replyId` is the note or a direct child (`listChildIds` + `deleteByMessageIds`; failure logs `messages.delete.notifications_failed` and still 204, never 503); staff `GET /hidden` lists soft-hidden notes newest-hidden-first (moderator session, not `DEBUG_TOKEN`, no `forum.read`; 200 `{ messages }` via `listHidden` / `serializeHiddenMessage`; logs `messages.hidden.listed` with `count` only); invoice returns `{ pr, amountSats }` only for NIP-57 invoices and 404s soft-hidden notes (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `notificationStore` fans out via `notifyForumPost` / `notifyForumReply` to every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (no inbox copy; missing `pushStore` still writes in-app rows; Web Push only to bell subscribers, same filter). Optional `spendPing`: after a **new** top-level persist the route POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` and the new row has media (`hasPhoto` / `hasVideo` / `photoCount > 0`) (fire-and-await, errors logged, POST still 200; ineligible logs `spend.ping.skipped` / `not_eligible`; eligible text-only logs `spend.ping.skipped` / `no_media`). When `role === 'verified'` and any live top-level photo or video exists, including About me, the route also POSTs `{ address, messageId, kind: "welcome" }` for that note, independently of `eligibleToday` and of whether the new row has media (`spend.ping.failed` on throw). Replies, and any role other than `verified`, skip welcome. Replies and media replays skip. Omitted `spendPing` skips. Notification or push failure still returns 200. - **Sunday rest:** A `Time-Zone` header naming the device IANA zone makes `POST /`, staff `DELETE /:id`, `PATCH /:id/place`, `PATCH /:id/shop-account`, `PATCH /:id/text`, `PATCH /:id/photos`, `POST /:id/invoice`, and `POST /:id/repayment` return 403 `{ error: 'SUNDAY_REST' }` while that zone is in Sunday. `GET /:id/repayment` stays open. Pay links, the till, and private messages are not refused. Missing or invalid zone does not refuse. - **External DELETE cascade:** When the target has `accountId === null` and a recorded `authorPubkey`, a successful `markDeleted` is followed by the single atomic `blockPubkeyAndHideRows` operation, which records the block and hides that pubkey's other live external rows. It logs `messages.external.blocked` with `{ messageId, hidden: cascaded + 1 }`; deleting a member row does not trigger this author-wide cascade. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 6bdc4d7b5..249422367 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -2610,6 +2610,15 @@ describe('InMemoryMessageStore', () => { 'place', 'text', ]); + const renamed = { id: 'shop-acc', username: 'luna-new', name: 'Cafe Luna' }; + await store.setShopAccount('a', renamed, { + ...accountEdit, + id: 'e-rename', + before: account, + after: renamed, + }); + expect((await store.listEdits('a')).map((item) => item.id)).not.toContain('e-rename'); + expect((await store.getById('a'))?.shopAccount).toEqual(renamed); }); it('pads a listed photo that has no stored capture time', async () => { @@ -6895,12 +6904,14 @@ describe('PostgresMessageStore', () => { after: 'next', }); expect(edited?.text).toBe('next'); - const history = sql.queries.at(-1); + const history = sql.queries[2]; expect(history?.text).toMatch(/FOR UPDATE/); expect(history?.text).toMatch(/locked\.text IS DISTINCT FROM \$2/); expect(history?.text).toMatch(/INSERT INTO message_edit/); + expect(history?.text).not.toMatch(/AS has_photo/); expect(history?.params?.[2]).toBe('e1'); - expect(sql.queries).toHaveLength(3); + expect(sql.queries[3]?.text).toMatch(/FROM message WHERE id = \$1/); + expect(sql.queries).toHaveLength(4); expect(sql.executes).toEqual([]); sql.nextRows = []; expect( @@ -7050,7 +7061,7 @@ describe('PostgresMessageStore', () => { nostr_publish_state: 'published', sats: 21, }; - sql.queryQueue = [[row]]; + sql.queryQueue = [[{ id: 'm1' }], [row]]; const store = new PostgresMessageStore(sql); const updated = await store.replacePhotos('m1', [ { ...JPEG, takenAt: '2020-01-01T00:00:00+00:00' }, @@ -7058,13 +7069,14 @@ describe('PostgresMessageStore', () => { { ...JPEG2, takenAt: '2020-01-02T00:00:00+00:00' }, ]); expect(updated?.id).toBe('m1'); + expect(updated?.photoCount).toBe(2); expect(sql.executes).toEqual([]); - expect(sql.queries).toHaveLength(1); + expect(sql.queries).toHaveLength(2); const statement = sql.queries[0]?.text ?? ''; expect(statement).toMatch(/UPDATE message\s+SET photo = \$2/); - expect(statement.slice(0, statement.indexOf('SELECT id, account_id'))).not.toMatch( - /video_content_type/, - ); + expect(statement).not.toMatch(/AS has_photo/); + expect(statement).toMatch(/SELECT id FROM updated/); + expect(sql.queries[1]?.text).toMatch(/FROM message WHERE id = \$1/); expect(statement).toMatch(/ON CONFLICT \(message_id, idx\) DO UPDATE/); expect(statement).toMatch(/extra\.idx > \$32/); expect(statement.indexOf('UPDATE message')).toBeLessThan( @@ -7077,11 +7089,12 @@ describe('PostgresMessageStore', () => { expect(sql.queries[0]?.params?.[6]).toBeNull(); expect(sql.queries[0]?.params?.[9]).toBe('2020-01-02T00:00:00+00:00'); expect(sql.queries[0]?.params?.at(-1)).toBe(2); - sql.queryQueue = [[row]]; + sql.queryQueue = [[{ id: 'm1' }], [row]]; const one = await store.replacePhotos('m1', [JPEG]); expect(one?.id).toBe('m1'); - expect(sql.queries.at(-1)?.params?.[3]).toBeNull(); - expect(sql.queries.at(-1)?.params?.at(-1)).toBe(0); + const oneWrite = sql.queries[2]; + expect(oneWrite?.params?.[3]).toBeNull(); + expect(oneWrite?.params?.at(-1)).toBe(0); const missed = new MockSql(); missed.queryQueue = [[]]; expect(await new PostgresMessageStore(missed).replacePhotos('m1', [])).toBeUndefined(); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 6f990f897..e7bfa3291 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -11960,6 +11960,21 @@ describe('PATCH /messages/:id/text and GET /messages/:id/edits', () => { }, ); expect(sameAccount.status).toBe(200); + const luna = await auth.getAccount('shop-acc'); + expect(luna).toBeDefined(); + await auth.updateAccount({ ...luna!, name: 'Cafe Luna' }); + const renamed = await mount(auth, messages).request('/messages/' + SHOP_ID + '/shop-account', { + method: 'PATCH', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'luna' }), + }); + expect(renamed.status).toBe(200); + expect(((await renamed.json()) as { shopAccount?: { name?: string } }).shopAccount?.name).toBe( + 'Cafe Luna', + ); + expect( + (await messages.listEdits(SHOP_ID)).filter((row) => row.field === 'shop_account'), + ).toHaveLength(1); const clearAccount = await mount(auth, messages).request( '/messages/' + SHOP_ID + '/shop-account', { diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 1e91b683c..78840bcc8 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -1902,7 +1902,10 @@ function cloneEditValue(value: unknown): unknown { return JSON.parse(JSON.stringify(value)); } -/** Whether two shop-account snapshots are the same assignment. */ +/** + * Whether two shop-account snapshots are the same assignment. + * Only the account id counts. A renamed username or display name is not a new assignment. + */ function shopSnapshotsMatch( a: { id: string; username: string; name: string } | null | undefined, b: { id: string; username: string; name: string } | null, @@ -1911,7 +1914,7 @@ function shopSnapshotsMatch( if (left === null || b === null) { return left === b; } - return left.id === b.id && left.username === b.username && left.name === b.name; + return left.id === b.id; } /** Copy one history row (cloned `createdAt` and jsonb values). */ @@ -5389,7 +5392,7 @@ export class PostgresMessageStore implements MessageStore { const row = rows[0]; return row === undefined ? undefined : mapMessageRow(row); } - const rows = await this.#sql.query( + const rows = await this.#sql.query<{ id: string }>( `WITH locked AS ( SELECT id, text FROM message WHERE id = $1 FOR UPDATE ), updated AS ( @@ -5404,7 +5407,7 @@ export class PostgresMessageStore implements MessageStore { WHERE EXISTS (SELECT 1 FROM updated) RETURNING id ) - SELECT ${MESSAGE_SELECT_COLUMNS} FROM message WHERE id = (SELECT id FROM locked)`, + SELECT id FROM locked`, [ id, text, @@ -5416,8 +5419,12 @@ export class PostgresMessageStore implements MessageStore { JSON.stringify(edit.after), ], ); - const row = rows[0]; - return row === undefined ? undefined : mapMessageRow(row); + // The write and this read are separate statements. One statement cannot + // see its own UPDATE, so the returned text would still be the old body. + if (rows[0] === undefined) { + return undefined; + } + return this.getById(id); } async updatePhoto(id: string, photo: ForumPhoto | null): Promise { @@ -5456,7 +5463,7 @@ export class PostgresMessageStore implements MessageStore { ); } params.push(kept.length - 1); - const rows = await this.#sql.query( + const rows = await this.#sql.query<{ id: string }>( `WITH updated AS ( UPDATE message SET photo = $2, photo_content_type = $3, photo_taken_at = $4 @@ -5479,13 +5486,14 @@ export class PostgresMessageStore implements MessageStore { WHERE extra.message_id = updated.id AND extra.idx > $32 RETURNING extra.message_id ) - SELECT ${MESSAGE_SELECT_COLUMNS} - FROM message - WHERE id = (SELECT id FROM updated)`, + SELECT id FROM updated`, params, ); - const row = rows[0]; - return row === undefined ? undefined : mapMessageRow(row); + // Same statement cannot see the new stills. Read them afterwards. + if (rows[0] === undefined) { + return undefined; + } + return this.getById(id); } async updateSignedEvent( diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 61695e8cc..2096ee97c 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -359,7 +359,7 @@ function ensureShopNoteTag(text: string): string { /** * Whether two shop-account snapshots are the same assignment. - * Both absent matches. One absent does not. + * Both absent matches. One absent does not. Only the account id counts. */ function shopAccountsMatch( a: { id: string; username: string; name: string } | null | undefined, @@ -369,7 +369,7 @@ function shopAccountsMatch( if (left === null || b === null) { return left === b; } - return left.id === b.id && left.username === b.username && left.name === b.name; + return left.id === b.id; } /** Public JSON `field` for one history row. */ From 03cb3b4ef02da23927d8b25a5aa2c1129dbbc835 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:04:36 +0200 Subject: [PATCH 19/19] Document the id-only shop edit reads and the text checks after the shop refusal. --- SPEC.md | 2 +- docs/handbook/endpoints.md | 2 +- docs/handbook/functions.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 54bdaa588..a0fe1d548 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4513,7 +4513,7 @@ unchanged. The write stores only `shop_account_id`. A real change appends `messa ### `PATCH /messages/:id/text` -Staff replacement of the body of a live top-level shop note (`#21GiftsShop`). Bearer session required. Live role must be at least `moderator`. Checks run in this order: `:id` must match `MESSAGE_ID_RE` or the response is **404**; a non-object body or a missing string `text` is **400** `{ "error": "Invalid body" }`; text outside 1–8000 characters is **400** before the row is read; a missing or hidden row is **404** `{ "error": "Not found" }`; a reply is **400** `{ "error": "A reply cannot be edited" }`; a non-shop note is **400** `{ "error": "Only a shop note can be edited" }`. The shop tag is kept or restored. An unchanged body is **200** without `message_edit`. A real change writes the body and `message_edit` together and is **200** public message JSON. Store throw → **503** `{ "error": "Messages are unavailable" }` and leaves the previous body with no new history row. +Staff replacement of the body of a live top-level shop note (`#21GiftsShop`). Bearer session required. Live role must be at least `moderator`. Checks run in this order: `:id` must match `MESSAGE_ID_RE` or the response is **404**; a non-object body or a missing string `text` is **400** `{ "error": "Invalid body" }`; text outside 1–8000 characters is **400** before the row is read; a missing or hidden row is **404** `{ "error": "Not found" }`; a reply is **400** `{ "error": "A reply cannot be edited" }`; a non-shop note is **400** `{ "error": "Only a shop note can be edited" }`; empty text on a note with no photo and no video is **400** `{ "error": "Text must be 1–8000 characters or include a photo" }`. The shop tag is kept or restored, and if that makes the text longer than 8000 characters the response is **400** `{ "error": "Text must be 1–8000 characters" }`. An unchanged body is **200** without `message_edit`. A real change writes the body and `message_edit` together and is **200** public message JSON. Store throw → **503** `{ "error": "Messages are unavailable" }` and leaves the previous body with no new history row. ### `PATCH /messages/:id/photos` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 67b7a0e9e..5010b52cf 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -52,7 +52,7 @@ ## Endpoint: PATCH /messages/:id/text - **Purpose:** Bearer required. A moderator changes the text of a live top-level shop note (`#21GiftsShop`) via `MessageStore.updateText`. The shop tag is kept or restored. Does not republish Nostr, notify, or change sats, media, author, mentions, event ids, or publish state. An unchanged body is 200 without a history row. A real change appends `message_edit`. Success is the live public message JSON (reply count, no hide stamps, no `edits` field). -- **Errors:** In order: 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or `text` is missing or not a string; 400 `{ error: 'Text must be 1–8000 characters' }` before the row is read; 404 `{ error: 'Not found' }` for a missing row, a hidden row, or `updateText` undefined; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 400 `{ error: 'Text must be 1–8000 characters or include a photo' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). +- **Errors:** In order: 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 404 `{ error: 'Not found' }` for a non-UUID `:id`; 400 `{ error: 'Invalid body' }` when the body is not a JSON object or `text` is missing or not a string; 400 `{ error: 'Text must be 1–8000 characters' }` before the row is read; 404 `{ error: 'Not found' }` for a missing row, a hidden row, or `updateText` undefined; 400 `{ error: 'A reply cannot be edited' }`; 400 `{ error: 'Only a shop note can be edited' }`; 400 `{ error: 'Text must be 1–8000 characters or include a photo' }` when the normalized text is empty and the note has neither a photo nor a video; the shop tag is then kept or restored, and if that exceeds 8000 characters, 400 `{ error: 'Text must be 1–8000 characters' }`; 503 `{ error: 'Messages are unavailable' }`. 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday on the server clock (a missing, blank, or invalid zone does not refuse). - **Used by:** Staff shop-note text edit in the app forum. - **Auth:** `Authorization: Bearer` session (moderator). diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 7cf43337a..7d9d3225e 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -346,7 +346,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). Nullable `goal_sats` (optional whole-sat ask; SQL null means no goal), nullable `goal_repayable` (`true` or SQL null, never false), and nullable `goal_term_days` (a whole number from 1 to 3650, or SQL null), plus nullable `goal_currency`, `goal_amount`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php` (null on a reply and on a legacy sats-only ask). `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set. Nullable `place_lat` / `place_lng` / `place_label` (both coordinates or neither; a reply stores null coordinates). `listPlaces` returns live top-level rows that have both coordinates, newest first. Nullable place columns are selected with the other message columns and inserted on both `create` INSERT shapes (top-level `VALUES` and reply `INSERT … SELECT … WHERE EXISTS`); a non-null `parentId` binds `goal_sats`, `goal_repayable`, and `goal_term_days` SQL null even if the row carried a positive `goalSats`, `goalRepayable` true, or a term; `mapMessageRow` maps it to `goalSats` (`null` when SQL null). `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. `markDeleted` soft-hides via a single UPDATE CTE (`deleted_at` / `deleted_by` on the untagged target and untagged direct replies; never `DELETE FROM message`). `markUndeleted` unhides via a single UPDATE CTE (clears `deleted_at` / `deleted_by` on the hidden target and stamp-matched direct replies; already-live target is a no-op for children; never `DELETE FROM message`). Live-only lists/claims require `deleted_at IS NULL`: `listLatest` is **top-level only** (`WHERE parent_id IS NULL AND deleted_at IS NULL`) with subquery `replyCount` (live attributed direct children, `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo`, `deleted_at`, `deleted_by`, and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listFeed` is the GET `/messages` keyset page (`mode` all/active/unpaid/popular, exclusive cursor, optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount`; WHERE also has the name-copy NOT EXISTS (no photo, no video content type, no extra still, non-empty trim, case-insensitive equality with account.name or message.name), not every profile note; a real About me stays; `active` is paid rows, staff unpaid rows, or `COALESCE(goal_sats, 0) > 0`; `popular` is sats-desc); `listReplies` is oldest-first attributed children (`WHERE parent_id = $1` plus the account-or-zapper predicate; `deleted_at IS NULL` unless `includeHidden === true`); `listChildIds` is `SELECT id FROM message WHERE parent_id = $1` (any `deleted_at`); `listDebug` is operator newest-first **all** rows (`SELECT … FROM message ORDER BY created_at DESC, id DESC LIMIT $1`, no `deleted_at` / `parent_id` filter; never `photo` bytea); `postCountsByUtcDay` groups living rows (`deleted_at IS NULL`) by UTC day, notes and replies together (no `parent_id` filter), omits days with no rows, and returns no media bytes; `listHidden` is staff newest-hidden-first **soft-hidden** rows (`SELECT … FROM message WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT $1`; never `photo` bytea); `listDirectChildren` is every direct child including hidden (`SELECT … FROM message WHERE parent_id = $1 ORDER BY created_at ASC, id ASC`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `findLiveByAccountContent` returns the oldest live row for account+parent+`content_fp`; `accountHasLiveTopLevelPost` (`parent_id IS NULL`, exclude profile id, replies do not count); `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video); `latestLiveTopLevelMediaId` (newest live top-level photo or video id, including About me, `ORDER BY created_at DESC, id DESC LIMIT 1`; an empty id is null); `countByAccount` is one `COUNT(*) FILTER` query of live posts (`parent_id IS NULL`) vs replies (`parent_id IS NOT NULL`) for `account_id = $1` and `deleted_at IS NULL` (uncapped; not derived from a list); `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown); `listPostsByAccount` is newest-first live top-level notes for one account (`WHERE parent_id IS NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, subquery `replyCount` of live direct children matching `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`); `listRepliesByAccount` is newest-first live replies for one account (`WHERE parent_id IS NOT NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, no `replyCount`); `create(row, photo?, video?, extraPhotos?)` inserts optional photo bytes, optional extra stills into `message_extra_photo` (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty), optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure), and `content_fp` when media is present and `account_id` is not null; `photoCount` is (photo 0 ? 1 : 0) + extras length; a non-null `parent_id` requires a live parent (`deleted_at` null) via `INSERT … SELECT … WHERE EXISTS`; a 0-row insert calls `getById` and returns that row when the id already exists (gift-reply retry after the parent was later deleted), otherwise throws without inserting; on unique violation `23505` it returns the existing row when `getById` matches the inserted id (no video unlink; gift-reply retry), otherwise unlinks the new video and returns the existing live row from `findLiveByAccountContent` when the pin matches; a different pin throws `place conflicts with live media` (the route maps that to 409); `getPhoto` loads bytes by id; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` load extras from `message_extra_photo`; `getById` / `getByEventId` still return soft-hidden rows; `listIdsByPrefix(prefix)` returns at most two stored ids whose lowercase text starts with the prefix (prefix lowercased, not trimmed), including soft-hidden rows, `SELECT id::text AS id … WHERE lower(id::text) LIKE $1 || '%' LIMIT 2`, never photo bytes; `claimUnsigned`/`claimUnpublished` lease live rows (`deleted_at IS NULL`; `claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns live 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** live rows (`parent_id IS NULL`, `deleted_at 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`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, clears the epoch, increments `nostr_attempts`, and stamps `nostr_first_attempt_at` once, 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); `listRecentOkInvoiceAttempts` (`result = 'ok'` and `created_at >= $1`, same `ORDER BY created_at DESC, id DESC` and `LIMIT` as `listInvoiceAttempts`); `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result = 'ok'` row; description is one message plus the stored invoice description); `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted); `sumUnassignedCreditSats` (sats with no payer account); `listRepayments` and `markRepaymentPaid` (table `message_repayment`; a repeat of the same day and giver is a no-op and does not change `message.sats`); `addSats` and `recordZapReceipt` set `goal_funded_at` once, when a repayable ask with `goal_sats` first reaches that ask, and the schema stamps `goal_funded_at = now()` on an ask that is already full when the column is added; `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice so the same event id may repeat; SQL requires non-null `conversation_id` and `conversation_message_id` and `NOT EXISTS` on `conversation_message`); `updateZapReceiptGift` (`UPDATE nostr_zap_receipt` payer / gift-reply / `comment` columns; omitted patch fields are left unchanged; missing event id is a no-op); `getZapReceiptGift` (one receipt by `event_id`); `listZapReceiptsAwaitingGiftReply` (`(payer_account_id IS NOT NULL OR payer_pubkey IS NOT NULL) AND gift_reply_id IS NULL`, `ORDER BY event_id ASC`, includes `comment`); `recordZapIngest` / `listZapIngests`; `listInvoiceAttemptsForPayer` (uncapped `WHERE payer_account_id = $1`, newest-first); `listIndexedZapIngests` (uncapped `WHERE outcome = 'indexed'`); `updateText` (`UPDATE message SET text = $2 WHERE id = $1 RETURNING …`; sats / photos / event ids unchanged; missing id → no row; an optional history row locks the message and inserts `message_edit` in that same statement only when the text differs); `setPlace` and `setShopAccount` do the same for a pin and `shop_account_id`; `appendEdit` inserts one `message_edit` row and does not change the message; `listEdits` returns that message's rows newest `created_at`, then `id`; `replacePhotos` replaces stills in one data-modifying CTE (primary photo update, video columns untouched, up to nine extra upserts with null bytea slots skipped, then delete extras whose `idx` is above the new count minus one) and returns the row only when the primary update matched; `create` binds `shop_account_id` on both INSERT shapes and binds null for a reply; `listAuthoredMessages` (`WHERE account_id = $1`, including hidden, no LIMIT). `mapMessageRow` keeps `nostr_publish_state` `skipped` (gift-only replies). +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). Nullable `goal_sats` (optional whole-sat ask; SQL null means no goal), nullable `goal_repayable` (`true` or SQL null, never false), and nullable `goal_term_days` (a whole number from 1 to 3650, or SQL null), plus nullable `goal_currency`, `goal_amount`, and `goal_fiat_usd` / `goal_fiat_chf` / `goal_fiat_eur` / `goal_fiat_php` (null on a reply and on a legacy sats-only ask). `addSats` / `recordZapReceipt` leave a fiat column unchanged when extra sats are 0 or that delta is null, assign a non-null delta onto a null column, and add when both sides are set. Nullable `place_lat` / `place_lng` / `place_label` (both coordinates or neither; a reply stores null coordinates). `listPlaces` returns live top-level rows that have both coordinates, newest first. Nullable place columns are selected with the other message columns and inserted on both `create` INSERT shapes (top-level `VALUES` and reply `INSERT … SELECT … WHERE EXISTS`); a non-null `parentId` binds `goal_sats`, `goal_repayable`, and `goal_term_days` SQL null even if the row carried a positive `goalSats`, `goalRepayable` true, or a term; `mapMessageRow` maps it to `goalSats` (`null` when SQL null). `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. `markDeleted` soft-hides via a single UPDATE CTE (`deleted_at` / `deleted_by` on the untagged target and untagged direct replies; never `DELETE FROM message`). `markUndeleted` unhides via a single UPDATE CTE (clears `deleted_at` / `deleted_by` on the hidden target and stamp-matched direct replies; already-live target is a no-op for children; never `DELETE FROM message`). Live-only lists/claims require `deleted_at IS NULL`: `listLatest` is **top-level only** (`WHERE parent_id IS NULL AND deleted_at IS NULL`) with subquery `replyCount` (live attributed direct children, `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo`, `deleted_at`, `deleted_by`, and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listFeed` is the GET `/messages` keyset page (`mode` all/active/unpaid/popular, exclusive cursor, optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount`; WHERE also has the name-copy NOT EXISTS (no photo, no video content type, no extra still, non-empty trim, case-insensitive equality with account.name or message.name), not every profile note; a real About me stays; `active` is paid rows, staff unpaid rows, or `COALESCE(goal_sats, 0) > 0`; `popular` is sats-desc); `listReplies` is oldest-first attributed children (`WHERE parent_id = $1` plus the account-or-zapper predicate; `deleted_at IS NULL` unless `includeHidden === true`); `listChildIds` is `SELECT id FROM message WHERE parent_id = $1` (any `deleted_at`); `listDebug` is operator newest-first **all** rows (`SELECT … FROM message ORDER BY created_at DESC, id DESC LIMIT $1`, no `deleted_at` / `parent_id` filter; never `photo` bytea); `postCountsByUtcDay` groups living rows (`deleted_at IS NULL`) by UTC day, notes and replies together (no `parent_id` filter), omits days with no rows, and returns no media bytes; `listHidden` is staff newest-hidden-first **soft-hidden** rows (`SELECT … FROM message WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT $1`; never `photo` bytea); `listDirectChildren` is every direct child including hidden (`SELECT … FROM message WHERE parent_id = $1 ORDER BY created_at ASC, id ASC`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `findLiveByAccountContent` returns the oldest live row for account+parent+`content_fp`; `accountHasLiveTopLevelPost` (`parent_id IS NULL`, exclude profile id, replies do not count); `accountHasLiveTopLevelMediaPost` (same live/top-level/exclude plus photo 0, extra stills, or video); `latestLiveTopLevelMediaId` (newest live top-level photo or video id, including About me, `ORDER BY created_at DESC, id DESC LIMIT 1`; an empty id is null); `countByAccount` is one `COUNT(*) FILTER` query of live posts (`parent_id IS NULL`) vs replies (`parent_id IS NOT NULL`) for `account_id = $1` and `deleted_at IS NULL` (uncapped; not derived from a list); `countAttributedReplies(parentId)` is that uncapped count of live direct children with an account or a recorded zapper pubkey (0 when the id is unknown); `listPostsByAccount` is newest-first live top-level notes for one account (`WHERE parent_id IS NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, subquery `replyCount` of live direct children matching `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`); `listRepliesByAccount` is newest-first live replies for one account (`WHERE parent_id IS NOT NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, no `replyCount`); `create(row, photo?, video?, extraPhotos?)` inserts optional photo bytes, optional extra stills into `message_extra_photo` (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty), optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure), and `content_fp` when media is present and `account_id` is not null; `photoCount` is (photo 0 ? 1 : 0) + extras length; a non-null `parent_id` requires a live parent (`deleted_at` null) via `INSERT … SELECT … WHERE EXISTS`; a 0-row insert calls `getById` and returns that row when the id already exists (gift-reply retry after the parent was later deleted), otherwise throws without inserting; on unique violation `23505` it returns the existing row when `getById` matches the inserted id (no video unlink; gift-reply retry), otherwise unlinks the new video and returns the existing live row from `findLiveByAccountContent` when the pin matches; a different pin throws `place conflicts with live media` (the route maps that to 409); `getPhoto` loads bytes by id; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` load extras from `message_extra_photo`; `getById` / `getByEventId` still return soft-hidden rows; `listIdsByPrefix(prefix)` returns at most two stored ids whose lowercase text starts with the prefix (prefix lowercased, not trimmed), including soft-hidden rows, `SELECT id::text AS id … WHERE lower(id::text) LIKE $1 || '%' LIMIT 2`, never photo bytes; `claimUnsigned`/`claimUnpublished` lease live rows (`deleted_at IS NULL`; `claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns live 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), 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** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** live rows (`parent_id IS NULL`, `deleted_at 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`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, clears the epoch, increments `nostr_attempts`, and stamps `nostr_first_attempt_at` once, 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); `listRecentOkInvoiceAttempts` (`result = 'ok'` and `created_at >= $1`, same `ORDER BY created_at DESC, id DESC` and `LIMIT` as `listInvoiceAttempts`); `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` / `findOkInvoiceByDescription` (newest `result = 'ok'` row; description is one message plus the stored invoice description); `listCreditPayers` (positive zap sats per 21.gifts payer; external payers omitted); `sumUnassignedCreditSats` (sats with no payer account); `listRepayments` and `markRepaymentPaid` (table `message_repayment`; a repeat of the same day and giver is a no-op and does not change `message.sats`); `addSats` and `recordZapReceipt` set `goal_funded_at` once, when a repayable ask with `goal_sats` first reaches that ask, and the schema stamps `goal_funded_at = now()` on an ask that is already full when the column is added; `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice so the same event id may repeat; SQL requires non-null `conversation_id` and `conversation_message_id` and `NOT EXISTS` on `conversation_message`); `updateZapReceiptGift` (`UPDATE nostr_zap_receipt` payer / gift-reply / `comment` columns; omitted patch fields are left unchanged; missing event id is a no-op); `getZapReceiptGift` (one receipt by `event_id`); `listZapReceiptsAwaitingGiftReply` (`(payer_account_id IS NOT NULL OR payer_pubkey IS NOT NULL) AND gift_reply_id IS NULL`, `ORDER BY event_id ASC`, includes `comment`); `recordZapIngest` / `listZapIngests`; `listInvoiceAttemptsForPayer` (uncapped `WHERE payer_account_id = $1`, newest-first); `listIndexedZapIngests` (uncapped `WHERE outcome = 'indexed'`); `updateText` without a history row is `UPDATE message SET text = $2 WHERE id = $1 RETURNING` the message columns (sats / photos / event ids unchanged; missing id → no row); with a history row the same statement locks the message, updates the text, inserts `message_edit` only when the text differs, and returns only `id` (`SELECT id FROM locked`), because one statement cannot see its own UPDATE, then `getById` reads the fresh row (a missing id returns undefined and does not call `getById`); `setPlace` and `setShopAccount` write the pin or `shop_account_id` and report existence only (`RETURNING id` without a history row; `SELECT id` after the write when a history row is requested) and do not return message columns; `appendEdit` inserts one `message_edit` row and does not change the message; `listEdits` returns that message's rows newest `created_at`, then `id`; `replacePhotos` replaces stills in one data-modifying CTE (primary photo update returning only `id`, video columns untouched, up to nine extra upserts with null bytea slots skipped, then delete extras whose `idx` is above the new count minus one) and, because that statement cannot see its own update, calls `getById` only when that id came back; `create` binds `shop_account_id` on both INSERT shapes and binds null for a reply; `listAuthoredMessages` (`WHERE account_id = $1`, including hidden, no LIMIT). `mapMessageRow` keeps `nostr_publish_state` `skipped` (gift-only replies). - **External-zapper storage:** `nostr_zap_receipt` adds nullable `payer_pubkey text` and `zap_request_id text`, with partial unique index `nostr_zap_receipt_request_uidx` on `zap_request_id WHERE zap_request_id IS NOT NULL`. `nostr_zapper` stores durable visibility entitlement as `pubkey` (primary key), `receipt_event_id`, and `created_at`; it is independent of receipt queue state and is not cleared by `deleteById`. `nostr_blocked_pubkey` is the staff kill-switch table with `pubkey` (primary key), `blocked_at`, `blocked_by`, and `message_id`. - **External-zapper methods:** `attributeZapReceipt(receiptEventId, { payerPubkey, zapRequestId, comment })` lowercases and stores the payer pubkey, request id, and comment only when the receipt exists, its current request id is null or the same id, and a `NOT EXISTS` check finds no other receipt with that request id. A retry with the same request id on the same receipt is idempotent `true`; a different request id on an already-attributed receipt, reuse by another receipt, or a concurrent partial-index unique violation returns `false`. `recordZapper(pubkey, receiptEventId, at)` lowercases and inserts an entitlement with `ON CONFLICT (pubkey) DO NOTHING`; `listZapperPubkeys()` returns every entitled pubkey; `listZappers(limit)` returns entitlement rows by `created_at DESC, pubkey DESC`. `blockPubkeyAndHideRows(pubkey, at, byAccountId, messageId)` performs that insert-or-skip and case-insensitively updates every live null-account row from the pubkey in one data-modifying CTE query, returning the number hidden; `unblockPubkeyByMessage(messageId)` deletes block rows with that `message_id` and reports whether any row was deleted; `isPubkeyBlocked(pubkey)` lowercases its input and performs a single-row `SELECT 1` lookup; `isZapperPubkey(pubkey)` lowercases its input and performs a single-row `SELECT 1 FROM nostr_zapper` lookup; `listBlockedPubkeys()` returns every blocked pubkey; `listBlockedPubkeyRows(limit)` returns block rows by `blocked_at DESC, pubkey DESC`. `listUnattributedIndexedReceipts(limit, before?)` joins each otherwise-unattributed receipt to its newest indexed `nostr_zap_ingest` frame (`payer_account_id`, `payer_pubkey`, `zap_request_id`, and `gift_reply_id` all null), orders by immutable ingest `created_at DESC, event_id DESC`, and applies an optional strict `{ createdAt, eventId }` keyset cursor. Unlike an `OFFSET` over a result set whose membership changes as receipts are attributed, the cursor cannot skip or repeat rows for that reason. - **Payment claims:** `claimZapPayment` inserts into `nostr_zap_payment` with `ON CONFLICT (payment_hash) DO NOTHING` and then compares the stored `receipt_event_id`: a new row or the same owner returns `true`, another owner `false`. The table has no foreign key to `message` and is not part of the `deleteById` statement, so the claim outlives the forum row. Insert and lookup failures propagate. @@ -730,8 +730,8 @@ ## Function: setPlace - **Purpose:** Write only the three place columns (`place_lat` / `place_lng` / `place_label`) on an existing forum row. Does not change text, event ids, hide stamps, sats, media, or publish state. -- **Inputs:** `id` (message id string) and `place` (`ForumPlace | null`). -- **Returns / side effects:** `Promise` — `true` when the id existed and the three columns were written; `false` when no row has that id. `null` stores SQL NULL / in-memory `place: null`. No other column changes. +- **Inputs:** `id` (message id string), `place` (`ForumPlace | null`), and optional `edit` (`MessageEditRow`). +- **Returns / side effects:** `Promise` — `true` when the id existed and the three columns were written; `false` when no row has that id. `null` stores SQL NULL / in-memory `place: null`. No other message column changes. When `edit` is set and the pin changes, a `message_edit` row is written in the same step. An unchanged pin writes no history. - **Used by:** `messagesRoutes` (`PATCH /messages/:id/place`). ## Function: textHasHashtagToken