From b8e9d19833bd8de73af5d49315b3ce55237a6d44 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:06:00 +0200 Subject: [PATCH 01/23] 01a10b01 - Add member habits with comment windows and Bitcoin invoices Any signed-in member can add, edit, archive, and rate their own daily or weekly habits. Name and description changes keep the wording that was public for past periods. Comments are open Monday 16:00 through Saturday 20:00 Asia/Manila, and a Bitcoin invoice can be requested for another member's comment. Private notes are returned only to the owner. --- docs/handbook/endpoints.md | 14 + docs/handbook/functions.md | 91 ++ docs/schema/member-habit.sql | 40 + e2e/functions.spec.ts | 65 ++ scripts/check-handbook.mjs | 1 + src/__tests__/lib/member-habit-store.test.ts | 722 +++++++++++++++ src/__tests__/lib/member-habit.test.ts | 128 +++ src/__tests__/routes/member-habits.test.ts | 927 +++++++++++++++++++ src/lib/boot-stores.ts | 28 + src/lib/member-habit-store.ts | 826 +++++++++++++++++ src/lib/member-habit.ts | 183 ++++ src/routes/member-habits.ts | 487 ++++++++++ src/server.ts | 11 + 13 files changed, 3523 insertions(+) create mode 100644 docs/schema/member-habit.sql create mode 100644 src/__tests__/lib/member-habit-store.test.ts create mode 100644 src/__tests__/lib/member-habit.test.ts create mode 100644 src/__tests__/routes/member-habits.test.ts create mode 100644 src/lib/member-habit-store.ts create mode 100644 src/lib/member-habit.ts create mode 100644 src/routes/member-habits.ts diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index adf37a475..7d1b3b1cc 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1149,3 +1149,17 @@ Operator inspection of external Nostr identities that have earned visibility or - **Errors:** 400 `{ error: 'invalid_code' }` when `:code` is not exactly eight hex digits after `toLowerCase()` (no trim); 404 `{ error: 'not_found' }` when neither store has a match; 409 `{ error: 'ambiguous' }` when two or more ids match (two messages, two accounts, or one of each). No ids in error bodies. No 503 path. - **Used by:** Website short-link landing (`/l/<8 hex>`). - **Auth:** none. Public. Soft-hidden messages are included; the public message page decides who may see them. + +## Endpoint: GET /habits + +- **Purpose:** Public list of member habits. No bearer required. A valid bearer includes `notes` only on the caller's own habits. `notes` is omitted for everyone else. Periods run from `firstPeriod` through the latest ratable day or week and stop at `lastPeriod`. `logged` is false and `status` is null when the owner has not recorded that period. +- **Inputs:** Optional `Authorization: Bearer`. `reviewWeek.start` is the Manila review Monday. `reviewWeek.commentsOpen` is Monday 16:00 inclusive through Saturday 20:00 exclusive Asia/Manila of the following week. +- **Returns / side effects:** 200 `{ reviewWeek, habits }`. No writes. +- **Used by:** the habit tracker page. + +## Endpoint: POST /habits + +- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `invoice` returns a BOLT11 `pr` and does not pay. Comments are not forum posts. +- **Inputs:** Bearer session. Body is one action object. `add` also sends `Time-Zone`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, comment, or amount, and self-donation. 401 missing bearer. 403 comments closed or delete without initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 too many invoices. 502 invoice unavailable. 503 store failure, event `habits.failed`. +- **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 28b4ee011..4c1622682 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3317,3 +3317,94 @@ Builds the operator-only external-pubkey inspection route. - **Inputs:** `{ store, rates, fiatRates, now }` — the same collaborators as {@link loadLatestGoalRateDay}. - **Returns / side effects:** A function that calls `loadLatestGoalRateDay` with those collaborators. - **Used by:** `createApp`. + +## Function: memberHabitRoutes + +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. Does not pay. +- **Inputs:** `{ store, authStore, now, fetchImpl }`. +- **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. +- **Used by:** `createApp`. + +## Function: isValidTimeZone + +- **Purpose:** Accept a non-empty IANA time zone and reject an empty string or a name `Intl` does not know. +- **Inputs:** A time-zone string from the `Time-Zone` header. +- **Returns / side effects:** `true` or `false`. No writes. +- **Used by:** `POST /habits` action `add`. + +## Function: dayKey + +- **Purpose:** Calendar date `YYYY-MM-DD` of an instant in a time zone. +- **Inputs:** Epoch milliseconds and an IANA time zone. +- **Returns / side effects:** The date string. No writes. +- **Used by:** daily habit periods and the daily log bound. + +## Function: weekKey + +- **Purpose:** Monday `YYYY-MM-DD` of the week that contains `dayKey` for that instant. Weeks start Monday. +- **Inputs:** Epoch milliseconds and an IANA time zone. +- **Returns / side effects:** The Monday string. No writes. +- **Used by:** weekly periods and the weekly log check. + +## Function: periodKey + +- **Purpose:** `dayKey` when the cadence is daily, `weekKey` when it is weekly. +- **Inputs:** Epoch milliseconds, cadence `daily` or `weekly`, and an IANA time zone. +- **Returns / side effects:** The period string. No writes. +- **Used by:** `add`, `edit`, and `archive` when they stamp the current period. + +## Function: nextPeriod + +- **Purpose:** The next calendar day, or the Monday seven days later. +- **Inputs:** A `YYYY-MM-DD` key and a cadence. +- **Returns / side effects:** The following period key. Throws when the key is not `YYYY-MM-DD`. +- **Used by:** period ranges and the Manila comment week. + +## Function: comparePeriod + +- **Purpose:** Lexical compare of `YYYY-MM-DD`, which is chronological. +- **Inputs:** Two period keys. +- **Returns / side effects:** `-1`, `0`, or `1`. No writes. +- **Used by:** the closed-period check and period ranges. + +## Function: weeklyRatableThrough + +- **Purpose:** Latest Monday that is ratable at this instant. A week becomes ratable at 08:00 on the following Monday in the habit time zone. +- **Inputs:** Epoch milliseconds and an IANA time zone. +- **Returns / side effects:** A Monday `YYYY-MM-DD`. No writes. +- **Used by:** weekly logs and `manilaReviewWeek`. + +## Function: manilaReviewWeek + +- **Purpose:** The Manila review week, which is `weeklyRatableThrough` in `Asia/Manila`. +- **Inputs:** Epoch milliseconds. +- **Returns / side effects:** `{ start }` with that Monday. No writes. +- **Used by:** `GET /habits` and comment creation. + +## Function: commentsOpen + +- **Purpose:** Whether comments are open: Monday 16:00 inclusive through Saturday 20:00 exclusive Asia/Manila, in the week after the Manila review week. +- **Inputs:** Epoch milliseconds. +- **Returns / side effects:** `true` or `false`. No writes. +- **Used by:** `GET /habits` and `POST /habits` action `comment`. + +## Function: migrateMemberHabitSchema + +- **Purpose:** Run the idempotent `CREATE TABLE IF NOT EXISTS` statements for `member_habit`, `member_habit_revision`, `member_habit_log`, and `member_habit_comment`. +- **Inputs:** A SQL client whose `query` returns `{ rows }`. +- **Returns / side effects:** Resolves when the four statements have run. Safe to call more than once. +- **Used by:** `openBootStores` when `DATABASE_URL` is set. + +## Function: InMemoryMemberHabitStore + +- **Purpose:** Process-local `MemberHabitStore` for tests and for boot without a database URL. Notes stay on the row and are copied onto the public view only for the owner. +- **Inputs:** None. Starts empty. +- **Returns / side effects:** Add, edit, archive, log, list, comment, delete, and Lightning methods. Mutates private maps. +- **Used by:** unit tests and `createApp` when no database URL is set. + +## Function: PostgresMemberHabitStore + +- **Purpose:** `MemberHabitStore` against the `member_habit*` tables. Lightning addresses go through the injected address port, not a habit column. +- **Inputs:** A SQL client whose `query` returns `{ rows }`, and a Lightning address port. +- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. +- **Used by:** `openBootStores` when `DATABASE_URL` is set. diff --git a/docs/schema/member-habit.sql b/docs/schema/member-habit.sql new file mode 100644 index 000000000..ff9c7f48a --- /dev/null +++ b/docs/schema/member-habit.sql @@ -0,0 +1,40 @@ +-- Mirrors MEMBER_HABIT_SCHEMA_SQL. +CREATE TABLE IF NOT EXISTS member_habit ( + id uuid PRIMARY KEY, + account_id uuid NOT NULL, + owner_name text NOT NULL, + role text NOT NULL, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), + description text NOT NULL CHECK (char_length(description) <= 2000), + notes text NOT NULL CHECK (char_length(notes) <= 2000), + cadence text NOT NULL CHECK (cadence IN ('daily', 'weekly')), + time_zone text NOT NULL, + first_period text NOT NULL, + last_period text NULL CHECK (last_period IS NULL OR last_period >= first_period) +); + +CREATE TABLE IF NOT EXISTS member_habit_revision ( + habit_id uuid NOT NULL, + period text NOT NULL, + name text NOT NULL, + description text NOT NULL, + PRIMARY KEY (habit_id, period) +); + +CREATE TABLE IF NOT EXISTS member_habit_log ( + habit_id uuid NOT NULL, + period text NOT NULL, + status text NOT NULL CHECK (status IN ('achieved', 'partial', 'missed')), + PRIMARY KEY (habit_id, period) +); + +CREATE TABLE IF NOT EXISTS member_habit_comment ( + id uuid PRIMARY KEY, + habit_id uuid NOT NULL, + account_id uuid NOT NULL, + name text NOT NULL, + "text" text NOT NULL CHECK (char_length("text") BETWEEN 1 AND 2000), + week text NOT NULL, + created_at double precision NOT NULL, + deleted_at double precision NULL +); diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index ffb49a0c5..4c7935b1c 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -3064,3 +3064,68 @@ test('Function: publicExternalAuthorPosts — unknown id is not found', async ({ test('Function: publicExternalAuthorReplies — unknown id is not found', async ({ request }) => { expect((await request.get('/messages/not-a-uuid/external-replies')).status()).toBe(404); }); + +test('Function: memberHabitRoutes — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: isValidTimeZone — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: dayKey — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: weekKey — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: periodKey — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: nextPeriod — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: comparePeriod — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: weeklyRatableThrough — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: manilaReviewWeek — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: commentsOpen — POST /habits without bearer is 401', async ({ request }) => { + const res = await request.post('/habits', { data: { action: 'add' } }); + expect(res.status()).toBe(401); +}); + +test('Function: migrateMemberHabitSchema — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: InMemoryMemberHabitStore — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); + +test('Function: PostgresMemberHabitStore — GET /habits is public', async ({ request }) => { + const res = await request.get('/habits'); + expect(res.status()).toBe(200); +}); diff --git a/scripts/check-handbook.mjs b/scripts/check-handbook.mjs index e01ef1ffc..ccbb069f5 100644 --- a/scripts/check-handbook.mjs +++ b/scripts/check-handbook.mjs @@ -127,6 +127,7 @@ function extractEndpoints() { } const mountByFile = { 'health.ts': '/healthz', + 'member-habits.ts': '/habits', 'info.ts': '/info', 'auth.ts': '/auth', 'me.ts': '/me', diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts new file mode 100644 index 000000000..c83feabfb --- /dev/null +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -0,0 +1,722 @@ +import { describe, expect, it } from 'vitest'; +import { + InMemoryMemberHabitStore, + MEMBER_HABIT_SCHEMA_SQL, + migrateMemberHabitSchema, + PostgresMemberHabitStore, + type MemberHabit, +} from '@/lib/member-habit-store'; + +function sampleHabit(): MemberHabit { + return { + id: '11111111-1111-1111-1111-111111111111', + accountId: '22222222-2222-2222-2222-222222222222', + ownerName: 'Ada', + role: 'initiator', + name: 'Walk', + description: 'Walk outside', + notes: 'keep a secret', + cadence: 'daily', + timeZone: 'Asia/Manila', + firstPeriod: '2026-10-01', + lastPeriod: null, + }; +} + +/** 2026-10-05 12:00 in Asia/Manila. */ +const nowMs = Date.parse('2026-10-05T04:00:00.000Z'); + +function comment( + patch: Partial<{ + id: string; + habitId: string; + createdAt: number; + }>, +): { + id: string; + habitId: string; + accountId: string; + name: string; + text: string; + week: string; + createdAt: number; + deletedAt: null; +} { + return { + id: patch.id ?? '33333333-3333-3333-3333-333333333333', + habitId: patch.habitId ?? '11111111-1111-1111-1111-111111111111', + accountId: '44444444-4444-4444-4444-444444444444', + name: 'Bob', + text: 'nice', + week: '2026-09-28', + createdAt: patch.createdAt ?? 1, + deletedAt: null, + }; +} + +type Row = Record; + +function habitRow(patch: Row = {}): Row { + return { + id: '11111111-1111-1111-1111-111111111111', + account_id: '22222222-2222-2222-2222-222222222222', + owner_name: 'Ada', + role: 'initiator', + name: 'Walk', + description: 'Walk outside', + notes: 'keep a secret', + cadence: 'daily', + time_zone: 'Asia/Manila', + first_period: '2026-10-01', + last_period: null, + ...patch, + }; +} + +function revisionRow(patch: Row = {}): Row { + return { + habit_id: '11111111-1111-1111-1111-111111111111', + period: '2026-10-01', + name: 'Walk', + description: 'Walk outside', + ...patch, + }; +} + +function scriptedSql(initial: { habits: Row[]; revisions: Row[]; logs: Row[]; comments: Row[] }) { + const state = { + habits: initial.habits.map((row) => ({ ...row })), + revisions: initial.revisions.map((row) => ({ ...row })), + logs: initial.logs.map((row) => ({ ...row })), + comments: initial.comments.map((row) => ({ ...row })), + }; + const sql = { + query: async (text: string, params: unknown[] = []): Promise<{ rows: Row[] }> => { + if (text.includes('member_habit_comment')) { + if (text.includes('INSERT')) { + state.comments.push({ + id: params[0], + habit_id: params[1], + account_id: params[2], + name: params[3], + text: params[4], + week: params[5], + created_at: params[6], + deleted_at: params[7], + }); + return { rows: [] }; + } + if (text.includes('UPDATE')) { + const found = state.comments.find((row) => row['id'] === params[1]); + if (found !== undefined) { + found['deleted_at'] = params[0]; + } + return { rows: [] }; + } + if (text.includes('SELECT deleted_at')) { + const found = state.comments.find((row) => row['id'] === params[0]); + return { rows: found === undefined ? [] : [{ deleted_at: found['deleted_at'] }] }; + } + if (text.includes('WHERE id = $1')) { + const found = state.comments.find( + (row) => row['id'] === params[0] && row['deleted_at'] === null, + ); + return { rows: found === undefined ? [] : [found] }; + } + return { rows: state.comments.filter((row) => row['deleted_at'] === null) }; + } + if (text.includes('member_habit_revision')) { + if (text.includes('INSERT')) { + state.revisions.push({ + habit_id: params[0], + period: params[1], + name: params[2], + description: params[3], + }); + return { rows: [] }; + } + return { rows: state.revisions }; + } + if (text.includes('member_habit_log')) { + if (text.includes('INSERT')) { + state.logs.push({ habit_id: params[0], period: params[1], status: params[2] }); + return { rows: [] }; + } + return { rows: state.logs }; + } + if (text.includes('member_habit')) { + if (text.includes('INSERT')) { + return { rows: [] }; + } + if (text.includes('UPDATE') && text.includes('last_period')) { + const found = state.habits.find((row) => row['id'] === params[1]); + if (found !== undefined) { + found['last_period'] = params[0]; + } + return { rows: [] }; + } + if (text.includes('UPDATE')) { + const found = state.habits.find((row) => row['id'] === params[3]); + if (found !== undefined) { + found['name'] = params[0]; + found['description'] = params[1]; + found['notes'] = params[2]; + } + return { rows: [] }; + } + if (text.includes('WHERE id = $1')) { + const found = state.habits.find((row) => row['id'] === params[0]); + return { rows: found === undefined ? [] : [found] }; + } + return { rows: state.habits }; + } + return { rows: [] }; + }, + }; + return sql; +} + +function addressesOf(initial: string | null) { + let stored = initial; + return { + get: async (): Promise => stored, + set: async (_accountId: string, address: string | null): Promise => { + stored = address; + }, + }; +} + +describe('InMemoryMemberHabitStore', () => { + it('omits notes for another viewer and includes them for the owner', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + await store.comment({ + id: '33333333-3333-3333-3333-333333333333', + habitId: habit.id, + accountId: '44444444-4444-4444-4444-444444444444', + name: 'Bob', + text: 'nice', + week: '2026-09-28', + createdAt: 1, + deletedAt: null, + }); + + const ownerView = await store.listPublic(habit.accountId, nowMs); + const owner = ownerView[0]; + expect(owner?.notes).toBe('keep a secret'); + expect(owner?.comments).toEqual([ + { + id: '33333333-3333-3333-3333-333333333333', + habitId: habit.id, + accountId: '44444444-4444-4444-4444-444444444444', + name: 'Bob', + text: 'nice', + week: '2026-09-28', + createdAt: 1, + deletedAt: null, + }, + ]); + + const otherView = await store.listPublic('55555555-5555-5555-5555-555555555555', nowMs); + const other = otherView[0]; + expect(other !== undefined && !('notes' in other)).toBe(true); + + const anonView = await store.listPublic(null, nowMs); + const anon = anonView[0]; + expect(anon !== undefined && !('notes' in anon)).toBe(true); + }); + + it('shows revision text on an older period and the new text from atPeriod', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + const edited = await store.edit( + habit.id, + habit.accountId, + { name: 'Run', description: 'Run outside', notes: 'keep a secret' }, + '2026-10-03', + ); + expect(edited).toBe('ok'); + + const view = await store.listPublic(habit.accountId, nowMs); + const listed = view[0]; + expect(listed?.name).toBe('Run'); + expect(listed?.description).toBe('Run outside'); + const older = listed?.periods.find((period) => period.period === '2026-10-01'); + const fromEdit = listed?.periods.find((period) => period.period === '2026-10-03'); + expect(older).toEqual({ + period: '2026-10-01', + name: 'Walk', + description: 'Walk outside', + logged: false, + status: null, + }); + expect(fromEdit).toEqual({ + period: '2026-10-03', + name: 'Run', + description: 'Run outside', + logged: false, + status: null, + }); + }); + + it('blocks a later log after archive and leaves lastPeriod unchanged on a second archive', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + expect(await store.archive(habit.id, habit.accountId, '2026-10-03')).toBe('ok'); + expect(await store.log(habit.id, habit.accountId, '2026-10-03', 'achieved')).toBe('ok'); + expect(await store.log(habit.id, habit.accountId, '2026-10-04', 'partial')).toBe('closed'); + expect(await store.log(habit.id, habit.accountId, '2026-09-30', 'missed')).toBe('closed'); + expect(await store.archive(habit.id, habit.accountId, '2026-10-10')).toBe('ok'); + expect(await store.log(habit.id, habit.accountId, '2026-10-04', 'missed')).toBe('closed'); + + const view = await store.listPublic(habit.accountId, nowMs); + const listed = view[0]; + if (listed === undefined) { + throw new Error('expected archived habit in listPublic'); + } + const periods = listed.periods.map((period) => period.period); + expect(periods[periods.length - 1]).toBe('2026-10-03'); + expect(periods).not.toContain('2026-10-04'); + const logged = listed.periods.find((period) => period.period === '2026-10-03'); + expect(logged?.logged).toBe(true); + expect(logged?.status).toBe('achieved'); + }); + + it('returns missing when a non-owner edits', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + expect( + await store.edit( + habit.id, + '55555555-5555-5555-5555-555555555555', + { name: 'Other', description: 'nope', notes: 'stolen' }, + '2026-10-01', + ), + ).toBe('missing'); + expect( + await store.edit( + '66666666-6666-6666-6666-666666666666', + habit.accountId, + { name: 'Ghost', description: 'gone', notes: '' }, + '2026-10-01', + ), + ).toBe('missing'); + const view = await store.listPublic(habit.accountId, nowMs); + expect(view[0]?.name).toBe('Walk'); + expect(view[0]?.notes).toBe('keep a secret'); + }); + + it('sorts comments by createdAt and then by id in both directions', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + await store.comment(comment({ id: 'm', createdAt: 2, habitId: habit.id })); + await store.comment(comment({ id: 'a', createdAt: 1, habitId: habit.id })); + await store.comment(comment({ id: 'c', createdAt: 2, habitId: habit.id })); + const forward = await store.listPublic(null, nowMs); + expect(forward[0]?.comments.map((row) => row.id)).toEqual(['a', 'c', 'm']); + + const reverse = new InMemoryMemberHabitStore(); + await reverse.add(habit); + await reverse.comment(comment({ id: 'a', createdAt: 1, habitId: habit.id })); + await reverse.comment(comment({ id: 'm', createdAt: 5, habitId: habit.id })); + await reverse.comment(comment({ id: 'b', createdAt: 5, habitId: habit.id })); + const backward = await reverse.listPublic(null, nowMs); + expect(backward[0]?.comments.map((row) => row.id)).toEqual(['a', 'b', 'm']); + + const largerSecond = new InMemoryMemberHabitStore(); + await largerSecond.add(habit); + await largerSecond.comment(comment({ id: 'b', createdAt: 5, habitId: habit.id })); + await largerSecond.comment(comment({ id: 'm', createdAt: 5, habitId: habit.id })); + const greaterId = await largerSecond.listPublic(null, nowMs); + expect(greaterId[0]?.comments.map((row) => row.id)).toEqual(['b', 'm']); + }); + + it('replaces a revision when the same period is edited twice', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + await store.edit( + habit.id, + habit.accountId, + { name: 'A', description: 'd', notes: 'n' }, + '2026-10-05', + ); + expect( + await store.edit( + habit.id, + habit.accountId, + { name: 'B', description: 'e', notes: 'n2' }, + '2026-10-05', + ), + ).toBe('ok'); + const view = await store.listPublic(habit.accountId, nowMs); + expect(view[0]?.name).toBe('B'); + expect(view[0]?.notes).toBe('n2'); + expect( + await store.edit( + habit.id, + habit.accountId, + { name: 'B', description: 'only description', notes: 'n3' }, + '2026-10-06', + ), + ).toBe('ok'); + expect( + await store.edit( + habit.id, + habit.accountId, + { name: 'B', description: 'only description', notes: 'n4' }, + '2026-10-06', + ), + ).toBe('ok'); + const revised = await store.listPublic(habit.accountId, nowMs); + expect(revised[0]?.name).toBe('B'); + expect(revised[0]?.description).toBe('only description'); + expect(revised[0]?.notes).toBe('n4'); + }); + + it('archive and log of an unknown id or another owner are missing', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + expect(await store.archive('missing', habit.accountId, '2026-10-01')).toBe('missing'); + expect(await store.archive(habit.id, 'other', '2026-10-01')).toBe('missing'); + expect(await store.log('missing', habit.accountId, '2026-10-01', 'achieved')).toBe('missing'); + expect(await store.log(habit.id, 'other', '2026-10-01', 'achieved')).toBe('missing'); + }); + + it('lists a weekly habit and an empty range when lastPeriod is before firstPeriod', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add({ + ...sampleHabit(), + id: 'weekly', + cadence: 'weekly', + firstPeriod: '2026-09-28', + }); + await store.add({ + ...sampleHabit(), + id: 'empty', + firstPeriod: '2026-10-08', + lastPeriod: '2026-10-01', + }); + const view = await store.listPublic(null, nowMs); + const weekly = view.find((row) => row.id === 'weekly'); + const empty = view.find((row) => row.id === 'empty'); + expect(weekly?.periods[0]?.period).toBe('2026-09-28'); + expect(empty?.periods).toEqual([]); + }); + + it('findComment and deleteComment cover missing and already deleted rows', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + expect(await store.findComment('missing')).toBeNull(); + expect(await store.deleteComment('missing')).toBe(false); + await store.comment(comment({ id: 'c-live', habitId: habit.id })); + expect((await store.findComment('c-live'))?.text).toBe('nice'); + expect(await store.deleteComment('c-live')).toBe(true); + expect(await store.findComment('c-live')).toBeNull(); + expect(await store.deleteComment('c-live')).toBe(false); + }); + + it('stores and clears a lightning address', async () => { + const store = new InMemoryMemberHabitStore(); + expect(await store.lightning('acc')).toBeNull(); + await store.setLightning('acc', 'ada@wallet.example'); + expect(await store.lightning('acc')).toBe('ada@wallet.example'); + await store.setLightning('acc', null); + expect(await store.lightning('acc')).toBeNull(); + }); +}); + +describe('PostgresMemberHabitStore', () => { + it('migrates and inserts through a fake sql client', async () => { + const statements: string[] = []; + const sql = { + query: async ( + text: string, + _params?: unknown[], + ): Promise<{ rows: Record[] }> => { + statements.push(text); + return { rows: [] }; + }, + }; + const lightning = { + get: async (_accountId: string): Promise => null, + set: async (_accountId: string, _address: string | null): Promise => { + return; + }, + }; + + await migrateMemberHabitSchema(sql); + expect(statements).toEqual([...MEMBER_HABIT_SCHEMA_SQL]); + expect(MEMBER_HABIT_SCHEMA_SQL).toHaveLength(4); + + const store = new PostgresMemberHabitStore(sql, lightning); + await store.add(sampleHabit()); + await store.listPublic(null, nowMs); + + expect(statements.some((text) => text.includes('INSERT INTO member_habit'))).toBe(true); + expect(statements.some((text) => text.includes('INSERT INTO member_habit_revision'))).toBe( + true, + ); + expect(statements.some((text) => text.includes('FROM member_habit'))).toBe(true); + }); + + it('edits, archives, logs, lists, comments, and reads lightning through scripted rows', async () => { + const id = '11111111-1111-1111-1111-111111111111'; + const accountId = '22222222-2222-2222-2222-222222222222'; + const sql = scriptedSql({ + habits: [ + habitRow(), + habitRow({ + id: 'weekly', + cadence: 'weekly', + first_period: '2026-09-28', + account_id: 'other', + }), + habitRow({ + id: 'ended', + first_period: '2026-09-01', + last_period: '2026-09-28', + }), + ], + revisions: [ + revisionRow(), + revisionRow({ habit_id: 'weekly', period: '2026-09-28' }), + revisionRow({ habit_id: 'ended', period: '2026-09-01' }), + revisionRow({ habit_id: id, period: '2026-10-03', name: 'Later', description: 'Later' }), + ], + logs: [ + { habit_id: id, period: '2026-10-01', status: 'achieved' }, + { habit_id: id, period: '2026-10-02', status: 'partial' }, + { habit_id: id, period: '2026-10-03', status: 'missed' }, + ], + comments: [ + { + id: 'c-b', + habit_id: id, + account_id: 'bob', + name: 'Bob', + text: 'later', + week: '2026-09-28', + created_at: 2, + deleted_at: null, + }, + { + id: 'c-a', + habit_id: id, + account_id: 'bob', + name: 'Bob', + text: 'earlier', + week: '2026-09-28', + created_at: 1, + deleted_at: null, + }, + ], + }); + const addresses = addressesOf('ada@wallet.example'); + const store = new PostgresMemberHabitStore(sql, addresses); + expect( + await store.edit( + 'missing', + accountId, + { name: 'N', description: '', notes: '' }, + '2026-10-05', + ), + ).toBe('missing'); + expect( + await store.edit( + id, + accountId, + { name: 'Run', description: 'Outside', notes: 'secret' }, + '2026-10-05', + ), + ).toBe('ok'); + expect( + await store.edit( + 'weekly', + accountId, + { name: 'Nope', description: '', notes: '' }, + '2026-09-28', + ), + ).toBe('missing'); + expect(await store.archive('missing', accountId, '2026-10-05')).toBe('missing'); + expect(await store.archive('ended', accountId, '2026-10-05')).toBe('ok'); + expect(await store.archive(id, accountId, '2026-10-05')).toBe('ok'); + expect(await store.log('missing', accountId, '2026-10-01', 'achieved')).toBe('missing'); + expect(await store.log(id, accountId, '2026-09-01', 'achieved')).toBe('closed'); + expect(await store.log('ended', accountId, '2026-10-08', 'missed')).toBe('closed'); + const fresh = scriptedSql({ + habits: [habitRow({ last_period: null })], + revisions: [revisionRow()], + logs: [], + comments: [], + }); + const openStore = new PostgresMemberHabitStore(fresh, addressesOf(null)); + expect(await openStore.log(id, accountId, '2026-10-01', 'achieved')).toBe('ok'); + const listed = await store.listPublic(accountId, nowMs); + const owned = listed.find((row) => row.id === id); + expect(owned?.notes).toBe('secret'); + expect(owned?.comments.map((row) => row.id)).toEqual(['c-a', 'c-b']); + const weekly = listed.find((row) => row.id === 'weekly'); + expect(weekly?.notes).toBeUndefined(); + expect(weekly?.periods[0]?.period).toBe('2026-09-28'); + expect(await store.findComment('missing')).toBeNull(); + expect((await store.findComment('c-a'))?.text).toBe('earlier'); + await store.comment({ + id: 'c-new', + habitId: id, + accountId: 'bob', + name: 'Bob', + text: 'new', + week: '2026-09-28', + createdAt: 3, + deletedAt: null, + }); + expect(await store.deleteComment('missing')).toBe(false); + expect(await store.deleteComment('c-new')).toBe(true); + expect(await store.deleteComment('c-new')).toBe(false); + expect(await store.lightning(accountId)).toBe('ada@wallet.example'); + await store.setLightning(accountId, null); + expect(await store.lightning(accountId)).toBeNull(); + await store.setLightning(accountId, 'next@wallet.example'); + expect(await store.lightning(accountId)).toBe('next@wallet.example'); + }); + + it('rejects rows the column parsers cannot read', async () => { + const cases: Array<{ + label: string; + habits: Row[]; + revisions: Row[]; + logs: Row[]; + comments: Row[]; + }> = [ + { + label: 'revision id', + habits: [], + revisions: [{ habit_id: 1, period: '2026-10-01', name: 'Walk', description: '' }], + logs: [], + comments: [], + }, + { + label: 'status', + habits: [], + revisions: [], + logs: [{ habit_id: 'h', period: '2026-10-01', status: 'nope' }], + comments: [], + }, + { + label: 'created_at', + habits: [], + revisions: [], + logs: [], + comments: [ + { + id: 'c', + habit_id: 'h', + account_id: 'a', + name: 'Bob', + text: 't', + week: '2026-09-28', + created_at: 'x', + deleted_at: null, + }, + ], + }, + { + label: 'deleted_at', + habits: [], + revisions: [], + logs: [], + comments: [ + { + id: 'c', + habit_id: 'h', + account_id: 'a', + name: 'Bob', + text: 't', + week: '2026-09-28', + created_at: 1, + deleted_at: 'x', + }, + ], + }, + { + label: 'habit id', + habits: [habitRow({ id: 1 })], + revisions: [revisionRow({ habit_id: 1 })], + logs: [], + comments: [], + }, + { + label: 'last_period', + habits: [habitRow({ last_period: 1 })], + revisions: [revisionRow()], + logs: [], + comments: [], + }, + { + label: 'cadence', + habits: [habitRow({ cadence: 'yearly' })], + revisions: [revisionRow()], + logs: [], + comments: [], + }, + { + label: 'missing revision', + habits: [habitRow()], + revisions: [], + logs: [], + comments: [], + }, + { + label: 'uncovered period', + habits: [habitRow({ first_period: '2026-10-01' })], + revisions: [revisionRow({ period: '2026-10-15' })], + logs: [], + comments: [], + }, + ]; + for (const entry of cases) { + const sql = + entry.label === 'created_at' || entry.label === 'deleted_at' + ? { + query: async (text: string): Promise<{ rows: Row[] }> => { + if (text.includes('member_habit_comment') && !text.includes('WHERE id')) { + return { rows: entry.comments }; + } + return { rows: [] }; + }, + } + : scriptedSql({ + habits: entry.habits, + revisions: entry.revisions, + logs: entry.logs, + comments: entry.comments, + }); + const store = new PostgresMemberHabitStore(sql, addressesOf(null)); + await expect(store.listPublic(null, nowMs)).rejects.toThrow(Error); + } + const sparse = { + query: async (text: string): Promise<{ rows: Row[] }> => { + if ( + text.includes('WHERE id = $1') && + text.includes('member_habit_comment') && + !text.includes('SELECT deleted_at') + ) { + const rows: Row[] = []; + rows.length = 1; + return { rows }; + } + return { rows: [] }; + }, + }; + const sparseStore = new PostgresMemberHabitStore(sparse, addressesOf(null)); + expect(await sparseStore.findComment('c')).toBeNull(); + }); +}); diff --git a/src/__tests__/lib/member-habit.test.ts b/src/__tests__/lib/member-habit.test.ts new file mode 100644 index 000000000..1a524a8b2 --- /dev/null +++ b/src/__tests__/lib/member-habit.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { + commentsOpen, + comparePeriod, + dayKey, + isValidTimeZone, + manilaReviewWeek, + nextPeriod, + periodKey, + weekKey, + weeklyRatableThrough, +} from '../../lib/member-habit'; + +describe('isValidTimeZone', () => { + it('accepts IANA zones and rejects empty or invalid names', () => { + expect(isValidTimeZone('Asia/Manila')).toBe(true); + expect(isValidTimeZone('Europe/Zurich')).toBe(true); + expect(isValidTimeZone('')).toBe(false); + expect(isValidTimeZone('Not/AZone')).toBe(false); + }); +}); + +describe('dayKey / weekKey / periodKey', () => { + it('dayKey is YYYY-MM-DD of the instant in the zone', () => { + expect(dayKey(Date.parse('2026-10-05T00:00:00Z'), 'Asia/Manila')).toBe('2026-10-05'); + expect(dayKey(Date.parse('2026-10-04T15:00:00Z'), 'Asia/Manila')).toBe('2026-10-04'); + }); + + it('weekKey of a Wednesday is that week’s Monday', () => { + expect(weekKey(Date.parse('2026-10-07T12:00:00Z'), 'Asia/Manila')).toBe('2026-10-05'); + }); + + it('periodKey selects dayKey or weekKey by cadence', () => { + const now = Date.parse('2026-10-07T12:00:00Z'); + expect(periodKey(now, 'daily', 'Asia/Manila')).toBe('2026-10-07'); + expect(periodKey(now, 'weekly', 'Asia/Manila')).toBe('2026-10-05'); + }); +}); + +describe('nextPeriod', () => { + it('advances a daily key by one calendar day', () => { + expect(nextPeriod('2026-10-05', 'daily')).toBe('2026-10-06'); + expect(nextPeriod('2026-10-31', 'daily')).toBe('2026-11-01'); + }); + + it('advances a weekly Monday key by seven days', () => { + expect(nextPeriod('2026-10-05', 'weekly')).toBe('2026-10-12'); + }); +}); + +describe('comparePeriod', () => { + it('compares YYYY-MM-DD lexicographically as chronology', () => { + expect(comparePeriod('2026-09-28', '2026-10-05')).toBe(-1); + expect(comparePeriod('2026-10-05', '2026-10-05')).toBe(0); + expect(comparePeriod('2026-10-05', '2026-09-28')).toBe(1); + }); +}); + +describe('weeklyRatableThrough', () => { + it('Manila: Monday 08:00 opens the week that ended the previous Monday', () => { + expect(weeklyRatableThrough(Date.parse('2026-10-05T00:00:00Z'), 'Asia/Manila')).toBe( + '2026-09-28', + ); + }); + + it('Manila: Monday 07:59 still belongs to the prior ratable week', () => { + expect(weeklyRatableThrough(Date.parse('2026-10-04T23:59:00Z'), 'Asia/Manila')).toBe( + '2026-09-21', + ); + }); + + it('Manila: Sunday 23:00 is not yet the following Monday 08:00', () => { + expect(weeklyRatableThrough(Date.parse('2026-10-04T15:00:00Z'), 'Asia/Manila')).toBe( + '2026-09-21', + ); + }); + + it('Europe/Zurich: Monday 08:00 local flips weeklyRatableThrough', () => { + expect(weeklyRatableThrough(Date.parse('2026-10-05T06:00:00Z'), 'Europe/Zurich')).toBe( + '2026-09-28', + ); + expect(weeklyRatableThrough(Date.parse('2026-10-05T05:59:00Z'), 'Europe/Zurich')).toBe( + '2026-09-21', + ); + }); +}); + +describe('manilaReviewWeek', () => { + it('wraps weeklyRatableThrough for Asia/Manila', () => { + expect(manilaReviewWeek(Date.parse('2026-10-05T00:00:00Z'))).toEqual({ + start: '2026-09-28', + }); + }); +}); + +describe('commentsOpen', () => { + it('opens at Monday 16:00 Manila of the week after the review week', () => { + expect(commentsOpen(Date.parse('2026-10-05T07:59:00Z'))).toBe(false); + expect(commentsOpen(Date.parse('2026-10-05T08:00:00Z'))).toBe(true); + }); + + it('closes at Saturday 20:00 Manila', () => { + expect(commentsOpen(Date.parse('2026-10-10T11:59:00Z'))).toBe(true); + expect(commentsOpen(Date.parse('2026-10-10T12:00:00Z'))).toBe(false); + }); + + it('is closed on Sunday and on Monday before 16:00', () => { + expect(commentsOpen(Date.parse('2026-10-04T15:00:00Z'))).toBe(false); + expect(commentsOpen(Date.parse('2026-10-05T07:59:00Z'))).toBe(false); + }); +}); + +describe('nextPeriod rejects', () => { + it('throws when the key is not YYYY-MM-DD', () => { + expect(() => nextPeriod('bad', 'daily')).toThrow(/invalid YYYY-MM-DD/); + }); +}); + +describe('commentsOpen outside the open week', () => { + it('is closed on Monday before 08:00 Manila because that week is not the comment week', () => { + expect(commentsOpen(Date.parse('2026-10-04T23:59:00.000Z'))).toBe(false); + }); + + it('is open Tuesday through Friday of the comment week', () => { + expect(commentsOpen(Date.parse('2026-10-06T04:00:00.000Z'))).toBe(true); + expect(commentsOpen(Date.parse('2026-10-08T04:00:00.000Z'))).toBe(true); + }); +}); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts new file mode 100644 index 000000000..a4b6fff6e --- /dev/null +++ b/src/__tests__/routes/member-habits.test.ts @@ -0,0 +1,927 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryMemberHabitStore, type MemberHabit } from '@/lib/member-habit-store'; +import { memberHabitRoutes } from '@/routes/member-habits'; +import type { FetchFn } from '@/lib/lnurlp'; +import { InMemoryAuthStore } from '@/lib/auth/store'; + +const NOW_OPEN = Date.parse('2026-10-05T08:00:00.000Z'); +const NOW_CLOSED = Date.parse('2026-10-05T07:59:00.000Z'); +const AUTH = { Authorization: 'Bearer tok' }; + +type AccountView = { + id: string; + role: string; + name: string | null; + lightningAddress: string | null; +}; + +const BASIS: AccountView = { + id: 'acc-basis', + role: 'basis', + name: 'Basis', + lightningAddress: 'basis@wallet.example', +}; + +const INITIATOR: AccountView = { + id: 'acc-init', + role: 'initiator', + name: 'Initiator', + lightningAddress: null, +}; + +const ALICE: AccountView = { + id: 'acc-alice', + role: 'basis', + name: 'Alice', + lightningAddress: 'alice@wallet.example', +}; + +const unusedFetch: FetchFn = async () => new Response(null, { status: 500 }); + +const successFetch: FetchFn = async (input) => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return Response.json({ + callback: 'https://wallet.example/callback', + minSendable: 1000, + maxSendable: 100000000000, + metadata: '[]', + }); + } + return Response.json({ pr: 'lnbc1' }); +}; + +const throwingFetch: FetchFn = async () => { + throw new Error('network'); +}; + +function sampleHabit( + patch: Partial & Pick, +): MemberHabit { + return { + ownerName: 'Owner', + name: 'Walk', + description: '', + notes: 'secret', + cadence: 'daily', + timeZone: 'Asia/Manila', + firstPeriod: '2026-10-01', + lastPeriod: null, + ...patch, + }; +} + +function mount(opts: { + store?: InMemoryMemberHabitStore; + account?: { id: string; role: string; name: string | null } | null; + accounts?: Record; + now?: () => number; + fetchImpl?: FetchFn; +}) { + const account = opts.account === undefined ? null : opts.account; + const accounts = opts.accounts ?? {}; + return memberHabitRoutes({ + store: opts.store ?? new InMemoryMemberHabitStore(), + now: opts.now ?? (() => NOW_OPEN), + fetchImpl: opts.fetchImpl ?? unusedFetch, + resolve: async () => account, + authStore: { + async getAccount(id: string) { + return accounts[id]; + }, + }, + }); +} + +async function post( + app: ReturnType, + body: unknown, + headers: Record = {}, +): Promise { + return app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +describe('memberHabitRoutes', () => { + it('anonymous GET omits notes and sorts founder before initiator before basis', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add( + sampleHabit({ + id: 'h-basis', + accountId: 'a-basis', + role: 'basis', + ownerName: 'Mmm', + name: 'Basis habit', + notes: 'basis-secret', + }), + ); + await store.add( + sampleHabit({ + id: 'h-founder', + accountId: 'a-founder', + role: 'founder', + ownerName: 'Zed', + name: 'Founder habit', + notes: 'founder-secret', + }), + ); + await store.add( + sampleHabit({ + id: 'h-initiator', + accountId: 'a-init', + role: 'initiator', + ownerName: 'Aaa', + name: 'Initiator habit', + notes: 'initiator-secret', + }), + ); + const res = await mount({ store, account: null }).request('/'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + habits: Array<{ id: string; role: string; notes?: string }>; + }; + expect(body.habits.map((habit) => habit.role)).toEqual(['founder', 'initiator', 'basis']); + for (const habit of body.habits) { + expect('notes' in habit).toBe(false); + } + }); + + it('owner GET includes notes', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add( + sampleHabit({ + id: 'h-owner', + accountId: BASIS.id, + role: 'basis', + notes: 'keep a secret', + }), + ); + const res = await mount({ store, account: BASIS }).request('/', { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { habits: Array<{ id: string; notes?: string }> }; + const habit = body.habits.find((row) => row.id === 'h-owner'); + expect(habit?.notes).toBe('keep a secret'); + }); + + it('POST without bearer is 401', async () => { + const res = await post(mount({ account: BASIS }), { action: 'archive', id: 'x' }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('malformed JSON is 400', async () => { + const res = await post(mount({ account: BASIS }), '{', AUTH); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid body' }); + }); + + it('add without Time-Zone is 400', async () => { + const res = await post( + mount({ account: BASIS }), + { action: 'add', name: 'Walk', cadence: 'daily' }, + AUTH, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid time zone' }); + }); + + it('add with an unknown Time-Zone is 400', async () => { + const res = await post( + mount({ account: BASIS }), + { action: 'add', name: 'Walk', cadence: 'daily' }, + { ...AUTH, 'Time-Zone': 'Not/AZone' }, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid time zone' }); + }); + + it('add with an empty name is 400', async () => { + const res = await post( + mount({ account: BASIS }), + { action: 'add', name: '', cadence: 'daily' }, + { ...AUTH, 'Time-Zone': 'Asia/Manila' }, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid name' }); + }); + + it('add of a daily habit returns 201 and a later owner GET shows it', async () => { + const store = new InMemoryMemberHabitStore(); + const app = mount({ store, account: BASIS }); + const created = await post( + app, + { action: 'add', name: 'Walk', cadence: 'daily' }, + { ...AUTH, 'Time-Zone': 'Asia/Manila' }, + ); + expect(created.status).toBe(201); + const added = (await created.json()) as { ok: boolean; id: string }; + expect(added.ok).toBe(true); + expect(typeof added.id).toBe('string'); + const listed = await app.request('/', { headers: AUTH }); + expect(listed.status).toBe(200); + const body = (await listed.json()) as { + habits: Array<{ id: string; name: string; notes?: string }>; + }; + const habit = body.habits.find((row) => row.id === added.id); + expect(habit?.name).toBe('Walk'); + expect(habit?.notes).toBe(''); + }); + + it('edit of a missing id is 404', async () => { + const res = await post( + mount({ account: BASIS }), + { action: 'edit', id: 'missing', name: 'Walk' }, + AUTH, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('archive of that habit returns 200', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-archive', accountId: BASIS.id, role: 'basis' })); + const res = await post( + mount({ store, account: BASIS }), + { action: 'archive', id: 'h-archive' }, + AUTH, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('log of a future period is 409', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-log', accountId: BASIS.id, role: 'basis' })); + const res = await post( + mount({ store, account: BASIS }), + { action: 'log', id: 'h-log', period: '2026-10-06', status: 'achieved' }, + AUTH, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Period is closed' }); + }); + + it('log of the current daily period with status achieved returns 200', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-log', accountId: BASIS.id, role: 'basis' })); + const res = await post( + mount({ store, account: BASIS }), + { action: 'log', id: 'h-log', period: '2026-10-05', status: 'achieved' }, + AUTH, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('comment at 2026-10-05T07:59:00.000Z is 403', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + const res = await post( + mount({ store, account: BASIS, now: () => NOW_CLOSED }), + { action: 'comment', habitId: 'h-comment', text: 'nice' }, + AUTH, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Comments are closed' }); + }); + + it('comment at 2026-10-05T08:00:00.000Z by a basis account is 201', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: INITIATOR.id, role: 'initiator' })); + const res = await post( + mount({ store, account: BASIS }), + { action: 'comment', habitId: 'h-comment', text: 'nice' }, + AUTH, + ); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('deleteComment by basis is 403', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + await store.comment({ + id: 'c-1', + habitId: 'h-comment', + accountId: BASIS.id, + name: 'Basis', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ store, account: BASIS }), + { action: 'deleteComment', id: 'c-1' }, + AUTH, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Forbidden' }); + }); + + it('deleteComment by initiator is 200', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + await store.comment({ + id: 'c-1', + habitId: 'h-comment', + accountId: BASIS.id, + name: 'Basis', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ store, account: INITIATOR }), + { action: 'deleteComment', id: 'c-1' }, + AUTH, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it("invoice of the caller's own comment is 400", async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + await store.comment({ + id: 'c-own', + habitId: 'h-comment', + accountId: BASIS.id, + name: 'Basis', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ store, account: BASIS }), + { action: 'invoice', commentId: 'c-own', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Cannot donate to yourself' }); + }); + + it('invoice when getAccount returns lightningAddress null is 409', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: { ...ALICE, lightningAddress: null } }, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'No wallet' }); + }); + + it('invoice when fetchImpl throws is 502', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: throwingFetch, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Invoice unavailable' }); + }); + + it('invoice when fetchImpl succeeds is 200 { pr: lnbc1 }', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc1' }); + }); + + it('a store whose listPublic throws returns 503 and the body error Habits are unavailable', async () => { + const store = new InMemoryMemberHabitStore(); + store.listPublic = async () => { + throw new Error('down'); + }; + const res = await mount({ store, account: null }).request('/'); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Habits are unavailable' }); + }); + + it('GET returns live comments without deletedAt', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + const app = mount({ store, account: BASIS }); + const created = await post( + app, + { action: 'comment', habitId: 'h-comment', text: 'nice' }, + AUTH, + ); + expect(created.status).toBe(201); + const listed = await app.request('/'); + expect(listed.status).toBe(200); + const body = (await listed.json()) as { + habits: Array<{ comments: Array<{ text: string; deletedAt?: number }> }>; + }; + expect(body.habits[0]?.comments[0]?.text).toBe('nice'); + expect(body.habits[0]?.comments[0]?.deletedAt).toBeUndefined(); + }); + + it('sorts the same role by owner name and habit name, and keeps a moderator with everyone else', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add( + sampleHabit({ id: 'mona', accountId: '1', role: 'basis', ownerName: 'Mona', name: 'Beta' }), + ); + await store.add( + sampleHabit({ id: 'adam', accountId: '2', role: 'basis', ownerName: 'Adam', name: 'Alpha' }), + ); + await store.add( + sampleHabit({ id: 'sam-b', accountId: '3', role: 'basis', ownerName: 'Sam', name: 'Beta' }), + ); + await store.add( + sampleHabit({ id: 'sam-a', accountId: '4', role: 'basis', ownerName: 'Sam', name: 'Alpha' }), + ); + await store.add( + sampleHabit({ id: 'same-1', accountId: '5', role: 'basis', ownerName: 'Sam', name: 'Same' }), + ); + await store.add( + sampleHabit({ id: 'same-2', accountId: '6', role: 'basis', ownerName: 'Sam', name: 'Same' }), + ); + await store.add( + sampleHabit({ id: 'mod', accountId: '7', role: 'moderator', ownerName: 'Zed', name: 'Mod' }), + ); + const res = await mount({ store, account: null }).request('/'); + const body = (await res.json()) as { habits: Array<{ id: string }> }; + expect(body.habits.map((habit) => habit.id)).toEqual([ + 'adam', + 'mona', + 'sam-a', + 'sam-b', + 'same-1', + 'same-2', + 'mod', + ]); + }); + + it('add rejects a long name, a long description, and long notes', async () => { + const app = mount({ account: BASIS }); + const headers = { ...AUTH, 'Time-Zone': 'Asia/Manila' }; + const longName = await post( + app, + { action: 'add', name: 'n'.repeat(81), cadence: 'daily' }, + headers, + ); + expect(longName.status).toBe(400); + expect(await longName.json()).toEqual({ error: 'Invalid name' }); + const longDescription = await post( + app, + { action: 'add', name: 'Walk', description: 'd'.repeat(2001), cadence: 'daily' }, + headers, + ); + expect(longDescription.status).toBe(400); + expect(await longDescription.json()).toEqual({ error: 'Invalid description' }); + const longNotes = await post( + app, + { action: 'add', name: 'Walk', notes: 'n'.repeat(2001), cadence: 'daily' }, + headers, + ); + expect(longNotes.status).toBe(400); + expect(await longNotes.json()).toEqual({ error: 'Invalid notes' }); + }); + + it('add with a null account name stores an empty owner name', async () => { + const store = new InMemoryMemberHabitStore(); + const app = mount({ store, account: { id: 'acc-noname', role: 'basis', name: null } }); + const res = await post( + app, + { action: 'add', name: 'Walk', cadence: 'daily' }, + { ...AUTH, 'Time-Zone': 'Asia/Manila' }, + ); + expect(res.status).toBe(201); + const listed = await app.request('/', { headers: AUTH }); + const body = (await listed.json()) as { habits: Array<{ ownerName: string; notes?: string }> }; + expect(body.habits[0]?.ownerName).toBe(''); + expect(body.habits[0]?.notes).toBe(''); + }); + + it('edit updates the owner habit and rejects another person, a missing id, and a long name', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'mine', accountId: BASIS.id, role: 'basis' })); + await store.add(sampleHabit({ id: 'theirs', accountId: ALICE.id, role: 'basis' })); + const app = mount({ store, account: BASIS }); + const ok = await post( + app, + { action: 'edit', id: 'mine', name: 'Run', description: 'Out', notes: 'n' }, + AUTH, + ); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ ok: true }); + const other = await post( + app, + { action: 'edit', id: 'theirs', name: 'Run', description: '', notes: '' }, + AUTH, + ); + expect(other.status).toBe(404); + const missing = await post( + app, + { action: 'edit', id: 'nope', name: 'Run', description: '', notes: '' }, + AUTH, + ); + expect(missing.status).toBe(404); + const longName = await post( + app, + { action: 'edit', id: 'mine', name: 'n'.repeat(81), description: '', notes: '' }, + AUTH, + ); + expect(longName.status).toBe(400); + expect(await longName.json()).toEqual({ error: 'Invalid name' }); + }); + + it('archive of an unknown id is 404 and archive of another person is 404', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'theirs', accountId: ALICE.id, role: 'basis' })); + const app = mount({ store, account: BASIS }); + const missing = await post(app, { action: 'archive', id: 'nope' }, AUTH); + expect(missing.status).toBe(404); + const other = await post(app, { action: 'archive', id: 'theirs' }, AUTH); + expect(other.status).toBe(404); + }); + + it('log covers partial, missed, a bad status, a bad day, a weekly Tuesday, and a closed past day', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'daily', accountId: BASIS.id, role: 'basis' })); + await store.add( + sampleHabit({ + id: 'weekly', + accountId: BASIS.id, + role: 'basis', + cadence: 'weekly', + firstPeriod: '2026-09-28', + }), + ); + await store.add(sampleHabit({ id: 'theirs', accountId: ALICE.id, role: 'basis' })); + const app = mount({ store, account: BASIS }); + const partial = await post( + app, + { action: 'log', id: 'daily', period: '2026-10-02', status: 'partial' }, + AUTH, + ); + expect(partial.status).toBe(200); + const missed = await post( + app, + { action: 'log', id: 'daily', period: '2026-10-03', status: 'missed' }, + AUTH, + ); + expect(missed.status).toBe(200); + const badStatus = await post( + app, + { action: 'log', id: 'daily', period: '2026-10-01', status: 'nope' }, + AUTH, + ); + expect(badStatus.status).toBe(400); + expect(await badStatus.json()).toEqual({ error: 'Invalid status' }); + const badDay = await post( + app, + { action: 'log', id: 'daily', period: '2026-02-31', status: 'achieved' }, + AUTH, + ); + expect(badDay.status).toBe(400); + expect(await badDay.json()).toEqual({ error: 'Invalid period' }); + const notYmd = await post( + app, + { action: 'log', id: 'daily', period: 'nope', status: 'achieved' }, + AUTH, + ); + expect(notYmd.status).toBe(400); + const tuesday = await post( + app, + { action: 'log', id: 'weekly', period: '2026-09-29', status: 'achieved' }, + AUTH, + ); + expect(tuesday.status).toBe(400); + expect(await tuesday.json()).toEqual({ error: 'Invalid period' }); + const monday = await post( + app, + { action: 'log', id: 'weekly', period: '2026-09-28', status: 'achieved' }, + AUTH, + ); + expect(monday.status).toBe(200); + const past = await post( + app, + { action: 'log', id: 'daily', period: '2026-09-01', status: 'achieved' }, + AUTH, + ); + expect(past.status).toBe(409); + expect(await past.json()).toEqual({ error: 'Period is closed' }); + const other = await post( + app, + { action: 'log', id: 'theirs', period: '2026-10-01', status: 'achieved' }, + AUTH, + ); + expect(other.status).toBe(404); + const unknown = await post( + app, + { action: 'log', id: 'missing', period: '2026-10-01', status: 'achieved' }, + AUTH, + ); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toEqual({ error: 'Not found' }); + }); + + it('comment rejects blank text, text over 2000, a missing habit, and a null author name', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + const app = mount({ store, account: { id: BASIS.id, role: 'basis', name: null } }); + const blank = await post(app, { action: 'comment', habitId: 'h-comment', text: ' ' }, AUTH); + expect(blank.status).toBe(400); + expect(await blank.json()).toEqual({ error: 'Invalid comment' }); + const longText = await post( + app, + { action: 'comment', habitId: 'h-comment', text: 'c'.repeat(2001) }, + AUTH, + ); + expect(longText.status).toBe(400); + const missing = await post(app, { action: 'comment', habitId: 'nope', text: 'hi' }, AUTH); + expect(missing.status).toBe(404); + const ok = await post(app, { action: 'comment', habitId: 'h-comment', text: 'hi' }, AUTH); + expect(ok.status).toBe(201); + const listed = await store.listPublic(null, NOW_OPEN); + expect(listed[0]?.comments[0]?.name).toBe(''); + }); + + it('deleteComment allows moderator and founder, rejects verified and an unknown role, and 404s when missing', async () => { + async function seed(role: string): Promise { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h', accountId: 'owner', role: 'basis' })); + await store.comment({ + id: 'c-1', + habitId: 'h', + accountId: 'owner', + name: 'Owner', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + return post( + mount({ store, account: { id: 'staff', role, name: 'Staff' } }), + { action: 'deleteComment', id: 'c-1' }, + AUTH, + ); + } + expect((await seed('verified')).status).toBe(403); + expect((await seed('moderator')).status).toBe(200); + expect((await seed('founder')).status).toBe(200); + expect((await seed('guest')).status).toBe(403); + const store = new InMemoryMemberHabitStore(); + const missing = await post( + mount({ store, account: INITIATOR }), + { action: 'deleteComment', id: 'nope' }, + AUTH, + ); + expect(missing.status).toBe(404); + await store.add(sampleHabit({ id: 'h', accountId: 'owner', role: 'basis' })); + await store.comment({ + id: 'c-stuck', + habitId: 'h', + accountId: 'owner', + name: 'Owner', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + store.deleteComment = async () => false; + const stuck = await post( + mount({ store, account: INITIATOR }), + { action: 'deleteComment', id: 'c-stuck' }, + AUTH, + ); + expect(stuck.status).toBe(404); + }); + + it('invoice rejects a bad amount, a missing comment, an empty wallet, a second burst, and a failed lookup', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const app = mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: { ...ALICE, lightningAddress: '' } }, + fetchImpl: successFetch, + }); + const zero = await post(app, { action: 'invoice', commentId: 'c-alice', amountSats: 0 }, AUTH); + expect(zero.status).toBe(400); + expect(await zero.json()).toEqual({ error: 'Invalid amount' }); + const fraction = await post( + app, + { action: 'invoice', commentId: 'c-alice', amountSats: 1.5 }, + AUTH, + ); + expect(fraction.status).toBe(400); + const huge = await post( + app, + { action: 'invoice', commentId: 'c-alice', amountSats: 100000001 }, + AUTH, + ); + expect(huge.status).toBe(400); + const missing = await post(app, { action: 'invoice', commentId: 'nope', amountSats: 1 }, AUTH); + expect(missing.status).toBe(404); + const emptyWallet = await post( + app, + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(emptyWallet.status).toBe(409); + expect(await emptyWallet.json()).toEqual({ error: 'No wallet' }); + + const paying = mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + }); + const first = await post( + paying, + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(first.status).toBe(200); + const second = await post( + paying, + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(second.status).toBe(429); + expect(await second.json()).toEqual({ error: 'Too many invoices' }); + + const failFetch: FetchFn = async () => new Response('no', { status: 500 }); + const failed = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: failFetch, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(failed.status).toBe(502); + expect(await failed.json()).toEqual({ error: 'Invoice unavailable' }); + }); + + it('a store whose add throws returns 503', async () => { + const store = new InMemoryMemberHabitStore(); + store.add = async () => { + throw new Error('down'); + }; + const res = await post( + mount({ store, account: BASIS }), + { action: 'add', name: 'Walk', cadence: 'daily' }, + { ...AUTH, 'Time-Zone': 'Asia/Manila' }, + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Habits are unavailable' }); + }); + + it('uses resolveSession when resolve is omitted', async () => { + const authStore = new InMemoryAuthStore(); + await authStore.createAccount({ + id: 'acc-session', + linkingKey: null, + role: 'basis', + name: 'Sam', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + await authStore.createSession({ token: 'tok', accountId: 'acc-session', createdAt: NOW_OPEN }); + const app = memberHabitRoutes({ + store: new InMemoryMemberHabitStore(), + authStore, + now: () => NOW_OPEN, + fetchImpl: unusedFetch, + }); + const res = await post( + app, + { action: 'add', name: 'Walk', cadence: 'daily' }, + { Authorization: 'Bearer tok', 'Time-Zone': 'Asia/Manila' }, + ); + expect(res.status).toBe(201); + const unknown = await post( + app, + { action: 'archive', id: 'x' }, + { Authorization: 'Bearer other' }, + ); + expect(unknown.status).toBe(401); + const expiredStore = new InMemoryAuthStore(); + await expiredStore.createAccount({ + id: 'acc-old', + linkingKey: null, + role: 'basis', + name: 'Old', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + await expiredStore.createSession({ token: 'old', accountId: 'acc-old', createdAt: 1 }); + const expired = memberHabitRoutes({ + store: new InMemoryMemberHabitStore(), + authStore: expiredStore, + now: () => NOW_OPEN, + fetchImpl: unusedFetch, + }); + const stale = await post( + expired, + { action: 'archive', id: 'x' }, + { Authorization: 'Bearer old' }, + ); + expect(stale.status).toBe(401); + const publicGet = await app.request('/'); + expect(publicGet.status).toBe(200); + }); + + it('a throwing resolve returns 503', async () => { + const app = memberHabitRoutes({ + store: new InMemoryMemberHabitStore(), + authStore: { + async getAccount() { + return undefined; + }, + }, + now: () => NOW_OPEN, + fetchImpl: unusedFetch, + resolve: async () => { + throw new Error('boom'); + }, + }); + const res = await post(app, { action: 'archive', id: 'x' }, AUTH); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Habits are unavailable' }); + }); +}); diff --git a/src/lib/boot-stores.ts b/src/lib/boot-stores.ts index 056a78212..ce81e79bc 100644 --- a/src/lib/boot-stores.ts +++ b/src/lib/boot-stores.ts @@ -40,6 +40,11 @@ import { type DiagnosticStore, } from '@/lib/diagnostic-log'; import { migrateContactSchema, PostgresContactStore, type ContactStore } from '@/lib/contact-store'; +import { + migrateMemberHabitSchema, + PostgresMemberHabitStore, + type MemberHabitStore, +} from '@/lib/member-habit-store'; import { InMemoryPosStore, PostgresPosStore, @@ -104,6 +109,11 @@ export interface BootStores { * opened so `createApp` keeps the empty in-memory default. */ contactStore: ContactStore | undefined; + /** + * Postgres-backed member habits, or `undefined` when no SQL client was + * opened so `createApp` keeps the empty in-memory default. + */ + memberHabitStore: MemberHabitStore | undefined; /** POS charge store (memory when no SQL; Postgres otherwise). */ posStore: PosStore; /** @@ -261,6 +271,7 @@ export async function openBootStores( conversationTranslationStore: undefined, nostrKek: undefined, contactStore: undefined, + memberHabitStore: undefined, posStore: new InMemoryPosStore(), apiLogStore: undefined, diagnosticStore: undefined, @@ -283,6 +294,12 @@ export async function openBootStores( await migrateGiftSchema(sqlClient); await migrateMessageSchema(sqlClient); await migrateContactSchema(sqlClient); + const habitSql = { + query: async (text: string, params?: unknown[]) => ({ + rows: await sql.query>(text, params), + }), + }; + await migrateMemberHabitSchema(habitSql); await migratePosSchema(sqlClient); await migrateConversationSchema(sqlClient); await migratePushSchema(sqlClient); @@ -436,6 +453,16 @@ export async function openBootStores( logEvent('nostr.zapper.backfill.failed'); } const contactStore = new PostgresContactStore(sqlClient); + const memberHabitStore = new PostgresMemberHabitStore(habitSql, { + /* v8 ignore start -- wallets are read from the auth store, not this adapter */ + async get() { + return null; + }, + async set() { + return undefined; + }, + /* v8 ignore stop */ + }); const posStore = new PostgresPosStore(sqlClient); const apiLogStore = new PostgresApiLogStore(sqlClient); const conversationStore = new PostgresConversationStore(sqlClient, { fetchImpl, fiatRates, now }); @@ -454,6 +481,7 @@ export async function openBootStores( conversationTranslationStore, nostrKek, contactStore, + memberHabitStore, posStore, apiLogStore, diagnosticStore, diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts new file mode 100644 index 000000000..9d412b65b --- /dev/null +++ b/src/lib/member-habit-store.ts @@ -0,0 +1,826 @@ +import { comparePeriod, dayKey, nextPeriod, weeklyRatableThrough } from '@/lib/member-habit'; + +/** + * Cadence chosen at create time and never changed later. + */ +export type MemberHabitCadence = 'daily' | 'weekly'; + +/** + * Outcome recorded for one habit period. + */ +export type MemberHabitStatus = 'achieved' | 'partial' | 'missed'; + +/** + * Persisted habit row. `lastPeriod` is null while the habit is active. + */ +export type MemberHabit = { + id: string; + accountId: string; + ownerName: string; + role: string; + name: string; + description: string; + notes: string; + cadence: MemberHabitCadence; + timeZone: string; + firstPeriod: string; + lastPeriod: string | null; +}; + +/** + * Upserted status for `(habitId, period)`. + */ +export type MemberHabitLog = { + habitId: string; + period: string; + status: MemberHabitStatus; +}; + +/** + * Comment on a habit. Live when `deletedAt` is null. + */ +export type MemberHabitComment = { + id: string; + habitId: string; + accountId: string; + name: string; + text: string; + week: string; + createdAt: number; + deletedAt: number | null; +}; + +/** + * Public name and description that apply from `period` onward until a later revision. + */ +export type MemberHabitRevision = { + habitId: string; + period: string; + name: string; + description: string; +}; + +type MemberHabitPeriodPublic = { + period: string; + name: string; + description: string; + logged: boolean; + status: MemberHabitStatus | null; +}; + +/** + * Public habit view. `notes` is present only when the viewer owns the habit. + */ +export type MemberHabitPublic = { + id: string; + accountId: string; + ownerName: string; + role: string; + name: string; + description: string; + cadence: MemberHabitCadence; + timeZone: string; + firstPeriod: string; + lastPeriod: string | null; + periods: MemberHabitPeriodPublic[]; + comments: MemberHabitComment[]; + notes?: string; +}; + +type SqlClient = { + query: (text: string, params?: unknown[]) => Promise<{ rows: Record[] }>; +}; + +type LightningAddresses = { + get: (accountId: string) => Promise; + set: (accountId: string, address: string | null) => Promise; +}; + +/** + * Persistence for member habits, logs, comments, revisions, and Lightning addresses. + */ +export interface MemberHabitStore { + add(habit: MemberHabit): Promise; + edit( + id: string, + accountId: string, + patch: { name: string; description: string; notes: string }, + atPeriod: string, + ): Promise<'ok' | 'missing'>; + archive(id: string, accountId: string, lastPeriod: string): Promise<'ok' | 'missing'>; + log( + habitId: string, + accountId: string, + period: string, + status: MemberHabitStatus, + ): Promise<'ok' | 'missing' | 'closed'>; + listPublic(viewerAccountId: string | null, nowMs: number): Promise; + findComment(id: string): Promise; + comment(row: MemberHabitComment): Promise; + deleteComment(id: string): Promise; + setLightning(accountId: string, address: string | null): Promise; + lightning(accountId: string): Promise; +} + +/** + * Idempotent `CREATE TABLE IF NOT EXISTS` statements for the member-habit tables. + */ +export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ + `CREATE TABLE IF NOT EXISTS member_habit ( + id uuid PRIMARY KEY, + account_id uuid NOT NULL, + owner_name text NOT NULL, + role text NOT NULL, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), + description text NOT NULL CHECK (char_length(description) <= 2000), + notes text NOT NULL CHECK (char_length(notes) <= 2000), + cadence text NOT NULL CHECK (cadence IN ('daily', 'weekly')), + time_zone text NOT NULL, + first_period text NOT NULL, + last_period text NULL CHECK (last_period IS NULL OR last_period >= first_period) +);`, + `CREATE TABLE IF NOT EXISTS member_habit_revision ( + habit_id uuid NOT NULL, + period text NOT NULL, + name text NOT NULL, + description text NOT NULL, + PRIMARY KEY (habit_id, period) +);`, + `CREATE TABLE IF NOT EXISTS member_habit_log ( + habit_id uuid NOT NULL, + period text NOT NULL, + status text NOT NULL CHECK (status IN ('achieved', 'partial', 'missed')), + PRIMARY KEY (habit_id, period) +);`, + `CREATE TABLE IF NOT EXISTS member_habit_comment ( + id uuid PRIMARY KEY, + habit_id uuid NOT NULL, + account_id uuid NOT NULL, + name text NOT NULL, + "text" text NOT NULL CHECK (char_length("text") BETWEEN 1 AND 2000), + week text NOT NULL, + created_at double precision NOT NULL, + deleted_at double precision NULL +);`, +]; + +/** + * Runs each `MEMBER_HABIT_SCHEMA_SQL` statement. Safe to call more than once. + */ +export async function migrateMemberHabitSchema(sql: SqlClient): Promise { + for (const statement of MEMBER_HABIT_SCHEMA_SQL) { + await sql.query(statement); + } +} + +function cloneHabit(habit: MemberHabit): MemberHabit { + return { + id: habit.id, + accountId: habit.accountId, + ownerName: habit.ownerName, + role: habit.role, + name: habit.name, + description: habit.description, + notes: habit.notes, + cadence: habit.cadence, + timeZone: habit.timeZone, + firstPeriod: habit.firstPeriod, + lastPeriod: habit.lastPeriod, + }; +} + +function cloneComment(row: MemberHabitComment): MemberHabitComment { + return { + id: row.id, + habitId: row.habitId, + accountId: row.accountId, + name: row.name, + text: row.text, + week: row.week, + createdAt: row.createdAt, + deletedAt: row.deletedAt, + }; +} + +function latestRatableKey(habit: MemberHabit, nowMs: number): string { + if (habit.cadence === 'daily') { + return dayKey(nowMs, habit.timeZone); + } + return weeklyRatableThrough(nowMs, habit.timeZone); +} + +function periodRangeEnd(habit: MemberHabit, nowMs: number): string { + const latest = latestRatableKey(habit, nowMs); + if (habit.lastPeriod !== null && comparePeriod(habit.lastPeriod, latest) < 0) { + return habit.lastPeriod; + } + return latest; +} + +function revisionForPeriod(revisions: MemberHabitRevision[], key: string): MemberHabitRevision { + const sorted = [...revisions].sort((a, b) => comparePeriod(a.period, b.period)); + let match: MemberHabitRevision | null = null; + for (const revision of sorted) { + if (comparePeriod(revision.period, key) <= 0) { + match = revision; + } + } + if (match === null) { + throw new Error(`no revision covering period ${key}`); + } + return match; +} + +function buildPeriods( + habit: MemberHabit, + revisions: MemberHabitRevision[], + logs: Map, + nowMs: number, +): MemberHabitPeriodPublic[] { + const end = periodRangeEnd(habit, nowMs); + if (comparePeriod(habit.firstPeriod, end) > 0) { + return []; + } + const periods: MemberHabitPeriodPublic[] = []; + let key = habit.firstPeriod; + while (comparePeriod(key, end) <= 0) { + const revision = revisionForPeriod(revisions, key); + const status = logs.get(key); + if (status === undefined) { + periods.push({ + period: key, + name: revision.name, + description: revision.description, + logged: false, + status: null, + }); + } else { + periods.push({ + period: key, + name: revision.name, + description: revision.description, + logged: true, + status, + }); + } + key = nextPeriod(key, habit.cadence); + } + return periods; +} + +function toPublic( + habit: MemberHabit, + periods: MemberHabitPeriodPublic[], + comments: MemberHabitComment[], + viewerAccountId: string | null, +): MemberHabitPublic { + const base: MemberHabitPublic = { + id: habit.id, + accountId: habit.accountId, + ownerName: habit.ownerName, + role: habit.role, + name: habit.name, + description: habit.description, + cadence: habit.cadence, + timeZone: habit.timeZone, + firstPeriod: habit.firstPeriod, + lastPeriod: habit.lastPeriod, + periods, + comments: comments.map(cloneComment), + }; + if (viewerAccountId !== null && viewerAccountId === habit.accountId) { + return { ...base, notes: habit.notes }; + } + return base; +} + +function sortLiveComments(comments: MemberHabitComment[]): MemberHabitComment[] { + return [...comments].sort((a, b) => { + if (a.createdAt < b.createdAt) { + return -1; + } + if (a.createdAt > b.createdAt) { + return 1; + } + if (a.id < b.id) { + return -1; + } + /* v8 ignore next 3 -- two comments in the map cannot share createdAt and id */ + if (a.id === b.id) { + return 0; + } + return 1; + }); +} + +function logClosed(habit: MemberHabit, period: string): boolean { + if (comparePeriod(period, habit.firstPeriod) < 0) { + return true; + } + if (habit.lastPeriod !== null && comparePeriod(period, habit.lastPeriod) > 0) { + return true; + } + return false; +} + +function stringColumn(row: Record, key: string): string { + const value = row[key]; + if (typeof value !== 'string') { + throw new Error(`expected string column ${key}`); + } + return value; +} + +function nullableStringColumn(row: Record, key: string): string | null { + const value = row[key]; + if (value === null) { + return null; + } + if (typeof value !== 'string') { + throw new Error(`expected string or null column ${key}`); + } + return value; +} + +function numberColumn(row: Record, key: string): number { + const value = row[key]; + if (typeof value !== 'number') { + throw new Error(`expected number column ${key}`); + } + return value; +} + +function nullableNumberColumn(row: Record, key: string): number | null { + const value = row[key]; + if (value === null) { + return null; + } + if (typeof value !== 'number') { + throw new Error(`expected number or null column ${key}`); + } + return value; +} + +function asCadence(value: string): MemberHabitCadence { + if (value === 'daily' || value === 'weekly') { + return value; + } + throw new Error(`invalid cadence: ${value}`); +} + +function asStatus(value: string): MemberHabitStatus { + if (value === 'achieved' || value === 'partial' || value === 'missed') { + return value; + } + throw new Error(`invalid status: ${value}`); +} + +function habitFromRow(row: Record): MemberHabit { + return { + id: stringColumn(row, 'id'), + accountId: stringColumn(row, 'account_id'), + ownerName: stringColumn(row, 'owner_name'), + role: stringColumn(row, 'role'), + name: stringColumn(row, 'name'), + description: stringColumn(row, 'description'), + notes: stringColumn(row, 'notes'), + cadence: asCadence(stringColumn(row, 'cadence')), + timeZone: stringColumn(row, 'time_zone'), + firstPeriod: stringColumn(row, 'first_period'), + lastPeriod: nullableStringColumn(row, 'last_period'), + }; +} + +function revisionFromRow(row: Record): MemberHabitRevision { + return { + habitId: stringColumn(row, 'habit_id'), + period: stringColumn(row, 'period'), + name: stringColumn(row, 'name'), + description: stringColumn(row, 'description'), + }; +} + +function commentFromRow(row: Record): MemberHabitComment { + return { + id: stringColumn(row, 'id'), + habitId: stringColumn(row, 'habit_id'), + accountId: stringColumn(row, 'account_id'), + name: stringColumn(row, 'name'), + text: stringColumn(row, 'text'), + week: stringColumn(row, 'week'), + createdAt: numberColumn(row, 'created_at'), + deletedAt: nullableNumberColumn(row, 'deleted_at'), + }; +} + +function firstRow(rows: Record[]): Record | null { + if (rows.length === 0) { + return null; + } + const row = rows[0]; + if (row === undefined) { + return null; + } + return row; +} + +function pushByKey(map: Map, key: string, value: T): void { + const existing = map.get(key); + if (existing === undefined) { + map.set(key, [value]); + return; + } + existing.push(value); +} + +/** + * In-process `MemberHabitStore`. Insertion order is list order. + */ +export class InMemoryMemberHabitStore implements MemberHabitStore { + private readonly habits = new Map(); + private readonly revisions = new Map(); + private readonly logs = new Map>(); + private readonly comments = new Map(); + private readonly lightningByAccount = new Map(); + + async add(habit: MemberHabit): Promise { + const stored = cloneHabit(habit); + this.habits.set(stored.id, stored); + this.revisions.set(stored.id, [ + { + habitId: stored.id, + period: stored.firstPeriod, + name: stored.name, + description: stored.description, + }, + ]); + } + + async edit( + id: string, + accountId: string, + patch: { name: string; description: string; notes: string }, + atPeriod: string, + ): Promise<'ok' | 'missing'> { + const habit = this.habits.get(id); + if (habit === undefined || habit.accountId !== accountId) { + return 'missing'; + } + habit.notes = patch.notes; + if (habit.name !== patch.name || habit.description !== patch.description) { + habit.name = patch.name; + habit.description = patch.description; + } + const list = this.revisions.get(id); + /* v8 ignore next 3 -- add() always inserts the revision list */ + if (list === undefined) { + throw new Error(`missing revisions for habit ${id}`); + } + const next: MemberHabitRevision = { + habitId: id, + period: atPeriod, + name: patch.name, + description: patch.description, + }; + const index = list.findIndex((revision) => revision.period === atPeriod); + if (index === -1) { + list.push(next); + } else { + list[index] = next; + } + return 'ok'; + } + + async archive(id: string, accountId: string, lastPeriod: string): Promise<'ok' | 'missing'> { + const habit = this.habits.get(id); + if (habit === undefined || habit.accountId !== accountId) { + return 'missing'; + } + if (habit.lastPeriod !== null) { + return 'ok'; + } + habit.lastPeriod = lastPeriod; + return 'ok'; + } + + async log( + habitId: string, + accountId: string, + period: string, + status: MemberHabitStatus, + ): Promise<'ok' | 'missing' | 'closed'> { + const habit = this.habits.get(habitId); + if (habit === undefined || habit.accountId !== accountId) { + return 'missing'; + } + if (logClosed(habit, period)) { + return 'closed'; + } + let byPeriod = this.logs.get(habitId); + if (byPeriod === undefined) { + byPeriod = new Map(); + this.logs.set(habitId, byPeriod); + } + byPeriod.set(period, status); + return 'ok'; + } + + async listPublic(viewerAccountId: string | null, nowMs: number): Promise { + const result: MemberHabitPublic[] = []; + for (const habit of this.habits.values()) { + const revisions = this.revisions.get(habit.id); + /* v8 ignore next 3 -- add() always inserts the revision list */ + if (revisions === undefined) { + throw new Error(`missing revisions for habit ${habit.id}`); + } + const logs = this.logs.get(habit.id); + const logMap = logs === undefined ? new Map() : logs; + const comments: MemberHabitComment[] = []; + for (const row of this.comments.values()) { + if (row.habitId === habit.id && row.deletedAt === null) { + comments.push(row); + } + } + result.push( + toPublic( + habit, + buildPeriods(habit, revisions, logMap, nowMs), + sortLiveComments(comments), + viewerAccountId, + ), + ); + } + return result; + } + + async findComment(id: string): Promise { + const row = this.comments.get(id); + if (row === undefined || row.deletedAt !== null) { + return null; + } + return cloneComment(row); + } + + async comment(row: MemberHabitComment): Promise { + this.comments.set(row.id, cloneComment(row)); + } + + async deleteComment(id: string): Promise { + const row = this.comments.get(id); + if (row === undefined || row.deletedAt !== null) { + return false; + } + row.deletedAt = Date.now(); + return true; + } + + async setLightning(accountId: string, address: string | null): Promise { + if (address === null) { + this.lightningByAccount.delete(accountId); + return; + } + this.lightningByAccount.set(accountId, address); + } + + async lightning(accountId: string): Promise { + const address = this.lightningByAccount.get(accountId); + if (address === undefined) { + return null; + } + return address; + } +} + +/** + * Postgres `MemberHabitStore`. Lightning addresses are stored via `addresses`. + */ +export class PostgresMemberHabitStore implements MemberHabitStore { + constructor( + private readonly sql: SqlClient, + private readonly addresses: LightningAddresses, + ) {} + + async add(habit: MemberHabit): Promise { + await this.sql.query( + `INSERT INTO member_habit ( + id, account_id, owner_name, role, name, description, notes, + cadence, time_zone, first_period, last_period + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + habit.id, + habit.accountId, + habit.ownerName, + habit.role, + habit.name, + habit.description, + habit.notes, + habit.cadence, + habit.timeZone, + habit.firstPeriod, + habit.lastPeriod, + ], + ); + await this.sql.query( + `INSERT INTO member_habit_revision (habit_id, period, name, description) + VALUES ($1, $2, $3, $4)`, + [habit.id, habit.firstPeriod, habit.name, habit.description], + ); + } + + async edit( + id: string, + accountId: string, + patch: { name: string; description: string; notes: string }, + atPeriod: string, + ): Promise<'ok' | 'missing'> { + const habit = await this.ownedHabit(id, accountId); + if (habit === null) { + return 'missing'; + } + await this.sql.query( + `UPDATE member_habit SET name = $1, description = $2, notes = $3 WHERE id = $4`, + [patch.name, patch.description, patch.notes, id], + ); + await this.sql.query( + `INSERT INTO member_habit_revision (habit_id, period, name, description) + VALUES ($1, $2, $3, $4) + ON CONFLICT (habit_id, period) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description`, + [id, atPeriod, patch.name, patch.description], + ); + return 'ok'; + } + + async archive(id: string, accountId: string, lastPeriod: string): Promise<'ok' | 'missing'> { + const habit = await this.ownedHabit(id, accountId); + if (habit === null) { + return 'missing'; + } + if (habit.lastPeriod !== null) { + return 'ok'; + } + await this.sql.query(`UPDATE member_habit SET last_period = $1 WHERE id = $2`, [ + lastPeriod, + id, + ]); + return 'ok'; + } + + async log( + habitId: string, + accountId: string, + period: string, + status: MemberHabitStatus, + ): Promise<'ok' | 'missing' | 'closed'> { + const habit = await this.ownedHabit(habitId, accountId); + if (habit === null) { + return 'missing'; + } + if (logClosed(habit, period)) { + return 'closed'; + } + await this.sql.query( + `INSERT INTO member_habit_log (habit_id, period, status) + VALUES ($1, $2, $3) + ON CONFLICT (habit_id, period) DO UPDATE SET status = EXCLUDED.status`, + [habitId, period, status], + ); + return 'ok'; + } + + async listPublic(viewerAccountId: string | null, nowMs: number): Promise { + const habitResult = await this.sql.query( + `SELECT id, account_id, owner_name, role, name, description, notes, + cadence, time_zone, first_period, last_period + FROM member_habit + ORDER BY first_period ASC, id ASC`, + ); + const revisionResult = await this.sql.query( + `SELECT habit_id, period, name, description FROM member_habit_revision`, + ); + const logResult = await this.sql.query(`SELECT habit_id, period, status FROM member_habit_log`); + const commentResult = await this.sql.query( + `SELECT id, habit_id, account_id, name, "text", week, created_at, deleted_at + FROM member_habit_comment + WHERE deleted_at IS NULL + ORDER BY created_at ASC, id ASC`, + ); + const revisionsByHabit = new Map(); + for (const row of revisionResult.rows) { + const revision = revisionFromRow(row); + pushByKey(revisionsByHabit, revision.habitId, revision); + } + const logsByHabit = new Map>(); + for (const row of logResult.rows) { + const habitId = stringColumn(row, 'habit_id'); + const period = stringColumn(row, 'period'); + const status = asStatus(stringColumn(row, 'status')); + let byPeriod = logsByHabit.get(habitId); + if (byPeriod === undefined) { + byPeriod = new Map(); + logsByHabit.set(habitId, byPeriod); + } + byPeriod.set(period, status); + } + const commentsByHabit = new Map(); + for (const row of commentResult.rows) { + const comment = commentFromRow(row); + pushByKey(commentsByHabit, comment.habitId, comment); + } + const result: MemberHabitPublic[] = []; + for (const row of habitResult.rows) { + const habit = habitFromRow(row); + const revisions = revisionsByHabit.get(habit.id); + if (revisions === undefined) { + throw new Error(`missing revisions for habit ${habit.id}`); + } + const logs = logsByHabit.get(habit.id); + const logMap = logs === undefined ? new Map() : logs; + const comments = commentsByHabit.get(habit.id); + const live = comments === undefined ? [] : sortLiveComments(comments); + result.push( + toPublic(habit, buildPeriods(habit, revisions, logMap, nowMs), live, viewerAccountId), + ); + } + return result; + } + + async findComment(id: string): Promise { + const result = await this.sql.query( + `SELECT id, habit_id, account_id, name, "text", week, created_at, deleted_at + FROM member_habit_comment + WHERE id = $1 AND deleted_at IS NULL`, + [id], + ); + const row = firstRow(result.rows); + if (row === null) { + return null; + } + return commentFromRow(row); + } + + async comment(row: MemberHabitComment): Promise { + await this.sql.query( + `INSERT INTO member_habit_comment ( + id, habit_id, account_id, name, "text", week, created_at, deleted_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + row.id, + row.habitId, + row.accountId, + row.name, + row.text, + row.week, + row.createdAt, + row.deletedAt, + ], + ); + } + + async deleteComment(id: string): Promise { + const result = await this.sql.query( + `SELECT deleted_at FROM member_habit_comment WHERE id = $1`, + [id], + ); + const row = firstRow(result.rows); + if (row === null) { + return false; + } + if (nullableNumberColumn(row, 'deleted_at') !== null) { + return false; + } + await this.sql.query( + `UPDATE member_habit_comment SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL`, + [Date.now(), id], + ); + return true; + } + + async setLightning(accountId: string, address: string | null): Promise { + await this.addresses.set(accountId, address); + } + + async lightning(accountId: string): Promise { + return this.addresses.get(accountId); + } + + private async ownedHabit(id: string, accountId: string): Promise { + const result = await this.sql.query( + `SELECT id, account_id, owner_name, role, name, description, notes, + cadence, time_zone, first_period, last_period + FROM member_habit + WHERE id = $1`, + [id], + ); + const row = firstRow(result.rows); + if (row === null) { + return null; + } + const habit = habitFromRow(row); + if (habit.accountId !== accountId) { + return null; + } + return habit; + } +} diff --git a/src/lib/member-habit.ts b/src/lib/member-habit.ts new file mode 100644 index 000000000..3786898ed --- /dev/null +++ b/src/lib/member-habit.ts @@ -0,0 +1,183 @@ +type Cadence = 'daily' | 'weekly'; + +type ZonedParts = { + year: string; + month: string; + day: string; + weekday: string; + hour: number; +}; + +function partValue(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string { + const found = parts.find((part) => part.type === type); + /* v8 ignore next 3 -- Intl always emits year, month, day, weekday, and hour */ + if (found === undefined) { + throw new Error(`Intl part missing: ${type}`); + } + return found.value; +} + +function zonedParts(nowMs: number, timeZone: string): ZonedParts { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23', + weekday: 'short', + }).formatToParts(new Date(nowMs)); + const hourRaw = partValue(parts, 'hour'); + const hour = Number(hourRaw); + /* v8 ignore next 3 -- hourCycle h23 only emits integers 0 through 23 */ + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + throw new Error(`invalid zoned hour: ${hourRaw}`); + } + return { + year: partValue(parts, 'year'), + month: partValue(parts, 'month'), + day: partValue(parts, 'day'), + weekday: partValue(parts, 'weekday'), + hour, + }; +} + +function parseYmd(key: string): { year: number; month: number; day: number } { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key); + if (match === null) { + throw new Error(`invalid YYYY-MM-DD: ${key}`); + } + const yearStr = match[1]; + const monthStr = match[2]; + const dayStr = match[3]; + /* v8 ignore next 3 -- a successful match of three groups always binds them */ + if (yearStr === undefined || monthStr === undefined || dayStr === undefined) { + throw new Error(`invalid YYYY-MM-DD: ${key}`); + } + return { + year: Number(yearStr), + month: Number(monthStr), + day: Number(dayStr), + }; +} + +function addUtcDays(key: string, days: number): string { + const { year, month, day } = parseYmd(key); + const utc = new Date(Date.UTC(year, month - 1, day + days)); + const y = String(utc.getUTCFullYear()).padStart(4, '0'); + const m = String(utc.getUTCMonth() + 1).padStart(2, '0'); + const d = String(utc.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** + * True only when `zone` is non-empty and `Intl` accepts it as a time zone. + */ +export function isValidTimeZone(zone: string): boolean { + if (zone === '') { + return false; + } + try { + new Intl.DateTimeFormat(undefined, { timeZone: zone }); + return true; + } catch { + return false; + } +} + +/** + * Calendar date `YYYY-MM-DD` of `nowMs` in `timeZone`. + */ +export function dayKey(nowMs: number, timeZone: string): string { + const parts = zonedParts(nowMs, timeZone); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +/** + * Monday `YYYY-MM-DD` of the calendar week (Monday start) that contains + * `dayKey(nowMs, timeZone)`. Uses UTC date arithmetic on that Y-M-D. + */ +export function weekKey(nowMs: number, timeZone: string): string { + const day = dayKey(nowMs, timeZone); + const { year, month, day: monthDay } = parseYmd(day); + const utc = new Date(Date.UTC(year, month - 1, monthDay)); + const daysFromMonday = (utc.getUTCDay() + 6) % 7; + return addUtcDays(day, -daysFromMonday); +} + +/** + * `dayKey` when `cadence` is `daily`, `weekKey` when `weekly`. + */ +export function periodKey(nowMs: number, cadence: Cadence, timeZone: string): string { + if (cadence === 'daily') { + return dayKey(nowMs, timeZone); + } + return weekKey(nowMs, timeZone); +} + +/** + * Next calendar day, or the Monday seven days later. `key` is `YYYY-MM-DD`. + */ +export function nextPeriod(key: string, cadence: Cadence): string { + if (cadence === 'daily') { + return addUtcDays(key, 1); + } + return addUtcDays(key, 7); +} + +/** + * Lexical compare of `YYYY-MM-DD`, which is chronological. + * `-1` when `a < b`, `0` when equal, `1` when `a > b`. + */ +export function comparePeriod(a: string, b: string): -1 | 0 | 1 { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} + +/** + * Latest Monday that is ratable at `nowMs` in `timeZone`. + * A week becomes ratable at 08:00 on the following Monday in that zone. + */ +export function weeklyRatableThrough(nowMs: number, timeZone: string): string { + const monday = weekKey(nowMs, timeZone); + const parts = zonedParts(nowMs, timeZone); + if (parts.weekday === 'Mon' && parts.hour < 8) { + return addUtcDays(monday, -14); + } + return addUtcDays(monday, -7); +} + +/** + * Manila review week: `{ start: weeklyRatableThrough(nowMs, 'Asia/Manila') }`. + */ +export function manilaReviewWeek(nowMs: number): { start: string } { + return { start: weeklyRatableThrough(nowMs, 'Asia/Manila') }; +} + +/** + * True only inside `[Monday 16:00, Saturday 20:00)` Asia/Manila of the + * calendar week that follows `manilaReviewWeek(nowMs).start`. + */ +export function commentsOpen(nowMs: number): boolean { + const manila = 'Asia/Manila'; + const openWeekMonday = nextPeriod(manilaReviewWeek(nowMs).start, 'weekly'); + if (weekKey(nowMs, manila) !== openWeekMonday) { + return false; + } + const parts = zonedParts(nowMs, manila); + if (parts.weekday === 'Sun') { + return false; + } + if (parts.weekday === 'Mon') { + return parts.hour >= 16; + } + if (parts.weekday === 'Sat') { + return parts.hour < 20; + } + return true; +} diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts new file mode 100644 index 000000000..915e459a5 --- /dev/null +++ b/src/routes/member-habits.ts @@ -0,0 +1,487 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { bearerToken } from '@/routes/me'; +import { resolveSession } from '@/lib/auth/service'; +import { roleAtLeast } from '@/lib/auth/roles'; +import type { AccountRole } from '@/lib/auth/store'; +import { InvoiceRateLimiter } from '@/lib/nostr/rate-limit'; +import { requestGiftInvoice } from '@/lib/gift-invoice'; +import type { FetchFn } from '@/lib/lnurlp'; +import { + commentsOpen, + comparePeriod, + dayKey, + isValidTimeZone, + manilaReviewWeek, + periodKey, + weekKey, + weeklyRatableThrough, +} from '@/lib/member-habit'; +import type { MemberHabit, MemberHabitStore } from '@/lib/member-habit-store'; + +const PERIOD_RE = /^\d{4}-\d{2}-\d{2}$/; + +const addBody = z + .object({ + action: z.literal('add'), + name: z.string(), + description: z.string().optional().default(''), + notes: z.string().optional().default(''), + cadence: z.enum(['daily', 'weekly']), + }) + .strict(); + +const editBody = z + .object({ + action: z.literal('edit'), + id: z.string(), + name: z.string(), + description: z.string().optional().default(''), + notes: z.string().optional().default(''), + }) + .strict(); + +const archiveBody = z + .object({ + action: z.literal('archive'), + id: z.string(), + }) + .strict(); + +const logBody = z + .object({ + action: z.literal('log'), + id: z.string(), + period: z.string(), + status: z.string(), + }) + .strict(); + +const commentBody = z + .object({ + action: z.literal('comment'), + habitId: z.string(), + text: z.string(), + }) + .strict(); + +const deleteCommentBody = z + .object({ + action: z.literal('deleteComment'), + id: z.string(), + }) + .strict(); + +const invoiceBody = z + .object({ + action: z.literal('invoice'), + commentId: z.string(), + amountSats: z.number(), + }) + .strict(); + +const postBody = z.discriminatedUnion('action', [ + addBody, + editBody, + archiveBody, + logBody, + commentBody, + deleteCommentBody, + invoiceBody, +]); + +type Viewer = { id: string; role: string; name: string | null }; + +function isAccountRole(role: string): role is AccountRole { + return ( + role === 'basis' || + role === 'verified' || + role === 'moderator' || + role === 'initiator' || + role === 'founder' + ); +} + +function isHabitStatus(status: string): status is 'achieved' | 'partial' | 'missed' { + return status === 'achieved' || status === 'partial' || status === 'missed'; +} + +function isRealYmd(period: string): boolean { + const year = Number(period.slice(0, 4)); + const month = Number(period.slice(5, 7)); + const day = Number(period.slice(8, 10)); + const utc = new Date(Date.UTC(year, month - 1, day)); + return ( + utc.getUTCFullYear() === year && utc.getUTCMonth() + 1 === month && utc.getUTCDate() === day + ); +} + +function roleGroup(role: string): number { + if (role === 'founder') { + return 0; + } + if (role === 'initiator') { + return 1; + } + return 2; +} + +function invalidText( + name: string, + description: string, + notes: string, +): 'Invalid name' | 'Invalid description' | 'Invalid notes' | null { + const trimmed = name.trim(); + if (trimmed.length < 1 || trimmed.length > 80) { + return 'Invalid name'; + } + if (description.length > 2000) { + return 'Invalid description'; + } + if (notes.length > 2000) { + return 'Invalid notes'; + } + return null; +} + +function publicComments( + comments: Array<{ + id: string; + habitId: string; + accountId: string; + name: string; + text: string; + week: string; + createdAt: number; + deletedAt: number | null; + }>, +): Array<{ + id: string; + habitId: string; + accountId: string; + name: string; + text: string; + week: string; + createdAt: number; +}> { + return comments.map((comment) => ({ + id: comment.id, + habitId: comment.habitId, + accountId: comment.accountId, + name: comment.name, + text: comment.text, + week: comment.week, + createdAt: comment.createdAt, + })); +} + +/** + * Hono routes `GET /` and `POST /` mounted at `/habits`. + */ +export function memberHabitRoutes(deps: { + store: MemberHabitStore; + authStore: { + getAccount(id: string): Promise< + | { + id: string; + role: string; + name: string | null; + lightningAddress: string | null; + } + | undefined + >; + }; + now: () => number; + fetchImpl: FetchFn; + resolve?: (header: string | undefined) => Promise; +}): Hono { + const invoiceLimiter = new InvoiceRateLimiter(); + const resolve = + deps.resolve ?? + (async (header: string | undefined): Promise => { + const token = bearerToken(header); + if (token === null) { + return null; + } + const account = await resolveSession( + deps.authStore as Parameters[0], + deps.now(), + token, + ); + if (account === null) { + return null; + } + return { id: account.id, role: account.role, name: account.name }; + }); + + const app = new Hono(); + + app.get('/', async (c) => { + c.header('Cache-Control', 'no-store'); + try { + const viewer = await resolve(c.req.header('Authorization')); + const viewerId = viewer === null ? null : viewer.id; + const nowMs = deps.now(); + const listed = await deps.store.listPublic(viewerId, nowMs); + listed.sort((a, b) => { + const group = roleGroup(a.role) - roleGroup(b.role); + if (group !== 0) { + return group; + } + if (a.ownerName !== b.ownerName) { + return a.ownerName < b.ownerName ? -1 : 1; + } + if (a.name !== b.name) { + return a.name < b.name ? -1 : 1; + } + return 0; + }); + const habits = listed.map((habit) => { + const comments = publicComments(habit.comments); + const body = { + id: habit.id, + accountId: habit.accountId, + ownerName: habit.ownerName, + role: habit.role, + name: habit.name, + description: habit.description, + cadence: habit.cadence, + timeZone: habit.timeZone, + firstPeriod: habit.firstPeriod, + lastPeriod: habit.lastPeriod, + periods: habit.periods, + comments, + }; + if (habit.notes === undefined) { + return body; + } + return { ...body, notes: habit.notes }; + }); + const review = manilaReviewWeek(nowMs); + return c.json({ + reviewWeek: { start: review.start, commentsOpen: commentsOpen(nowMs) }, + habits, + }); + } catch { + console.warn(JSON.stringify({ ts: new Date().toISOString(), event: 'habits.failed' })); + return c.json({ error: 'Habits are unavailable' }, 503); + } + }); + + app.post('/', async (c) => { + c.header('Cache-Control', 'no-store'); + const header = c.req.header('Authorization'); + if (bearerToken(header) === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + let account: Viewer | null; + try { + account = await resolve(header); + } catch { + console.warn(JSON.stringify({ ts: new Date().toISOString(), event: 'habits.failed' })); + return c.json({ error: 'Habits are unavailable' }, 503); + } + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + const raw = await c.req.json().catch(() => null); + const parsed = postBody.safeParse(raw); + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400); + } + const body = parsed.data; + const nowMs = deps.now(); + try { + if (body.action === 'add') { + const zone = c.req.header('Time-Zone'); + if (zone === undefined || !isValidTimeZone(zone)) { + return c.json({ error: 'Invalid time zone' }, 400); + } + const textError = invalidText(body.name, body.description, body.notes); + if (textError !== null) { + return c.json({ error: textError }, 400); + } + const id = crypto.randomUUID(); + const habit: MemberHabit = { + id, + accountId: account.id, + ownerName: account.name ?? '', + role: account.role, + name: body.name.trim(), + description: body.description, + notes: body.notes, + cadence: body.cadence, + timeZone: zone, + firstPeriod: periodKey(nowMs, body.cadence, zone), + lastPeriod: null, + }; + await deps.store.add(habit); + return c.json({ ok: true, id }, 201); + } + + if (body.action === 'edit') { + const textError = invalidText(body.name, body.description, body.notes); + if (textError !== null) { + return c.json({ error: textError }, 400); + } + const habits = await deps.store.listPublic(account.id, nowMs); + const habit = habits.find((row) => row.id === body.id); + if (habit === undefined) { + return c.json({ error: 'Not found' }, 404); + } + const result = await deps.store.edit( + body.id, + account.id, + { name: body.name.trim(), description: body.description, notes: body.notes }, + periodKey(nowMs, habit.cadence, habit.timeZone), + ); + if (result === 'missing') { + return c.json({ error: 'Not found' }, 404); + } + return c.json({ ok: true }, 200); + } + + if (body.action === 'archive') { + const habits = await deps.store.listPublic(account.id, nowMs); + const habit = habits.find((row) => row.id === body.id); + if (habit === undefined) { + return c.json({ error: 'Not found' }, 404); + } + const result = await deps.store.archive( + body.id, + account.id, + periodKey(nowMs, habit.cadence, habit.timeZone), + ); + if (result === 'missing') { + return c.json({ error: 'Not found' }, 404); + } + return c.json({ ok: true }, 200); + } + + if (body.action === 'log') { + if (!isHabitStatus(body.status)) { + return c.json({ error: 'Invalid status' }, 400); + } + if (!PERIOD_RE.test(body.period)) { + return c.json({ error: 'Invalid period' }, 400); + } + const habits = await deps.store.listPublic(account.id, nowMs); + const habit = habits.find((row) => row.id === body.id); + if (habit === undefined) { + return c.json({ error: 'Not found' }, 404); + } + if (habit.cadence === 'weekly') { + const instant = Date.parse(`${body.period}T12:00:00Z`); + if (body.period !== weekKey(instant, habit.timeZone)) { + return c.json({ error: 'Invalid period' }, 400); + } + } else if (!isRealYmd(body.period)) { + return c.json({ error: 'Invalid period' }, 400); + } + const latest = + habit.cadence === 'daily' + ? dayKey(nowMs, habit.timeZone) + : weeklyRatableThrough(nowMs, habit.timeZone); + if (comparePeriod(body.period, latest) > 0) { + return c.json({ error: 'Period is closed' }, 409); + } + const result = await deps.store.log(body.id, account.id, body.period, body.status); + if (result === 'closed') { + return c.json({ error: 'Period is closed' }, 409); + } + if (result === 'missing') { + return c.json({ error: 'Not found' }, 404); + } + return c.json({ ok: true }, 200); + } + + if (body.action === 'comment') { + const text = body.text.trim(); + if (text.length < 1 || text.length > 2000) { + return c.json({ error: 'Invalid comment' }, 400); + } + if (!commentsOpen(nowMs)) { + return c.json({ error: 'Comments are closed' }, 403); + } + const habits = await deps.store.listPublic(null, nowMs); + const habit = habits.find((row) => row.id === body.habitId); + if (habit === undefined) { + return c.json({ error: 'Not found' }, 404); + } + await deps.store.comment({ + id: crypto.randomUUID(), + habitId: body.habitId, + accountId: account.id, + name: account.name ?? '', + text, + week: manilaReviewWeek(nowMs).start, + createdAt: nowMs, + deletedAt: null, + }); + return c.json({ ok: true }, 201); + } + + if (body.action === 'deleteComment') { + if (!isAccountRole(account.role) || !roleAtLeast(account.role, 'initiator')) { + return c.json({ error: 'Forbidden' }, 403); + } + const comment = await deps.store.findComment(body.id); + if (comment === null) { + return c.json({ error: 'Not found' }, 404); + } + const deleted = await deps.store.deleteComment(body.id); + if (!deleted) { + return c.json({ error: 'Not found' }, 404); + } + return c.json({ ok: true }, 200); + } + + if ( + !Number.isInteger(body.amountSats) || + body.amountSats < 1 || + body.amountSats > 100000000 + ) { + return c.json({ error: 'Invalid amount' }, 400); + } + const comment = await deps.store.findComment(body.commentId); + if (comment === null) { + return c.json({ error: 'Not found' }, 404); + } + if (comment.accountId === account.id) { + return c.json({ error: 'Cannot donate to yourself' }, 400); + } + if (!invoiceLimiter.allow(account.id, nowMs)) { + return c.json({ error: 'Too many invoices' }, 429); + } + const author = await deps.authStore.getAccount(comment.accountId); + if ( + author === undefined || + author.lightningAddress === null || + author.lightningAddress === '' + ) { + return c.json({ error: 'No wallet' }, 409); + } + const address = author.lightningAddress; + let invoice: { ok: true; pr: string } | { ok: false }; + try { + invoice = await requestGiftInvoice({ + address, + amountMsat: body.amountSats * 1000, + fetchImpl: deps.fetchImpl, + }); + /* v8 ignore next 3 -- requestGiftInvoice returns ok:false instead of throwing */ + } catch { + return c.json({ error: 'Invoice unavailable' }, 502); + } + if (!invoice.ok) { + return c.json({ error: 'Invoice unavailable' }, 502); + } + return c.json({ pr: invoice.pr }, 200); + } catch { + console.warn(JSON.stringify({ ts: new Date().toISOString(), event: 'habits.failed' })); + return c.json({ error: 'Habits are unavailable' }, 503); + } + }); + + return app; +} diff --git a/src/server.ts b/src/server.ts index a9843709c..707ec56a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,6 +27,7 @@ import { InMemoryTranslationStore, type TranslationStore } from '@/lib/translati import { wellKnownRoutes } from '@/routes/well-known'; import { payRoutes } from '@/routes/pay'; import { contactRoutes } from '@/routes/contact'; +import { memberHabitRoutes } from '@/routes/member-habits'; import { posRoutes } from '@/routes/pos'; import { grantContinuationRoutes } from '@/routes/grant-continuation'; import { shopActivityRoutes } from '@/routes/shop-activity'; @@ -61,6 +62,7 @@ import { InMemoryApiLogStore, type ApiLogStore } from '@/lib/api-log'; import { InMemoryDiagnosticStore, type DiagnosticStore } from '@/lib/diagnostic-log'; import { InMemoryContactStore } from '@/lib/contact-store'; import type { ContactStore } from '@/lib/contact-store'; +import { InMemoryMemberHabitStore, type MemberHabitStore } from '@/lib/member-habit-store'; import { InMemoryPosStore, type PosStore } from '@/lib/pos-store'; import { inboxUnreadCountFor } from '@/lib/conversation-push'; import { InMemoryConversationStore } from '@/lib/conversation-store'; @@ -278,6 +280,10 @@ export interface AppDeps { * {@link PostgresContactStore} when `DATABASE_URL` is set. */ contactStore?: ContactStore; + /** + * Member habit tracker (default: empty {@link InMemoryMemberHabitStore}). + */ + memberHabitStore?: MemberHabitStore; /** * Point-of-sale charges (default: empty {@link InMemoryPosStore}). * Boot injects {@link PostgresPosStore} when `DATABASE_URL` is set. @@ -409,6 +415,7 @@ export function createApp(deps: AppDeps = {}): Hono { } const nostrKek = deps.nostrKek; const contactStore = deps.contactStore ?? new InMemoryContactStore(); + const memberHabitStore = deps.memberHabitStore ?? new InMemoryMemberHabitStore(); const posStore = deps.posStore ?? new InMemoryPosStore(); const apiLogStore = deps.apiLogStore ?? new InMemoryApiLogStore(); const diagnosticStore = deps.diagnosticStore ?? new InMemoryDiagnosticStore(); @@ -695,6 +702,10 @@ export function createApp(deps: AppDeps = {}): Hono { notificationStore, }), ); + app.route( + '/habits', + memberHabitRoutes({ store: memberHabitStore, authStore: store, now, fetchImpl }), + ); app.route('/pos', posRoutes({ store: posStore, authStore: store, now, fetchImpl })); app.route( '/shops/activity', From 8683c15628942c8ddeeb0f5de9fbc24e542e19cc Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:53:09 +0200 Subject: [PATCH 02/23] 01a10b01 - Use Sunday rest for habit comments Comment and comment deletion use the existing Sunday writing pause. A Bitcoin invoice can still be requested on Sunday. The Manila comment window is removed. --- docs/handbook/endpoints.md | 8 +- docs/handbook/functions.md | 11 +-- e2e/functions.spec.ts | 11 ++- src/__tests__/lib/member-habit.test.ts | 29 -------- src/__tests__/routes/member-habits.test.ts | 86 ++++++++++++++++++++-- src/lib/member-habit.ts | 23 ------ src/routes/member-habits.ts | 13 ++-- 7 files changed, 100 insertions(+), 81 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 7d1b3b1cc..8de7b425a 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1153,13 +1153,13 @@ Operator inspection of external Nostr identities that have earned visibility or ## Endpoint: GET /habits - **Purpose:** Public list of member habits. No bearer required. A valid bearer includes `notes` only on the caller's own habits. `notes` is omitted for everyone else. Periods run from `firstPeriod` through the latest ratable day or week and stop at `lastPeriod`. `logged` is false and `status` is null when the owner has not recorded that period. -- **Inputs:** Optional `Authorization: Bearer`. `reviewWeek.start` is the Manila review Monday. `reviewWeek.commentsOpen` is Monday 16:00 inclusive through Saturday 20:00 exclusive Asia/Manila of the following week. +- **Inputs:** Optional `Authorization: Bearer`. `reviewWeek.start` is the Manila review Monday. - **Returns / side effects:** 200 `{ reviewWeek, habits }`. No writes. - **Used by:** the habit tracker page. ## Endpoint: POST /habits -- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `invoice` returns a BOLT11 `pr` and does not pay. Comments are not forum posts. -- **Inputs:** Bearer session. Body is one action object. `add` also sends `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, comment, or amount, and self-donation. 401 missing bearer. 403 comments closed or delete without initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 too many invoices. 502 invoice unavailable. 503 store failure, event `habits.failed`. +- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment` and `deleteComment` follow the same Sunday rest as other public writing: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. A missing, blank, or invalid zone does not refuse. Rating, add, edit, archive, and `invoice` are not that refusal. `invoice` returns a BOLT11 `pr` and does not pay. Comments are not forum posts. +- **Inputs:** Bearer session. Body is one action object. `add`, `comment`, and `deleteComment` also send `Time-Zone`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, comment, or amount, and self-donation. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment` and `deleteComment` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 too many invoices. 502 invoice unavailable. 503 store failure, event `habits.failed`. - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 4c1622682..324f61ef5 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1215,7 +1215,7 @@ - **Purpose:** True when a non-empty `Time-Zone` header names an IANA zone that is in Sunday at `nowMs`. A blank header is false. An invalid zone is false. - **Inputs:** `nowMs` epoch milliseconds and the raw `Time-Zone` header. - **Returns / side effects:** boolean. No I/O. -- **Used by:** conversation routes for a `moderator_group` thread. +- **Used by:** conversation routes for a `moderator_group` thread, and `memberHabitRoutes` for `comment` and `deleteComment`. ## Function: isSundayInZone @@ -3358,7 +3358,7 @@ Builds the operator-only external-pubkey inspection route. - **Purpose:** The next calendar day, or the Monday seven days later. - **Inputs:** A `YYYY-MM-DD` key and a cadence. - **Returns / side effects:** The following period key. Throws when the key is not `YYYY-MM-DD`. -- **Used by:** period ranges and the Manila comment week. +- **Used by:** period ranges. ## Function: comparePeriod @@ -3381,13 +3381,6 @@ Builds the operator-only external-pubkey inspection route. - **Returns / side effects:** `{ start }` with that Monday. No writes. - **Used by:** `GET /habits` and comment creation. -## Function: commentsOpen - -- **Purpose:** Whether comments are open: Monday 16:00 inclusive through Saturday 20:00 exclusive Asia/Manila, in the week after the Manila review week. -- **Inputs:** Epoch milliseconds. -- **Returns / side effects:** `true` or `false`. No writes. -- **Used by:** `GET /habits` and `POST /habits` action `comment`. - ## Function: migrateMemberHabitSchema - **Purpose:** Run the idempotent `CREATE TABLE IF NOT EXISTS` statements for `member_habit`, `member_habit_revision`, `member_habit_log`, and `member_habit_comment`. diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 4c7935b1c..80c8902be 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -3065,9 +3065,13 @@ test('Function: publicExternalAuthorReplies — unknown id is not found', async expect((await request.get('/messages/not-a-uuid/external-replies')).status()).toBe(404); }); -test('Function: memberHabitRoutes — GET /habits is public', async ({ request }) => { +test('Function: memberHabitRoutes — GET /habits is public and POST without bearer is 401', async ({ + request, +}) => { const res = await request.get('/habits'); expect(res.status()).toBe(200); + const denied = await request.post('/habits', { data: { action: 'add' } }); + expect(denied.status()).toBe(401); }); test('Function: isValidTimeZone — GET /habits is public', async ({ request }) => { @@ -3110,11 +3114,6 @@ test('Function: manilaReviewWeek — GET /habits is public', async ({ request }) expect(res.status()).toBe(200); }); -test('Function: commentsOpen — POST /habits without bearer is 401', async ({ request }) => { - const res = await request.post('/habits', { data: { action: 'add' } }); - expect(res.status()).toBe(401); -}); - test('Function: migrateMemberHabitSchema — GET /habits is public', async ({ request }) => { const res = await request.get('/habits'); expect(res.status()).toBe(200); diff --git a/src/__tests__/lib/member-habit.test.ts b/src/__tests__/lib/member-habit.test.ts index 1a524a8b2..610bf68fc 100644 --- a/src/__tests__/lib/member-habit.test.ts +++ b/src/__tests__/lib/member-habit.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { - commentsOpen, comparePeriod, dayKey, isValidTimeZone, @@ -93,36 +92,8 @@ describe('manilaReviewWeek', () => { }); }); -describe('commentsOpen', () => { - it('opens at Monday 16:00 Manila of the week after the review week', () => { - expect(commentsOpen(Date.parse('2026-10-05T07:59:00Z'))).toBe(false); - expect(commentsOpen(Date.parse('2026-10-05T08:00:00Z'))).toBe(true); - }); - - it('closes at Saturday 20:00 Manila', () => { - expect(commentsOpen(Date.parse('2026-10-10T11:59:00Z'))).toBe(true); - expect(commentsOpen(Date.parse('2026-10-10T12:00:00Z'))).toBe(false); - }); - - it('is closed on Sunday and on Monday before 16:00', () => { - expect(commentsOpen(Date.parse('2026-10-04T15:00:00Z'))).toBe(false); - expect(commentsOpen(Date.parse('2026-10-05T07:59:00Z'))).toBe(false); - }); -}); - describe('nextPeriod rejects', () => { it('throws when the key is not YYYY-MM-DD', () => { expect(() => nextPeriod('bad', 'daily')).toThrow(/invalid YYYY-MM-DD/); }); }); - -describe('commentsOpen outside the open week', () => { - it('is closed on Monday before 08:00 Manila because that week is not the comment week', () => { - expect(commentsOpen(Date.parse('2026-10-04T23:59:00.000Z'))).toBe(false); - }); - - it('is open Tuesday through Friday of the comment week', () => { - expect(commentsOpen(Date.parse('2026-10-06T04:00:00.000Z'))).toBe(true); - expect(commentsOpen(Date.parse('2026-10-08T04:00:00.000Z'))).toBe(true); - }); -}); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index a4b6fff6e..d071ed414 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -5,7 +5,7 @@ import type { FetchFn } from '@/lib/lnurlp'; import { InMemoryAuthStore } from '@/lib/auth/store'; const NOW_OPEN = Date.parse('2026-10-05T08:00:00.000Z'); -const NOW_CLOSED = Date.parse('2026-10-05T07:59:00.000Z'); +const SUNDAY_ZURICH = Date.parse('2026-09-27T12:00:00.000Z'); const AUTH = { Authorization: 'Bearer tok' }; type AccountView = { @@ -141,8 +141,10 @@ describe('memberHabitRoutes', () => { const res = await mount({ store, account: null }).request('/'); expect(res.status).toBe(200); const body = (await res.json()) as { + reviewWeek: { start: string }; habits: Array<{ id: string; role: string; notes?: string }>; }; + expect(body.reviewWeek).toEqual({ start: '2026-09-28' }); expect(body.habits.map((habit) => habit.role)).toEqual(['founder', 'initiator', 'basis']); for (const habit of body.habits) { expect('notes' in habit).toBe(false); @@ -276,16 +278,40 @@ describe('memberHabitRoutes', () => { expect(await res.json()).toEqual({ ok: true }); }); - it('comment at 2026-10-05T07:59:00.000Z is 403', async () => { + it('comment on Sunday in Europe/Zurich is 403 SUNDAY_REST', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); const res = await post( - mount({ store, account: BASIS, now: () => NOW_CLOSED }), + mount({ store, account: BASIS, now: () => SUNDAY_ZURICH }), { action: 'comment', habitId: 'h-comment', text: 'nice' }, - AUTH, + { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'Comments are closed' }); + expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); + }); + + it('comment on Sunday without Time-Zone is 201', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + const res = await post( + mount({ store, account: BASIS, now: () => SUNDAY_ZURICH }), + { action: 'comment', habitId: 'h-comment', text: 'nice' }, + AUTH, + ); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('comment on Sunday with an invalid Time-Zone is 201', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + const res = await post( + mount({ store, account: BASIS, now: () => SUNDAY_ZURICH }), + { action: 'comment', habitId: 'h-comment', text: 'nice' }, + { ...AUTH, 'Time-Zone': 'Not/AZone' }, + ); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ ok: true }); }); it('comment at 2026-10-05T08:00:00.000Z by a basis account is 201', async () => { @@ -322,6 +348,28 @@ describe('memberHabitRoutes', () => { expect(await res.json()).toEqual({ error: 'Forbidden' }); }); + it('deleteComment by initiator on Sunday in Europe/Zurich is 403 SUNDAY_REST', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); + await store.comment({ + id: 'c-1', + habitId: 'h-comment', + accountId: BASIS.id, + name: 'Basis', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ store, account: INITIATOR, now: () => SUNDAY_ZURICH }), + { action: 'deleteComment', id: 'c-1' }, + { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); + }); + it('deleteComment by initiator is 200', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); @@ -446,6 +494,34 @@ describe('memberHabitRoutes', () => { expect(await res.json()).toEqual({ pr: 'lnbc1' }); }); + it('invoice on Sunday in Europe/Zurich is still 200', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + now: () => SUNDAY_ZURICH, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc1' }); + }); + it('a store whose listPublic throws returns 503 and the body error Habits are unavailable', async () => { const store = new InMemoryMemberHabitStore(); store.listPublic = async () => { diff --git a/src/lib/member-habit.ts b/src/lib/member-habit.ts index 3786898ed..9e9f7abde 100644 --- a/src/lib/member-habit.ts +++ b/src/lib/member-habit.ts @@ -158,26 +158,3 @@ export function weeklyRatableThrough(nowMs: number, timeZone: string): string { export function manilaReviewWeek(nowMs: number): { start: string } { return { start: weeklyRatableThrough(nowMs, 'Asia/Manila') }; } - -/** - * True only inside `[Monday 16:00, Saturday 20:00)` Asia/Manila of the - * calendar week that follows `manilaReviewWeek(nowMs).start`. - */ -export function commentsOpen(nowMs: number): boolean { - const manila = 'Asia/Manila'; - const openWeekMonday = nextPeriod(manilaReviewWeek(nowMs).start, 'weekly'); - if (weekKey(nowMs, manila) !== openWeekMonday) { - return false; - } - const parts = zonedParts(nowMs, manila); - if (parts.weekday === 'Sun') { - return false; - } - if (parts.weekday === 'Mon') { - return parts.hour >= 16; - } - if (parts.weekday === 'Sat') { - return parts.hour < 20; - } - return true; -} diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 915e459a5..a50eb2a1f 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -8,7 +8,6 @@ import { InvoiceRateLimiter } from '@/lib/nostr/rate-limit'; import { requestGiftInvoice } from '@/lib/gift-invoice'; import type { FetchFn } from '@/lib/lnurlp'; import { - commentsOpen, comparePeriod, dayKey, isValidTimeZone, @@ -17,6 +16,7 @@ import { weekKey, weeklyRatableThrough, } from '@/lib/member-habit'; +import { isSundayRestHeader } from '@/lib/sunday-rest'; import type { MemberHabit, MemberHabitStore } from '@/lib/member-habit-store'; const PERIOD_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -259,7 +259,7 @@ export function memberHabitRoutes(deps: { }); const review = manilaReviewWeek(nowMs); return c.json({ - reviewWeek: { start: review.start, commentsOpen: commentsOpen(nowMs) }, + reviewWeek: { start: review.start }, habits, }); } catch { @@ -396,13 +396,13 @@ export function memberHabitRoutes(deps: { } if (body.action === 'comment') { + if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { + return c.json({ error: 'SUNDAY_REST' }, 403); + } const text = body.text.trim(); if (text.length < 1 || text.length > 2000) { return c.json({ error: 'Invalid comment' }, 400); } - if (!commentsOpen(nowMs)) { - return c.json({ error: 'Comments are closed' }, 403); - } const habits = await deps.store.listPublic(null, nowMs); const habit = habits.find((row) => row.id === body.habitId); if (habit === undefined) { @@ -422,6 +422,9 @@ export function memberHabitRoutes(deps: { } if (body.action === 'deleteComment') { + if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { + return c.json({ error: 'SUNDAY_REST' }, 403); + } if (!isAccountRole(account.role) || !roleAtLeast(account.role, 'initiator')) { return c.json({ error: 'Forbidden' }, 403); } From a593a399208626b843ee7c507e3f2434845d13b8 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:34:25 +0200 Subject: [PATCH 03/23] 01a10b01 - Pause habit invoices on Sunday A habit comment invoice uses the same Sunday rest as a forum zap. A missing or invalid time zone does not refuse it. --- docs/handbook/endpoints.md | 8 +-- docs/handbook/functions.md | 4 +- src/__tests__/routes/member-habits.test.ts | 60 +++++++++++++++++++++- src/routes/member-habits.ts | 3 ++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 8de7b425a..8fdf22117 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -409,7 +409,7 @@ - **Purpose:** Public JSON of outbound gift totals: `totalSats` / `totalBtc` / `totalUsd` plus additive `totalChf` / `totalEur` / `totalPhp`, `giftCount`, `recipientCount`, date range, `spendOverTime` (giftCount+officialCount+sats+BTC+USD+fiat; `officialCount` is the distinct case-insensitive recipient handles that UTC day with kind `daily` or `welcome`, one person once, moderator excluded, gap days 0; `giftCount` remains every outbound row), `byRecipient`, `byMonth`, and `fx` (`quote` stays BTC-USD; `fx.quotes` lists USD always and CHF/EUR/PHP when at least one selected gift day has that cross). The stored payment-time USD/CHF/EUR/PHP is what is returned (not recomputed from that day's close). A gift day that lacks a fiat cross returns that currency as JSON `null` (totals go null if any selected gift lacks that cross). Optional query `recipient` filters to one Wallet of Satoshi handle (case-insensitive). When `recipient` contains `@` after the first character, the local-part before `@` is used; otherwise the whole trimmed string. Missing/blank `recipient` = unfiltered. Unknown handle = empty stats **200** with zeros and USD-only `fx.quotes` (no Coinbase / Frankfurter). Empty boots are empty **200** with zeros and USD-only `fx.quotes` (no Coinbase / Frankfurter). No invoices. - **Errors:** 503 `{ "error": "Gift stats are unavailable" }` when the gift store throws, when BTC-USD `ensureDays` fails, or when any selected gift day still lacks BTC-USD after ensure (`gifts.stats.fx_incomplete` / `gifts.stats.failed`). Missing CHF/EUR/PHP is never 503 (`gifts.stats.fiat_failed` still 200). -- **Used by:** App statistics page (`GET /gifts/stats` same-origin proxy); optional per-recipient view via `?recipient=`; staff payout-goal widget on `/moderate`. +- **Used by:** App statistics page (`GET /gifts/stats` same-origin proxy); optional per-recipient view via `?recipient=`; staff payout-goal widget on `/moderate`; the habit tracker pay sheet when a session is present, for the same fiat suffix as a forum zap. - **Auth:** Public. ## Endpoint: GET /healthz @@ -1159,7 +1159,7 @@ Operator inspection of external Nostr identities that have earned visibility or ## Endpoint: POST /habits -- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment` and `deleteComment` follow the same Sunday rest as other public writing: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. A missing, blank, or invalid zone does not refuse. Rating, add, edit, archive, and `invoice` are not that refusal. `invoice` returns a BOLT11 `pr` and does not pay. Comments are not forum posts. -- **Inputs:** Bearer session. Body is one action object. `add`, `comment`, and `deleteComment` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, comment, or amount, and self-donation. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment` and `deleteComment` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 too many invoices. 502 invoice unavailable. 503 store failure, event `habits.failed`. +- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns a BOLT11 `pr` and does not pay. Comments are not forum posts. +- **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, comment, or amount, and self-donation. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 too many invoices. 502 invoice unavailable. 503 store failure, event `habits.failed`. - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 324f61ef5..8016fccf9 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1215,7 +1215,7 @@ - **Purpose:** True when a non-empty `Time-Zone` header names an IANA zone that is in Sunday at `nowMs`. A blank header is false. An invalid zone is false. - **Inputs:** `nowMs` epoch milliseconds and the raw `Time-Zone` header. - **Returns / side effects:** boolean. No I/O. -- **Used by:** conversation routes for a `moderator_group` thread, and `memberHabitRoutes` for `comment` and `deleteComment`. +- **Used by:** conversation routes for a `moderator_group` thread, and `memberHabitRoutes` for `comment`, `deleteComment`, and `invoice`. ## Function: isSundayInZone @@ -3320,7 +3320,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. Does not pay. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Does not pay. Add, edit, archive, and log stay open on Sunday. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index d071ed414..e14e2bbd5 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -494,7 +494,7 @@ describe('memberHabitRoutes', () => { expect(await res.json()).toEqual({ pr: 'lnbc1' }); }); - it('invoice on Sunday in Europe/Zurich is still 200', async () => { + it('invoice on Sunday in Europe/Zurich is 403 SUNDAY_REST before the amount check', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ @@ -515,9 +515,65 @@ describe('memberHabitRoutes', () => { fetchImpl: successFetch, now: () => SUNDAY_ZURICH, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: 'c-alice', amountSats: 0 }, { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'SUNDAY_REST' }); + }); + + it('invoice on Sunday without Time-Zone is 200', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + now: () => SUNDAY_ZURICH, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc1' }); + }); + + it('invoice on Sunday with an invalid Time-Zone is 200', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: 'c-alice', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + now: () => SUNDAY_ZURICH, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { ...AUTH, 'Time-Zone': 'Not/AZone' }, + ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ pr: 'lnbc1' }); }); diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index a50eb2a1f..db4a74d87 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -439,6 +439,9 @@ export function memberHabitRoutes(deps: { return c.json({ ok: true }, 200); } + if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { + return c.json({ error: 'SUNDAY_REST' }, 403); + } if ( !Number.isInteger(body.amountSats) || body.amountSats < 1 || From c919419b7e37271d093333e517afe1ca9f59bfa2 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:00:45 +0200 Subject: [PATCH 04/23] 01a10b01 - Use the gift invoice contract for habit donations A habit donation returns the existing { pr, amountSats } body, rejects an amount above 10_000_000 sats with the existing amountSats error, and answers the limiter with Too many payments. --- SPEC.md | 65 +++++++++++++++++++++- docs/handbook/endpoints.md | 4 +- docs/handbook/functions.md | 2 +- src/__tests__/routes/member-habits.test.ts | 30 +++++++--- src/routes/member-habits.ts | 9 +-- 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/SPEC.md b/SPEC.md index 61516279f..28f17e574 100644 --- a/SPEC.md +++ b/SPEC.md @@ -168,6 +168,8 @@ Public base URLs used in examples: | 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 | `/habits` | none | Public member habits. A bearer includes private notes only on the caller's own rows. | +| POST | `/habits` | Bearer | Add, edit, archive, or log a habit; comment; delete a comment; or mint `{ pr, amountSats }` for a comment. | | 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. | | POST | `/contact` | Bearer | Send private in-app contact `{ text }` | @@ -228,7 +230,6 @@ Public base URLs used in examples: | GET | `/invoices/eligible` | Bearer `SPEND_API_TOKEN` | Whether the address is funding-eligible today, plus effective grant `status` | | POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey, funding grant, and forum post required) | | POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | - Auth column: "Bearer (X+)" means minimum role X — X or any higher role. ## Role hierarchy @@ -5414,6 +5415,68 @@ public notification, not `{ "ok", "tags" }`. Enqueue failure still returns Success → **Response** `200` (one public notification with `readAt` set, or still `null` for `moderator_proposal`). +### `GET /habits` + +Public list. No bearer required. `Cache-Control: no-store`. A valid bearer +includes `notes` only on the caller's own habits; everyone else's JSON +omits `notes`. Periods run from `firstPeriod` through the latest ratable +day or week. A daily period is ratable through today in the habit's +stored IANA zone. A weekly period becomes ratable at 08:00 on the +following Monday in that zone. `logged` is false and `status` is null +when the owner has not recorded that period. + +Success → **Response** `200` `{ "reviewWeek": { "start" }, "habits" }`. +Store failure → **503** `{ "error": "Habits are unavailable" }`. + +### `POST /habits` + +Bearer session required. One strict JSON action: `add`, `edit`, `archive`, +`log`, `comment`, `deleteComment`, or `invoice`. `Cache-Control: no-store`. +The api does not pay. + +`add` stores the device `Time-Zone` and requires a zone `Intl` accepts. +A missing, blank, or unknown zone is **400** `{ "error": "Invalid time zone" }`. +That check is only for `add`: the stored zone is the habit's clock, which +is a different job from Sunday rest. Name is 1–80 characters after trim. +Description and private notes are optional and at most 2000 characters. +Cadence is `daily` or `weekly` and is not changed by `edit`. Notes stay +owner-only. + +`comment`, `deleteComment`, and `invoice` use the Sunday rest already +stated for public writing (`POST /messages/:id/repayment`: device +`Time-Zone` in Sunday → **403** `{ "error": "SUNDAY_REST" }`, and a +missing, blank, or invalid zone does not refuse). `add`, `edit`, +`archive`, and `log` do not rest on Sunday. The invoice Sunday check is +before the amount check. + +A comment hangs on the habit. It is not a forum post and not a Nostr +note. Text is 1–2000 characters after trim. `deleteComment` requires +initiator rank; a lower role is **403** `{ "error": "Forbidden" }`. + +`invoice` mints a BOLT11 invoice for the comment author's Lightning +Address through the existing gift-invoice helper. Body `amountSats` must +be an integer from 1 through 10_000_000 (the whole-sat form of +`GIFT_INVOICE_MAX_MSAT`). A non-integer, a value below 1, or a value +above that ceiling is **400** +`{ "error": "Expected a JSON body with an integer \"amountSats\"" }`. +Success is the same gift body as `POST /pay/:username/invoice`: + +```json +{ "pr": "lnbc...", "amountSats": 21 } +``` + +Donating to the caller's own comment is **400** +`{ "error": "Cannot donate to yourself" }`. No Lightning Address on the +author is **409** `{ "error": "No wallet" }`. The existing invoice +limiter answers **429** `{ "error": "Too many payments" }`. A failed +mint is **502** `{ "error": "Invoice unavailable" }`. + +Missing bearer → **401** `{ "error": "Unauthorized" }`. A body that is +not one of the actions → **400** `{ "error": "Invalid body" }`. Unknown +habit or comment → **404** `{ "error": "Not found" }`. A period that is +not yet ratable → **409** `{ "error": "Period is closed" }`. Store +failure → **503** `{ "error": "Habits are unavailable" }`. + --- ## Not implemented (v1, decided in CONCEPT — no HTTP paths) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 8fdf22117..dccd692e4 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1159,7 +1159,7 @@ Operator inspection of external Nostr identities that have earned visibility or ## Endpoint: POST /habits -- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns a BOLT11 `pr` and does not pay. Comments are not forum posts. +- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, comment, or amount, and self-donation. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 too many invoices. 502 invoice unavailable. 503 store failure, event `habits.failed`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is not a positive integer or is above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 `{ error: 'Too many payments' }`. 502 invoice unavailable. 503 store failure, event `habits.failed`. - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 8016fccf9..99c8cb4b4 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3320,7 +3320,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Does not pay. Add, edit, archive, and log stay open on Sunday. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A bad or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. Does not pay. Add, edit, archive, and log stay open on Sunday. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index e14e2bbd5..5fcc76aa4 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -467,7 +467,7 @@ describe('memberHabitRoutes', () => { expect(await res.json()).toEqual({ error: 'Invoice unavailable' }); }); - it('invoice when fetchImpl succeeds is 200 { pr: lnbc1 }', async () => { + it('invoice when fetchImpl succeeds is 200 { pr, amountSats }', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ @@ -491,7 +491,7 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc1' }); + expect(await res.json()).toEqual({ pr: 'lnbc1', amountSats: 1 }); }); it('invoice on Sunday in Europe/Zurich is 403 SUNDAY_REST before the amount check', async () => { @@ -547,7 +547,7 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc1' }); + expect(await res.json()).toEqual({ pr: 'lnbc1', amountSats: 1 }); }); it('invoice on Sunday with an invalid Time-Zone is 200', async () => { @@ -575,7 +575,7 @@ describe('memberHabitRoutes', () => { { ...AUTH, 'Time-Zone': 'Not/AZone' }, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc1' }); + expect(await res.json()).toEqual({ pr: 'lnbc1', amountSats: 1 }); }); it('a store whose listPublic throws returns 503 and the body error Habits are unavailable', async () => { @@ -901,7 +901,9 @@ describe('memberHabitRoutes', () => { }); const zero = await post(app, { action: 'invoice', commentId: 'c-alice', amountSats: 0 }, AUTH); expect(zero.status).toBe(400); - expect(await zero.json()).toEqual({ error: 'Invalid amount' }); + expect(await zero.json()).toEqual({ + error: 'Expected a JSON body with an integer "amountSats"', + }); const fraction = await post( app, { action: 'invoice', commentId: 'c-alice', amountSats: 1.5 }, @@ -910,10 +912,13 @@ describe('memberHabitRoutes', () => { expect(fraction.status).toBe(400); const huge = await post( app, - { action: 'invoice', commentId: 'c-alice', amountSats: 100000001 }, + { action: 'invoice', commentId: 'c-alice', amountSats: 10_000_001 }, AUTH, ); expect(huge.status).toBe(400); + expect(await huge.json()).toEqual({ + error: 'Expected a JSON body with an integer "amountSats"', + }); const missing = await post(app, { action: 'invoice', commentId: 'nope', amountSats: 1 }, AUTH); expect(missing.status).toBe(404); const emptyWallet = await post( @@ -923,6 +928,17 @@ describe('memberHabitRoutes', () => { ); expect(emptyWallet.status).toBe(409); expect(await emptyWallet.json()).toEqual({ error: 'No wallet' }); + const atCeiling = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: { ...ALICE, lightningAddress: '' } }, + fetchImpl: successFetch, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: 10_000_000 }, + AUTH, + ); + expect(atCeiling.status).toBe(409); const paying = mount({ store, @@ -942,7 +958,7 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(second.status).toBe(429); - expect(await second.json()).toEqual({ error: 'Too many invoices' }); + expect(await second.json()).toEqual({ error: 'Too many payments' }); const failFetch: FetchFn = async () => new Response('no', { status: 500 }); const failed = await post( diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index db4a74d87..4e647c0a5 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -5,6 +5,7 @@ import { resolveSession } from '@/lib/auth/service'; import { roleAtLeast } from '@/lib/auth/roles'; import type { AccountRole } from '@/lib/auth/store'; import { InvoiceRateLimiter } from '@/lib/nostr/rate-limit'; +import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; import { requestGiftInvoice } from '@/lib/gift-invoice'; import type { FetchFn } from '@/lib/lnurlp'; import { @@ -445,9 +446,9 @@ export function memberHabitRoutes(deps: { if ( !Number.isInteger(body.amountSats) || body.amountSats < 1 || - body.amountSats > 100000000 + body.amountSats > GIFT_INVOICE_MAX_MSAT / 1000 ) { - return c.json({ error: 'Invalid amount' }, 400); + return c.json({ error: 'Expected a JSON body with an integer "amountSats"' }, 400); } const comment = await deps.store.findComment(body.commentId); if (comment === null) { @@ -457,7 +458,7 @@ export function memberHabitRoutes(deps: { return c.json({ error: 'Cannot donate to yourself' }, 400); } if (!invoiceLimiter.allow(account.id, nowMs)) { - return c.json({ error: 'Too many invoices' }, 429); + return c.json({ error: 'Too many payments' }, 429); } const author = await deps.authStore.getAccount(comment.accountId); if ( @@ -482,7 +483,7 @@ export function memberHabitRoutes(deps: { if (!invoice.ok) { return c.json({ error: 'Invoice unavailable' }, 502); } - return c.json({ pr: invoice.pr }, 200); + return c.json({ pr: invoice.pr, amountSats: body.amountSats }, 200); } catch { console.warn(JSON.stringify({ ts: new Date().toISOString(), event: 'habits.failed' })); return c.json({ error: 'Habits are unavailable' }, 503); From 70d4851b29b4f0e1ba3d1e3b5dd51d58411d6b8f Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:40:47 +0200 Subject: [PATCH 05/23] Do not count a habit invoice that has no wallet, and reject a week that is not a real day. --- src/__tests__/routes/member-habits.test.ts | 14 ++++++++++++++ src/routes/member-habits.ts | 11 ++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index 5fcc76aa4..ef2f817b9 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -780,6 +780,13 @@ describe('memberHabitRoutes', () => { ); expect(tuesday.status).toBe(400); expect(await tuesday.json()).toEqual({ error: 'Invalid period' }); + const impossibleWeek = await post( + app, + { action: 'log', id: 'weekly', period: '2026-13-01', status: 'achieved' }, + AUTH, + ); + expect(impossibleWeek.status).toBe(400); + expect(await impossibleWeek.json()).toEqual({ error: 'Invalid period' }); const monday = await post( app, { action: 'log', id: 'weekly', period: '2026-09-28', status: 'achieved' }, @@ -928,6 +935,13 @@ describe('memberHabitRoutes', () => { ); expect(emptyWallet.status).toBe(409); expect(await emptyWallet.json()).toEqual({ error: 'No wallet' }); + const stillNoWallet = await post( + app, + { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + AUTH, + ); + expect(stillNoWallet.status).toBe(409); + expect(await stillNoWallet.json()).toEqual({ error: 'No wallet' }); const atCeiling = await post( mount({ store, diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 4e647c0a5..2daa6c2b7 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -371,13 +371,14 @@ export function memberHabitRoutes(deps: { if (habit === undefined) { return c.json({ error: 'Not found' }, 404); } + if (!isRealYmd(body.period)) { + return c.json({ error: 'Invalid period' }, 400); + } if (habit.cadence === 'weekly') { const instant = Date.parse(`${body.period}T12:00:00Z`); if (body.period !== weekKey(instant, habit.timeZone)) { return c.json({ error: 'Invalid period' }, 400); } - } else if (!isRealYmd(body.period)) { - return c.json({ error: 'Invalid period' }, 400); } const latest = habit.cadence === 'daily' @@ -457,9 +458,6 @@ export function memberHabitRoutes(deps: { if (comment.accountId === account.id) { return c.json({ error: 'Cannot donate to yourself' }, 400); } - if (!invoiceLimiter.allow(account.id, nowMs)) { - return c.json({ error: 'Too many payments' }, 429); - } const author = await deps.authStore.getAccount(comment.accountId); if ( author === undefined || @@ -468,6 +466,9 @@ export function memberHabitRoutes(deps: { ) { return c.json({ error: 'No wallet' }, 409); } + if (!invoiceLimiter.allow(account.id, nowMs)) { + return c.json({ error: 'Too many payments' }, 429); + } const address = author.lightningAddress; let invoice: { ok: true; pr: string } | { ok: false }; try { From 19ea926c9d88c6fe96f9cf2b8415ba03ffa40410 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:59 +0200 Subject: [PATCH 06/23] Document the habit boot path and cover the address port that does not store a wallet. --- docs/handbook/functions.md | 11 +++++---- src/__tests__/lib/boot-stores.test.ts | 13 +++++++++++ src/lib/boot-stores.ts | 12 +++++----- src/lib/member-habit-store.ts | 3 +++ src/lib/member-habit.ts | 32 +++++++++++++++++++++++++++ src/routes/member-habits.ts | 3 +++ src/server.ts | 2 ++ 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 99c8cb4b4..c0aac7c7f 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -482,9 +482,9 @@ ## Function: openBootStores -- **Purpose:** Shared `DATABASE_URL` wiring: one `SqlClient` for durable auth, FX tables, `QueryGiftStore`, `SqlGiftRecorder`, `PostgresBtcUsdStore`, `PostgresFiatStore`, `migrateMessageSchema`, `PostgresMessageStore`, `PostgresTranslationStore` (forum `message_translation`) plus a second `PostgresTranslationStore` aimed at `conversation_message_translation` (`conversationTranslationStore`; never the forum store instance), `migrateContactSchema`, `PostgresContactStore`, `migratePosSchema`, `PostgresPosStore`, `migrateConversationSchema`, `PostgresConversationStore`, `migratePushSchema`, `PostgresPushStore`, `migrateNotificationSchema`, `PostgresNotificationStore`, `migrateTrustSchema`, `migrateFundingSchema`, `PostgresTrustStore`, `PostgresFundingStore`, `migrateApiLogSchema`, `PostgresApiLogStore`, `PostgresDebugDbStore`, `migrateBannerSchema`, `PostgresBannerStore`, `migrateDiagnosticSchema`, `PostgresDiagnosticStore`, `migrateDbChangeSchema`, and parsed `NOSTR_NSEC_KEK`; or in-memory auth, `giftStore`/`giftRecorder`/`messageStore`/`translationStore`/`conversationTranslationStore`/`contactStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`fundingStore`/`bannerStore`/`apiLogStore`/`diagnosticStore`/`listDbChange`/`debugDbStore` undefined, `nostrKek` undefined, empty `InMemoryBtcUsdStore`, and empty `InMemoryFiatStore` when unset. +- **Purpose:** Shared `DATABASE_URL` wiring: one `SqlClient` for durable auth, FX tables, `QueryGiftStore`, `SqlGiftRecorder`, `PostgresBtcUsdStore`, `PostgresFiatStore`, `migrateMessageSchema`, `PostgresMessageStore`, `PostgresTranslationStore` (forum `message_translation`) plus a second `PostgresTranslationStore` aimed at `conversation_message_translation` (`conversationTranslationStore`; never the forum store instance), `migrateContactSchema`, `PostgresContactStore`, `migrateMemberHabitSchema`, `PostgresMemberHabitStore`, `migratePosSchema`, `PostgresPosStore`, `migrateConversationSchema`, `PostgresConversationStore`, `migratePushSchema`, `PostgresPushStore`, `migrateNotificationSchema`, `PostgresNotificationStore`, `migrateTrustSchema`, `migrateFundingSchema`, `PostgresTrustStore`, `PostgresFundingStore`, `migrateApiLogSchema`, `PostgresApiLogStore`, `PostgresDebugDbStore`, `migrateBannerSchema`, `PostgresBannerStore`, `migrateDiagnosticSchema`, `PostgresDiagnosticStore`, `migrateDbChangeSchema`, and parsed `NOSTR_NSEC_KEK`; or in-memory auth, `giftStore`/`giftRecorder`/`messageStore`/`translationStore`/`conversationTranslationStore`/`contactStore`/`memberHabitStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`fundingStore`/`bannerStore`/`apiLogStore`/`diagnosticStore`/`listDbChange`/`debugDbStore` undefined, `nostrKek` undefined, empty `InMemoryBtcUsdStore`, and empty `InMemoryFiatStore` when unset. - **Inputs:** `databaseUrl`; optional `createClient` (required when URL set); optional `fx: { fetchImpl, candlesUrl, frankfurterUrl, now, nostrQuerier, zapRelayUrls, nostrRelayTimeoutMs }` so tests avoid the network (`candlesUrl` defaults via `resolveCandlesUrl(process.env)`; `frankfurterUrl` defaults via `resolveFrankfurterUrl(process.env)`; the last three feed `backfillExternalZappers` and default to a `WebsocketNostrQuerier`, `resolveZapRelays(process.env)` and a 5000 ms per-relay timeout). SQL path reads `process.env.NOSTR_NSEC_KEK`. -- **Returns / side effects:** `{ authStore, giftStore, giftRecorder, btcUsdRates, fiatRates, messageStore, translationStore, conversationTranslationStore, contactStore, posStore, conversationStore, notificationStore, pushStore, trustStore, fundingStore, bannerStore, apiLogStore, diagnosticStore, nostrKek, listDbChange, debugDbStore }`. Migrates `btc_usd_daily` then `usd_fiat_daily`, `message`, `contact`, `pos_charge` (via `migratePosSchema`), `conversation` (via `migrateConversationSchema`), `push_subscription`/`push_outbox` (via `migratePushSchema`), `notification` (via `migrateNotificationSchema` after push before `db_change`), then `trust_edge` (via `migrateTrustSchema`) after notification, then `funding_grant` (via `migrateFundingSchema`), then `api_log` (via `migrateApiLogSchema`), then `account_image` (via `migrateBannerSchema`), then `diagnostic_event` (via `migrateDiagnosticSchema`); the diagnostic sink is installed on that store before the FX fill and the zap backfills, immediately before `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge`, `funding_grant`, `api_log`, `account_image`, and `diagnostic_event`, then `db_change` after auth migrate; best-effort `fillRatesForGiftRange` logs `gifts.fx.boot_fill.failed` and does not throw; best-effort `fillFiatRatesForGiftRange` logs `gifts.fx.fiat_boot_fill.failed` and does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returns `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresTranslationStore` (forum) plus a second `PostgresTranslationStore` on `conversation_message_translation`, `PostgresContactStore`, `PostgresPosStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresFiatStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresBannerStore`, `PostgresApiLogStore`, `PostgresDiagnosticStore`, and `PostgresDebugDbStore`; memory path returns `giftRecorder`/`messageStore`/`translationStore`/`conversationTranslationStore`/`contactStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`fundingStore`/`bannerStore`/`apiLogStore`/`diagnosticStore`/`listDbChange`/`debugDbStore`/`nostrKek` undefined, returns a fresh `InMemoryPosStore` as `posStore`, and skips migrates including `migratePosSchema` / `migrateConversationSchema` / `migratePushSchema` / `migrateNotificationSchema` / `migrateTrustSchema` / `migrateFundingSchema` / `migrateApiLogSchema` / `migrateBannerSchema` / `migrateDiagnosticSchema` / `migrateDbChangeSchema`. SQL path calls `migratePosSchema` for `pos_charge` before `PostgresPosStore`. +- **Returns / side effects:** `{ authStore, giftStore, giftRecorder, btcUsdRates, fiatRates, messageStore, translationStore, conversationTranslationStore, contactStore, memberHabitStore, posStore, conversationStore, notificationStore, pushStore, trustStore, fundingStore, bannerStore, apiLogStore, diagnosticStore, nostrKek, listDbChange, debugDbStore }`. Migrates `btc_usd_daily` then `usd_fiat_daily`, `message`, `contact`, `member_habit` (via `migrateMemberHabitSchema`), `pos_charge` (via `migratePosSchema`), `conversation` (via `migrateConversationSchema`), `push_subscription`/`push_outbox` (via `migratePushSchema`), `notification` (via `migrateNotificationSchema` after push before `db_change`), then `trust_edge` (via `migrateTrustSchema`) after notification, then `funding_grant` (via `migrateFundingSchema`), then `api_log` (via `migrateApiLogSchema`), then `account_image` (via `migrateBannerSchema`), then `diagnostic_event` (via `migrateDiagnosticSchema`); the diagnostic sink is installed on that store before the FX fill and the zap backfills, immediately before `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge`, `funding_grant`, `api_log`, `account_image`, and `diagnostic_event`, then `db_change` after auth migrate; best-effort `fillRatesForGiftRange` logs `gifts.fx.boot_fill.failed` and does not throw; best-effort `fillFiatRatesForGiftRange` logs `gifts.fx.fiat_boot_fill.failed` and does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returns `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresTranslationStore` (forum) plus a second `PostgresTranslationStore` on `conversation_message_translation`, `PostgresContactStore`, `PostgresMemberHabitStore`, `PostgresPosStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresFiatStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresBannerStore`, `PostgresApiLogStore`, `PostgresDiagnosticStore`, and `PostgresDebugDbStore`; memory path returns `giftRecorder`/`messageStore`/`translationStore`/`conversationTranslationStore`/`contactStore`/`memberHabitStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`fundingStore`/`bannerStore`/`apiLogStore`/`diagnosticStore`/`listDbChange`/`debugDbStore`/`nostrKek` undefined, returns a fresh `InMemoryPosStore` as `posStore`, and skips migrates including `migrateMemberHabitSchema` / `migratePosSchema` / `migrateConversationSchema` / `migratePushSchema` / `migrateNotificationSchema` / `migrateTrustSchema` / `migrateFundingSchema` / `migrateApiLogSchema` / `migrateBannerSchema` / `migrateDiagnosticSchema` / `migrateDbChangeSchema`. SQL path calls `migrateMemberHabitSchema` for `member_habit` before `PostgresMemberHabitStore`, and `migratePosSchema` for `pos_charge` before `PostgresPosStore`. - **Payment and external-zapper backfills:** Only after `migrateDbChangeSchema` has attached `trg_db_change` to every public table (so the payment backfill's `nostr_zap_payment` inserts are logged), constructs `PostgresMessageStore`, runs `backfillZapPayments`, and immediately runs `backfillExternalZappers` before constructing the remaining Postgres stores and returning. The external-zapper backfill pages through unattributed receipts with a 10,000-row ceiling and logs `nostr.zapper.backfill.done` with its aggregate counts; a failure logs `nostr.zapper.backfill.failed` and boot continues. Payment-backfill failures still propagate. In-memory boots call neither backfill. - **Used by:** `src/index.ts` boot. @@ -1065,10 +1065,9 @@ ## Function: createApp -- **Purpose:** Wires CORS (`allowHeaders` includes `Time-Zone`), requestLog, sundayRest, brand, health, info, auth, me, `/pictures`, `/banners`, `/view`, `/pay`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/diagnostics`, `/debug/diagnostics`, `/debug/db`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/debug/dump`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / reject-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject / daily-roster), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice and `/messages/stats`), `GET /translate` (DeepL availability), `/members/:accountId`, `GET /mentions`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/pos`, `/conversations`, `/notifications`, and invoices. -- **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, optional `debugReadToken` (default `process.env.DEBUG_READ_TOKEN`), giftStore, `giftRecorder`, `btcUsdRates`, `fiatRates`, `messageStore`, optional `translationStore` (default `InMemoryTranslationStore`; SQL boot injects `PostgresTranslationStore`), optional `conversationTranslationStore` (passed to `conversationRoutes.translationStore`; omitted so that factory constructs one `InMemoryTranslationStore`; SQL boot injects a second `PostgresTranslationStore` on `conversation_message_translation`, never the forum store), `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), optional `notificationStore` (default `InMemoryNotificationStore`), optional `apiLogStore` (default `InMemoryApiLogStore`), optional `diagnosticStore` (default `InMemoryDiagnosticStore`), optional `debugDbStore` (omitted on a memory boot; `GET /debug/db` then 503 after the token matches), optional `mergeDb` (omitted on a memory boot; after a matching debug token and a valid body, `POST /debug/accounts/merge` is 503 `{ error: 'Merge is unavailable' }`; SQL boot injects it from `createBunDatabase`), `pushStore`, `trustStore`, optional `fundingStore` (default `InMemoryFundingStore`; also forwarded to `debugPaymentsRoutes`), optional `bannerStore` (default `InMemoryBannerStore`; SQL boot injects `PostgresBannerStore`; the About me photo is neither slot; mounted at `/pictures` and `/banners` and passed to the Nostr worker), optional `listDbChange`, `vapidPublicKey`, `nostrKek`, optional `nostrPublisher` (without `nostrKek` staff hide skips NIP-09), optional `env` (default `process.env`; relays / `PUBLIC_BASE_URL` / Cloudflare on `DELETE /messages/:id`; forwarded to `conversationRoutes`), spendApiToken, optional `mapPush` (default `resolveMapPush` on `env`, which stays off while `SHOP_PLACE_PUSH_ENABLED` is false even if both variables are set; a blank URL or token also sends nothing; the process still boots; forwarded to `messagesRoutes`), `spendPing` (default `resolveSpendPing(process.env, fetchImpl)`; unset/blank `SPEND_URL` or `SPEND_API_TOKEN` omits it; `POST /messages` still 200; daily calls `spendPing.ping(address, messageId, 'daily', effectiveStatus(grant, now))`; `conversationRoutes` gets the same `spendPing` and calls `spendPing.ping(address, created.id, 'moderator')`; forum `POST /messages` calls `spendPing.ping(address, messageId, 'daily', effectiveStatus(grant, now))`; a verified account with any live top-level photo or video, including About me, also calls `spendPing.ping(address, messageId, 'welcome')` even when the new row has no media; `spendPing` is also passed to `meRoutes` and, with `messages`, to `trustRoutes`; `fundingRoutes` receives the same optional `spendPing`), optional `postLimiter` (default a new `PostRateLimiter`; passed to `messagesRoutes`; boot shares one instance with the Nostr worker), invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). `debugPaymentsRoutes` receives the same optional `spendPing`. Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `translationStore` → `InMemoryTranslationStore`; omitted `conversationTranslationStore` → `conversationRoutes` constructs one `InMemoryTranslationStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `posStore` → `InMemoryPosStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `notificationStore` → `InMemoryNotificationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted `trustStore` → `InMemoryTrustStore`; omitted `fundingStore` → `InMemoryFundingStore`; omitted `apiLogStore` → `InMemoryApiLogStore`; omitted `diagnosticStore` → `InMemoryDiagnosticStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresTranslationStore`, a second `PostgresTranslationStore` on `conversation_message_translation`, `PostgresContactStore`, `PostgresPosStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresBannerStore`, `PostgresApiLogStore`, `PostgresDiagnosticStore`, `PostgresDebugDbStore`, and parsed KEK. `messagesRoutes`, `meRoutes`, `invoiceRoutes`, and `trustRoutes` receive `conversationStore`. `fundingRoutes`, `invoiceRoutes`, `messagesRoutes`, `conversationRoutes`, `meRoutes`, `membersRoutes`, and auth finish receive `fundingStore`. `contactRoutes` and `conversationRoutes` receive `pushStore` plus `notificationStore`. Mounts `notificationRoutes` at `/notifications`. Does not take a push sender (worker owns delivery). -- **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Default `fiatRates` is an empty `InMemoryFiatStore`. `createApp` passes the same `fiatRates` object into `/gifts`, `/gifts/stats`, `/me`, `/members`, and `/view`, and the same `now` into `/mentions`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. Before routes mount, equal trimmed tokens throw `DEBUG_READ_TOKEN matches DEBUG_TOKEN` and neither value is printed; an empty or missing read token does not fail boot. -- **Used by:** Boot path and every HTTP test. +- **Purpose:** Wires CORS (`allowHeaders` includes `Time-Zone`), requestLog, sundayRest, brand, health, info, auth, me, `/pictures`, `/banners`, `/view`, `/pay`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/diagnostics`, `/debug/diagnostics`, `/debug/db`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/debug/dump`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / reject-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject / daily-roster), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice and `/messages/stats`), `GET /translate` (DeepL availability), `/members/:accountId`, `GET /mentions`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/habits` (`memberHabitRoutes`), `/pos`, `/conversations`, `/notifications`, and invoices. +- **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, optional `debugReadToken` (default `process.env.DEBUG_READ_TOKEN`), giftStore, `giftRecorder`, `btcUsdRates`, `fiatRates`, `messageStore`, optional `translationStore` (default `InMemoryTranslationStore`; SQL boot injects `PostgresTranslationStore`), optional `conversationTranslationStore` (passed to `conversationRoutes.translationStore`; omitted so that factory constructs one `InMemoryTranslationStore`; SQL boot injects a second `PostgresTranslationStore` on `conversation_message_translation`, never the forum store), `contactStore`, optional `memberHabitStore` (default `InMemoryMemberHabitStore`; SQL boot injects `PostgresMemberHabitStore`), optional `conversationStore` (default `InMemoryConversationStore`), optional `notificationStore` (default `InMemoryNotificationStore`), optional `apiLogStore` (default `InMemoryApiLogStore`), optional `diagnosticStore` (default `InMemoryDiagnosticStore`), optional `debugDbStore` (omitted on a memory boot; `GET /debug/db` then 503 after the token matches), optional `mergeDb` (omitted on a memory boot; after a matching debug token and a valid body, `POST /debug/accounts/merge` is 503 `{ error: 'Merge is unavailable' }`; SQL boot injects it from `createBunDatabase`), `pushStore`, `trustStore`, optional `fundingStore` (default `InMemoryFundingStore`; also forwarded to `debugPaymentsRoutes`), optional `bannerStore` (default `InMemoryBannerStore`; SQL boot injects `PostgresBannerStore`; the About me photo is neither slot; mounted at `/pictures` and `/banners` and passed to the Nostr worker), optional `listDbChange`, `vapidPublicKey`, `nostrKek`, optional `nostrPublisher` (without `nostrKek` staff hide skips NIP-09), optional `env` (default `process.env`; relays / `PUBLIC_BASE_URL` / Cloudflare on `DELETE /messages/:id`; forwarded to `conversationRoutes`), spendApiToken, optional `mapPush` (default `resolveMapPush` on `env`, which stays off while `SHOP_PLACE_PUSH_ENABLED` is false even if both variables are set; a blank URL or token also sends nothing; the process still boots; forwarded to `messagesRoutes`), `spendPing` (default `resolveSpendPing(process.env, fetchImpl)`; unset/blank `SPEND_URL` or `SPEND_API_TOKEN` omits it; `POST /messages` still 200; daily calls `spendPing.ping(address, messageId, 'daily', effectiveStatus(grant, now))`; `conversationRoutes` gets the same `spendPing` and calls `spendPing.ping(address, created.id, 'moderator')`; forum `POST /messages` calls `spendPing.ping(address, messageId, 'daily', effectiveStatus(grant, now))`; a verified account with any live top-level photo or video, including About me, also calls `spendPing.ping(address, messageId, 'welcome')` even when the new row has no media; `spendPing` is also passed to `meRoutes` and, with `messages`, to `trustRoutes`; `fundingRoutes` receives the same optional `spendPing`), optional `postLimiter` (default a new `PostRateLimiter`; passed to `messagesRoutes`; boot shares one instance with the Nostr worker), invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). `debugPaymentsRoutes` receives the same optional `spendPing`. Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `translationStore` → `InMemoryTranslationStore`; omitted `conversationTranslationStore` → `conversationRoutes` constructs one `InMemoryTranslationStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `memberHabitStore` → `InMemoryMemberHabitStore`; omitted `posStore` → `InMemoryPosStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `notificationStore` → `InMemoryNotificationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted `trustStore` → `InMemoryTrustStore`; omitted `fundingStore` → `InMemoryFundingStore`; omitted `apiLogStore` → `InMemoryApiLogStore`; omitted `diagnosticStore` → `InMemoryDiagnosticStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresTranslationStore`, a second `PostgresTranslationStore` on `conversation_message_translation`, `PostgresContactStore`, `PostgresMemberHabitStore`, `PostgresPosStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresBannerStore`, `PostgresApiLogStore`, `PostgresDiagnosticStore`, `PostgresDebugDbStore`, and parsed KEK. `messagesRoutes`, `meRoutes`, `invoiceRoutes`, and `trustRoutes` receive `conversationStore`. `fundingRoutes`, `invoiceRoutes`, `messagesRoutes`, `conversationRoutes`, `meRoutes`, `membersRoutes`, and auth finish receive `fundingStore`. `contactRoutes` and `conversationRoutes` receive `pushStore` plus `notificationStore`. Mounts `notificationRoutes` at `/notifications`. Does not take a push sender (worker owns delivery). +- **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Default `fiatRates` is an empty `InMemoryFiatStore`. `createApp` passes the same `fiatRates` object into `/gifts`, `/gifts/stats`, `/me`, `/members`, and `/view`, and the same `now` into `/mentions`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. Before routes mount, equal trimmed tokens throw `DEBUG_READ_TOKEN matches DEBUG_TOKEN` and neither value is printed; an empty or missing read token does not fail boot.- **Used by:** Boot path and every HTTP test. - **Daily roster:** Optional `dailyRoster` defaults to `resolveDailyRoster(process.env, fetchImpl)`, the same env the spend ping reads. Unset or blank `SPEND_URL` or `SPEND_API_TOKEN` omits it. Roster routes then return 503 after the initiator or founder gate and do not call fetch. ## Function: healthRoute diff --git a/src/__tests__/lib/boot-stores.test.ts b/src/__tests__/lib/boot-stores.test.ts index 5caf7a890..650418b12 100644 --- a/src/__tests__/lib/boot-stores.test.ts +++ b/src/__tests__/lib/boot-stores.test.ts @@ -8,6 +8,7 @@ import { InMemoryFiatStore, PostgresFiatStore } from '@/lib/usd-fiat-store'; import { QueryGiftStore } from '@/lib/gift-store'; import { SqlGiftRecorder } from '@/lib/gift-recorder'; import { PostgresContactStore } from '@/lib/contact-store'; +import { PostgresMemberHabitStore } from '@/lib/member-habit-store'; import { InMemoryPosStore, PostgresPosStore } from '@/lib/pos-store'; import { PostgresConversationStore } from '@/lib/conversation-store'; @@ -68,6 +69,7 @@ describe('openBootStores', () => { translationStore, conversationTranslationStore, contactStore, + memberHabitStore, posStore, conversationStore, notificationStore, @@ -86,6 +88,7 @@ describe('openBootStores', () => { expect(translationStore).toBeUndefined(); expect(conversationTranslationStore).toBeUndefined(); expect(contactStore).toBeUndefined(); + expect(memberHabitStore).toBeUndefined(); expect(posStore).toBeInstanceOf(InMemoryPosStore); expect(conversationStore).toBeUndefined(); expect(notificationStore).toBeUndefined(); @@ -115,6 +118,7 @@ describe('openBootStores', () => { translationStore, conversationTranslationStore, contactStore, + memberHabitStore, posStore, conversationStore, notificationStore, @@ -132,6 +136,7 @@ describe('openBootStores', () => { expect(translationStore).toBeUndefined(); expect(conversationTranslationStore).toBeUndefined(); expect(contactStore).toBeUndefined(); + expect(memberHabitStore).toBeUndefined(); expect(posStore).toBeInstanceOf(InMemoryPosStore); expect(conversationStore).toBeUndefined(); expect(notificationStore).toBeUndefined(); @@ -539,6 +544,14 @@ describe('openBootStores', () => { expect(stores.conversationTranslationStore).toBeInstanceOf(PostgresTranslationStore); expect(stores.conversationTranslationStore).not.toBe(stores.translationStore); expect(stores.contactStore).toBeInstanceOf(PostgresContactStore); + expect(stores.memberHabitStore).toBeInstanceOf(PostgresMemberHabitStore); + const habits = stores.memberHabitStore; + if (!(habits instanceof PostgresMemberHabitStore)) { + throw new Error('expected PostgresMemberHabitStore'); + } + expect(await habits.lightning('acc')).toBeNull(); + await habits.setLightning('acc', 'ada@example.com'); + expect(await habits.lightning('acc')).toBeNull(); expect(stores.posStore).toBeInstanceOf(PostgresPosStore); expect(stores.conversationStore).toBeInstanceOf(PostgresConversationStore); expect(stores.notificationStore).toBeInstanceOf(PostgresNotificationStore); diff --git a/src/lib/boot-stores.ts b/src/lib/boot-stores.ts index ce81e79bc..f7d6b7684 100644 --- a/src/lib/boot-stores.ts +++ b/src/lib/boot-stores.ts @@ -192,7 +192,8 @@ export interface BootFxOptions { * `giftRecorder: undefined`, `messageStore: undefined`, * `translationStore: undefined`, * `conversationTranslationStore: undefined`, - * `contactStore: undefined`, a fresh {@link InMemoryPosStore} as `posStore`, + * `contactStore: undefined`, `memberHabitStore: undefined`, + * a fresh {@link InMemoryPosStore} as `posStore`, * `apiLogStore: undefined`, * `diagnosticStore: undefined`, * `conversationStore: undefined`, @@ -203,13 +204,15 @@ export interface BootFxOptions { * an empty {@link InMemoryBtcUsdStore}, and an empty {@link InMemoryFiatStore}. * A set URL asks `createClient` for one `SqlClient`, migrates auth (via * `openAuthStore`) then the FX tables (`btc_usd_daily` then `usd_fiat_daily`), - * `message`, `contact`, `pos_charge` (via `migratePosSchema`), `conversation`, `push`, `notification`, `trust_edge`, + * `message`, `contact`, `member_habit` (via `migrateMemberHabitSchema`), + * `pos_charge` (via `migratePosSchema`), `conversation`, `push`, `notification`, `trust_edge`, * `funding_grant`, `api_log`, `account_image`, `diagnostic_event`, and `db_change` schemas (notification after push, trust * after notification, funding after trust, `api_log` then `account_image` via * `migrateBannerSchema`, then `diagnostic_event` between `account_image` and `db_change` so `trg_db_change` attaches), builds a {@link QueryGiftStore}, * {@link SqlGiftRecorder}, {@link PostgresMessageStore}, * {@link PostgresTranslationStore}, - * {@link PostgresContactStore}, {@link PostgresPosStore}, {@link PostgresConversationStore}, + * {@link PostgresContactStore}, {@link PostgresMemberHabitStore}, + * {@link PostgresPosStore}, {@link PostgresConversationStore}, * {@link PostgresNotificationStore}, {@link PostgresPushStore}, * {@link PostgresTrustStore}, {@link PostgresFundingStore}, and * {@link PostgresBannerStore}, parses @@ -453,15 +456,14 @@ export async function openBootStores( logEvent('nostr.zapper.backfill.failed'); } const contactStore = new PostgresContactStore(sqlClient); + /* Invoice reads the auth store. This port does not keep a second address. */ const memberHabitStore = new PostgresMemberHabitStore(habitSql, { - /* v8 ignore start -- wallets are read from the auth store, not this adapter */ async get() { return null; }, async set() { return undefined; }, - /* v8 ignore stop */ }); const posStore = new PostgresPosStore(sqlClient); const apiLogStore = new PostgresApiLogStore(sqlClient); diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts index 9d412b65b..dac23bc9c 100644 --- a/src/lib/member-habit-store.ts +++ b/src/lib/member-habit-store.ts @@ -166,6 +166,9 @@ export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ /** * Runs each `MEMBER_HABIT_SCHEMA_SQL` statement. Safe to call more than once. + * + * @param sql - SQL client whose `query` returns `{ rows }`. + * @returns Resolves when every statement has executed. */ export async function migrateMemberHabitSchema(sql: SqlClient): Promise { for (const statement of MEMBER_HABIT_SCHEMA_SQL) { diff --git a/src/lib/member-habit.ts b/src/lib/member-habit.ts index 9e9f7abde..326dfad54 100644 --- a/src/lib/member-habit.ts +++ b/src/lib/member-habit.ts @@ -72,6 +72,9 @@ function addUtcDays(key: string, days: number): string { /** * True only when `zone` is non-empty and `Intl` accepts it as a time zone. + * + * @param zone - IANA time zone name, or empty. + * @returns `true` when `Intl` accepts `zone`. */ export function isValidTimeZone(zone: string): boolean { if (zone === '') { @@ -87,6 +90,10 @@ export function isValidTimeZone(zone: string): boolean { /** * Calendar date `YYYY-MM-DD` of `nowMs` in `timeZone`. + * + * @param nowMs - Epoch milliseconds. + * @param timeZone - IANA zone `Intl` accepts. + * @returns `YYYY-MM-DD` in that zone. */ export function dayKey(nowMs: number, timeZone: string): string { const parts = zonedParts(nowMs, timeZone); @@ -96,6 +103,10 @@ export function dayKey(nowMs: number, timeZone: string): string { /** * Monday `YYYY-MM-DD` of the calendar week (Monday start) that contains * `dayKey(nowMs, timeZone)`. Uses UTC date arithmetic on that Y-M-D. + * + * @param nowMs - Epoch milliseconds. + * @param timeZone - IANA zone `Intl` accepts. + * @returns The Monday of that week, `YYYY-MM-DD`. */ export function weekKey(nowMs: number, timeZone: string): string { const day = dayKey(nowMs, timeZone); @@ -107,6 +118,11 @@ export function weekKey(nowMs: number, timeZone: string): string { /** * `dayKey` when `cadence` is `daily`, `weekKey` when `weekly`. + * + * @param nowMs - Epoch milliseconds. + * @param cadence - `daily` or `weekly`. + * @param timeZone - IANA zone `Intl` accepts. + * @returns The period key for that cadence. */ export function periodKey(nowMs: number, cadence: Cadence, timeZone: string): string { if (cadence === 'daily') { @@ -117,6 +133,11 @@ export function periodKey(nowMs: number, cadence: Cadence, timeZone: string): st /** * Next calendar day, or the Monday seven days later. `key` is `YYYY-MM-DD`. + * + * @param key - `YYYY-MM-DD` period start. + * @param cadence - `daily` adds one day; `weekly` adds seven. + * @returns The following period key. + * @throws When `key` is not `YYYY-MM-DD`. */ export function nextPeriod(key: string, cadence: Cadence): string { if (cadence === 'daily') { @@ -128,6 +149,10 @@ export function nextPeriod(key: string, cadence: Cadence): string { /** * Lexical compare of `YYYY-MM-DD`, which is chronological. * `-1` when `a < b`, `0` when equal, `1` when `a > b`. + * + * @param a - First `YYYY-MM-DD` key. + * @param b - Second `YYYY-MM-DD` key. + * @returns `-1`, `0`, or `1`. */ export function comparePeriod(a: string, b: string): -1 | 0 | 1 { if (a < b) { @@ -142,6 +167,10 @@ export function comparePeriod(a: string, b: string): -1 | 0 | 1 { /** * Latest Monday that is ratable at `nowMs` in `timeZone`. * A week becomes ratable at 08:00 on the following Monday in that zone. + * + * @param nowMs - Epoch milliseconds. + * @param timeZone - IANA zone `Intl` accepts. + * @returns The latest ratable Monday, `YYYY-MM-DD`. */ export function weeklyRatableThrough(nowMs: number, timeZone: string): string { const monday = weekKey(nowMs, timeZone); @@ -154,6 +183,9 @@ export function weeklyRatableThrough(nowMs: number, timeZone: string): string { /** * Manila review week: `{ start: weeklyRatableThrough(nowMs, 'Asia/Manila') }`. + * + * @param nowMs - Epoch milliseconds. + * @returns `{ start }` for that Monday. */ export function manilaReviewWeek(nowMs: number): { start: string } { return { start: weeklyRatableThrough(nowMs, 'Asia/Manila') }; diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 2daa6c2b7..54aec5f31 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -178,6 +178,9 @@ function publicComments( /** * Hono routes `GET /` and `POST /` mounted at `/habits`. + * + * @param deps - Habit store, auth store, clock, and fetch. + * @returns The Hono app mounted at `/habits`. */ export function memberHabitRoutes(deps: { store: MemberHabitStore; diff --git a/src/server.ts b/src/server.ts index 707ec56a3..0ed49ed10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -357,6 +357,8 @@ function debugList(store: object, limit: number): Promise { * mapPush (optional; default resolveMapPush on env), * translationStore (optional; default InMemoryTranslationStore; SQL boot * injects PostgresTranslationStore), contact store, + * memberHabitStore (optional; default InMemoryMemberHabitStore; SQL boot + * injects PostgresMemberHabitStore), * conversation store, notification store, push store, trust store, * debugDbStore (`GET /debug/db`; omitted on a memory boot), * funding store (injected into `/funding`, `/me`, `/auth`, `/members`, From 5789cfe16e04acfd3306034eb350d43d7538e65f Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:13:42 +0200 Subject: [PATCH 07/23] 01a10b01 - Tie habit rows to their account and reuse the gift amount error. A habit invoice that omits the amount, or sends one that is not a number, returns the same visible error as a gift. A failed mint names the Lightning Address, and the new tables reference the account and the habit. --- SPEC.md | 5 ++- docs/handbook/endpoints.md | 2 +- docs/handbook/functions.md | 2 +- docs/schema/member-habit.sql | 10 +++--- src/__tests__/lib/member-habit-store.test.ts | 15 +++++++++ src/__tests__/routes/member-habits.test.ts | 32 ++++++++++++++++++-- src/lib/member-habit-store.ts | 10 +++--- src/routes/member-habits.ts | 18 ++++++----- 8 files changed, 71 insertions(+), 23 deletions(-) diff --git a/SPEC.md b/SPEC.md index 28f17e574..c6cdc3d72 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5459,6 +5459,8 @@ be an integer from 1 through 10_000_000 (the whole-sat form of `GIFT_INVOICE_MAX_MSAT`). A non-integer, a value below 1, or a value above that ceiling is **400** `{ "error": "Expected a JSON body with an integer \"amountSats\"" }`. +A missing `amountSats`, or a value that is not a number, is that same +**400**. Success is the same gift body as `POST /pay/:username/invoice`: ```json @@ -5469,7 +5471,8 @@ Donating to the caller's own comment is **400** `{ "error": "Cannot donate to yourself" }`. No Lightning Address on the author is **409** `{ "error": "No wallet" }`. The existing invoice limiter answers **429** `{ "error": "Too many payments" }`. A failed -mint is **502** `{ "error": "Invoice unavailable" }`. +mint is **502** `{ "error": "Lightning Address could not be resolved" }`, +the same failure as `POST /pay/:username/invoice`. Missing bearer → **401** `{ "error": "Unauthorized" }`. A body that is not one of the actions → **400** `{ "error": "Invalid body" }`. Unknown diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index dccd692e4..0d4c228f8 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1161,5 +1161,5 @@ Operator inspection of external Nostr identities that have earned visibility or - **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is not a positive integer or is above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 `{ error: 'Too many payments' }`. 502 invoice unavailable. 503 store failure, event `habits.failed`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 store failure, event `habits.failed`. - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index c0aac7c7f..13f058c0d 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3319,7 +3319,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A bad or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. Does not pay. Add, edit, archive, and log stay open on Sunday. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint is 502 `{ error: 'Lightning Address could not be resolved' }`. Does not pay. Add, edit, archive, and log stay open on Sunday. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. diff --git a/docs/schema/member-habit.sql b/docs/schema/member-habit.sql index ff9c7f48a..5242ba13e 100644 --- a/docs/schema/member-habit.sql +++ b/docs/schema/member-habit.sql @@ -1,7 +1,7 @@ -- Mirrors MEMBER_HABIT_SCHEMA_SQL. CREATE TABLE IF NOT EXISTS member_habit ( id uuid PRIMARY KEY, - account_id uuid NOT NULL, + account_id uuid NOT NULL REFERENCES account (id), owner_name text NOT NULL, role text NOT NULL, name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS member_habit ( ); CREATE TABLE IF NOT EXISTS member_habit_revision ( - habit_id uuid NOT NULL, + habit_id uuid NOT NULL REFERENCES member_habit (id), period text NOT NULL, name text NOT NULL, description text NOT NULL, @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS member_habit_revision ( ); CREATE TABLE IF NOT EXISTS member_habit_log ( - habit_id uuid NOT NULL, + habit_id uuid NOT NULL REFERENCES member_habit (id), period text NOT NULL, status text NOT NULL CHECK (status IN ('achieved', 'partial', 'missed')), PRIMARY KEY (habit_id, period) @@ -30,8 +30,8 @@ CREATE TABLE IF NOT EXISTS member_habit_log ( CREATE TABLE IF NOT EXISTS member_habit_comment ( id uuid PRIMARY KEY, - habit_id uuid NOT NULL, - account_id uuid NOT NULL, + habit_id uuid NOT NULL REFERENCES member_habit (id), + account_id uuid NOT NULL REFERENCES account (id), name text NOT NULL, "text" text NOT NULL CHECK (char_length("text") BETWEEN 1 AND 2000), week text NOT NULL, diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts index c83feabfb..f739a29d1 100644 --- a/src/__tests__/lib/member-habit-store.test.ts +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -455,6 +455,21 @@ describe('PostgresMemberHabitStore', () => { await migrateMemberHabitSchema(sql); expect(statements).toEqual([...MEMBER_HABIT_SCHEMA_SQL]); expect(MEMBER_HABIT_SCHEMA_SQL).toHaveLength(4); + expect(MEMBER_HABIT_SCHEMA_SQL[0]).toMatch( + /account_id uuid NOT NULL REFERENCES account \(id\)/, + ); + expect(MEMBER_HABIT_SCHEMA_SQL[1]).toMatch( + /habit_id uuid NOT NULL REFERENCES member_habit \(id\)/, + ); + expect(MEMBER_HABIT_SCHEMA_SQL[2]).toMatch( + /habit_id uuid NOT NULL REFERENCES member_habit \(id\)/, + ); + expect(MEMBER_HABIT_SCHEMA_SQL[3]).toMatch( + /habit_id uuid NOT NULL REFERENCES member_habit \(id\)/, + ); + expect(MEMBER_HABIT_SCHEMA_SQL[3]).toMatch( + /account_id uuid NOT NULL REFERENCES account \(id\)/, + ); const store = new PostgresMemberHabitStore(sql, lightning); await store.add(sampleHabit()); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index ef2f817b9..f660174a2 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -464,7 +464,7 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(res.status).toBe(502); - expect(await res.json()).toEqual({ error: 'Invoice unavailable' }); + expect(await res.json()).toEqual({ error: 'Lightning Address could not be resolved' }); }); it('invoice when fetchImpl succeeds is 200 { pr, amountSats }', async () => { @@ -986,7 +986,35 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(failed.status).toBe(502); - expect(await failed.json()).toEqual({ error: 'Invoice unavailable' }); + expect(await failed.json()).toEqual({ error: 'Lightning Address could not be resolved' }); + const textAmount = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + }), + { action: 'invoice', commentId: 'c-alice', amountSats: '21' }, + AUTH, + ); + expect(textAmount.status).toBe(400); + expect(await textAmount.json()).toEqual({ + error: 'Expected a JSON body with an integer "amountSats"', + }); + const missingAmount = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + }), + { action: 'invoice', commentId: 'c-alice' }, + AUTH, + ); + expect(missingAmount.status).toBe(400); + expect(await missingAmount.json()).toEqual({ + error: 'Expected a JSON body with an integer "amountSats"', + }); }); it('a store whose add throws returns 503', async () => { diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts index dac23bc9c..99fbc0589 100644 --- a/src/lib/member-habit-store.ts +++ b/src/lib/member-habit-store.ts @@ -128,7 +128,7 @@ export interface MemberHabitStore { export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ `CREATE TABLE IF NOT EXISTS member_habit ( id uuid PRIMARY KEY, - account_id uuid NOT NULL, + account_id uuid NOT NULL REFERENCES account (id), owner_name text NOT NULL, role text NOT NULL, name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), @@ -140,22 +140,22 @@ export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ last_period text NULL CHECK (last_period IS NULL OR last_period >= first_period) );`, `CREATE TABLE IF NOT EXISTS member_habit_revision ( - habit_id uuid NOT NULL, + habit_id uuid NOT NULL REFERENCES member_habit (id), period text NOT NULL, name text NOT NULL, description text NOT NULL, PRIMARY KEY (habit_id, period) );`, `CREATE TABLE IF NOT EXISTS member_habit_log ( - habit_id uuid NOT NULL, + habit_id uuid NOT NULL REFERENCES member_habit (id), period text NOT NULL, status text NOT NULL CHECK (status IN ('achieved', 'partial', 'missed')), PRIMARY KEY (habit_id, period) );`, `CREATE TABLE IF NOT EXISTS member_habit_comment ( id uuid PRIMARY KEY, - habit_id uuid NOT NULL, - account_id uuid NOT NULL, + habit_id uuid NOT NULL REFERENCES member_habit (id), + account_id uuid NOT NULL REFERENCES account (id), name text NOT NULL, "text" text NOT NULL CHECK (char_length("text") BETWEEN 1 AND 2000), week text NOT NULL, diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 54aec5f31..3b7a67f1f 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -77,7 +77,7 @@ const invoiceBody = z .object({ action: z.literal('invoice'), commentId: z.string(), - amountSats: z.number(), + amountSats: z.unknown().optional(), }) .strict(); @@ -447,10 +447,12 @@ export function memberHabitRoutes(deps: { if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { return c.json({ error: 'SUNDAY_REST' }, 403); } + const amountSats = body.amountSats; if ( - !Number.isInteger(body.amountSats) || - body.amountSats < 1 || - body.amountSats > GIFT_INVOICE_MAX_MSAT / 1000 + typeof amountSats !== 'number' || + !Number.isInteger(amountSats) || + amountSats < 1 || + amountSats > GIFT_INVOICE_MAX_MSAT / 1000 ) { return c.json({ error: 'Expected a JSON body with an integer "amountSats"' }, 400); } @@ -477,17 +479,17 @@ export function memberHabitRoutes(deps: { try { invoice = await requestGiftInvoice({ address, - amountMsat: body.amountSats * 1000, + amountMsat: amountSats * 1000, fetchImpl: deps.fetchImpl, }); /* v8 ignore next 3 -- requestGiftInvoice returns ok:false instead of throwing */ } catch { - return c.json({ error: 'Invoice unavailable' }, 502); + return c.json({ error: 'Lightning Address could not be resolved' }, 502); } if (!invoice.ok) { - return c.json({ error: 'Invoice unavailable' }, 502); + return c.json({ error: 'Lightning Address could not be resolved' }, 502); } - return c.json({ pr: invoice.pr, amountSats: body.amountSats }, 200); + return c.json({ pr: invoice.pr, amountSats }, 200); } catch { console.warn(JSON.stringify({ ts: new Date().toISOString(), event: 'habits.failed' })); return c.json({ error: 'Habits are unavailable' }, 503); From 9318df6a527e936184ee4f1b2a300166acfb8d29 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:45:45 +0200 Subject: [PATCH 08/23] 01a10b01 - Pass the SQL habit store into the process and reuse the wallet error. --- SPEC.md | 2 +- docs/handbook/endpoints.md | 2 +- src/__tests__/lib/boot-stores.test.ts | 15 +++++++++++++++ src/__tests__/routes/member-habits.test.ts | 12 +++++++++--- src/index.ts | 2 ++ src/routes/member-habits.ts | 2 +- 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index c6cdc3d72..ef3102eb0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5469,7 +5469,7 @@ Success is the same gift body as `POST /pay/:username/invoice`: Donating to the caller's own comment is **400** `{ "error": "Cannot donate to yourself" }`. No Lightning Address on the -author is **409** `{ "error": "No wallet" }`. The existing invoice +author is **409** `{ "error": "The author's wallet cannot receive this Bitcoin payment" }`. The existing invoice limiter answers **429** `{ "error": "Too many payments" }`. A failed mint is **502** `{ "error": "Lightning Address could not be resolved" }`, the same failure as `POST /pay/:username/invoice`. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 0d4c228f8..04cafa503 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1161,5 +1161,5 @@ Operator inspection of external Nostr identities that have earned visibility or - **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 closed period or no wallet. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 store failure, event `habits.failed`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 store failure, event `habits.failed`. - **Used by:** the habit tracker page. diff --git a/src/__tests__/lib/boot-stores.test.ts b/src/__tests__/lib/boot-stores.test.ts index 650418b12..fb52929cd 100644 --- a/src/__tests__/lib/boot-stores.test.ts +++ b/src/__tests__/lib/boot-stores.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { openBootStores } from '@/lib/boot-stores'; import { InMemoryAuthStore } from '@/lib/auth/store'; @@ -604,3 +606,16 @@ describe('openBootStores', () => { expect(fiatRates).toBeInstanceOf(PostgresFiatStore); }); }); + +describe('process entry store wiring', () => { + it('passes the SQL habit store into createApp the same way as the contact store', () => { + const source = readFileSync(join(process.cwd(), 'src/index.ts'), 'utf8'); + const taken = source.indexOf('memberHabitStore,'); + const passed = source.indexOf( + '...(memberHabitStore === undefined ? {} : { memberHabitStore })', + ); + expect(taken).toBeGreaterThanOrEqual(0); + expect(passed).toBeGreaterThan(taken); + expect(source).toContain('...(contactStore === undefined ? {} : { contactStore })'); + }); +}); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index f660174a2..9e8b8c3d8 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -437,7 +437,9 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(res.status).toBe(409); - expect(await res.json()).toEqual({ error: 'No wallet' }); + expect(await res.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); }); it('invoice when fetchImpl throws is 502', async () => { @@ -934,14 +936,18 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(emptyWallet.status).toBe(409); - expect(await emptyWallet.json()).toEqual({ error: 'No wallet' }); + expect(await emptyWallet.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); const stillNoWallet = await post( app, { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, AUTH, ); expect(stillNoWallet.status).toBe(409); - expect(await stillNoWallet.json()).toEqual({ error: 'No wallet' }); + expect(await stillNoWallet.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); const atCeiling = await post( mount({ store, diff --git a/src/index.ts b/src/index.ts index 9bbe31df3..b45fefef4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,7 @@ if (import.meta.main) { messageStore, nostrKek, contactStore, + memberHabitStore, posStore, apiLogStore, diagnosticStore, @@ -128,6 +129,7 @@ if (import.meta.main) { nostrQuerier: querier, nostrRelayUrls: resolveZapReadRelays(process.env), ...(contactStore === undefined ? {} : { contactStore }), + ...(memberHabitStore === undefined ? {} : { memberHabitStore }), ...(apiLogStore === undefined ? {} : { apiLogStore }), ...(diagnosticStore === undefined ? {} : { diagnosticStore }), ...(conversationStore === undefined ? {} : { conversationStore }), diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 3b7a67f1f..7bf9c87b8 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -469,7 +469,7 @@ export function memberHabitRoutes(deps: { author.lightningAddress === null || author.lightningAddress === '' ) { - return c.json({ error: 'No wallet' }, 409); + return c.json({ error: "The author's wallet cannot receive this Bitcoin payment" }, 409); } if (!invoiceLimiter.allow(account.id, nowMs)) { return c.json({ error: 'Too many payments' }, 429); From 697b49b9da0d73c2b36d0df559d68ca366478b3e Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:15:55 +0200 Subject: [PATCH 09/23] 01a10b01 - Document the habit list response and the store failure. --- docs/handbook/endpoints.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 04cafa503..d6f713875 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1152,14 +1152,15 @@ Operator inspection of external Nostr identities that have earned visibility or ## Endpoint: GET /habits -- **Purpose:** Public list of member habits. No bearer required. A valid bearer includes `notes` only on the caller's own habits. `notes` is omitted for everyone else. Periods run from `firstPeriod` through the latest ratable day or week and stop at `lastPeriod`. `logged` is false and `status` is null when the owner has not recorded that period. -- **Inputs:** Optional `Authorization: Bearer`. `reviewWeek.start` is the Manila review Monday. -- **Returns / side effects:** 200 `{ reviewWeek, habits }`. No writes. +- **Purpose:** Public list of member habits. No bearer required. A valid bearer includes `notes` only on the caller's own habits. `notes` is omitted for everyone else. An invalid or unknown bearer is treated as signed out and still returns the public list. Periods run from `firstPeriod` through the latest ratable day or week and stop at `lastPeriod`. `logged` is false and `status` is null when the owner has not recorded that period. +- **Inputs:** Optional `Authorization: Bearer`. No query and no body. +- **Returns / side effects:** 200 `{ reviewWeek: { start }, habits }`. `reviewWeek.start` is the Manila review Monday. No writes. +- **Errors:** 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). A bad bearer is not an error. - **Used by:** the habit tracker page. ## Endpoint: POST /habits - **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 store failure, event `habits.failed`. +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). - **Used by:** the habit tracker page. From 45a6763ea70bec8b7796f805017c5ea51b19bba4 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:58:26 +0200 Subject: [PATCH 10/23] 01a10b01 - Write each habit wording change as one statement. --- docs/handbook/functions.md | 2 +- src/__tests__/lib/member-habit-store.test.ts | 118 ++++++++++++++++++- src/lib/member-habit-store.ts | 46 +++++--- 3 files changed, 143 insertions(+), 23 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 13f058c0d..01b83a688 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3398,5 +3398,5 @@ Builds the operator-only external-pubkey inspection route. - **Purpose:** `MemberHabitStore` against the `member_habit*` tables. Lightning addresses go through the injected address port, not a habit column. - **Inputs:** A SQL client whose `query` returns `{ rows }`, and a Lightning address port. -- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. +- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. Adding a habit inserts the row and its first revision in one statement. Editing the wording updates the row and upserts that period's revision in one statement. A failed statement leaves both unchanged. - **Used by:** `openBootStores` when `DATABASE_URL` is set. diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts index f739a29d1..ac044d281 100644 --- a/src/__tests__/lib/member-habit-store.test.ts +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -126,6 +126,34 @@ function scriptedSql(initial: { habits: Row[]; revisions: Row[]; logs: Row[]; co return { rows: state.comments.filter((row) => row['deleted_at'] === null) }; } if (text.includes('member_habit_revision')) { + if (text.includes('UPDATE member_habit')) { + const found = state.habits.find((row) => row['id'] === params[3]); + if (found === undefined) { + return { rows: [] }; + } + found['name'] = params[0]; + found['description'] = params[1]; + found['notes'] = params[2]; + const period = params[4]; + const existing = state.revisions.find( + (row) => row['habit_id'] === params[3] && row['period'] === period, + ); + if (existing === undefined) { + state.revisions.push({ + habit_id: params[3], + period, + name: params[0], + description: params[1], + }); + } else { + existing['name'] = params[0]; + existing['description'] = params[1]; + } + return { rows: [{ habit_id: params[3] }] }; + } + if (text.includes('INSERT INTO member_habit (')) { + return { rows: [] }; + } if (text.includes('INSERT')) { state.revisions.push({ habit_id: params[0], @@ -475,13 +503,95 @@ describe('PostgresMemberHabitStore', () => { await store.add(sampleHabit()); await store.listPublic(null, nowMs); - expect(statements.some((text) => text.includes('INSERT INTO member_habit'))).toBe(true); - expect(statements.some((text) => text.includes('INSERT INTO member_habit_revision'))).toBe( - true, - ); + const writes = statements.filter((text) => text.includes('INSERT INTO member_habit (')); + expect(writes).toHaveLength(1); + expect(writes[0]).toContain('INSERT INTO member_habit_revision'); expect(statements.some((text) => text.includes('FROM member_habit'))).toBe(true); }); + it('edits the wording and the period revision in one statement', async () => { + const statements: string[] = []; + const habit = sampleHabit(); + const sql = { + query: async (text: string): Promise<{ rows: Record[] }> => { + statements.push(text); + if (text.includes('WHERE id = $1')) { + return { + rows: [ + { + id: habit.id, + account_id: habit.accountId, + owner_name: habit.ownerName, + role: habit.role, + name: habit.name, + description: habit.description, + notes: habit.notes, + cadence: habit.cadence, + time_zone: habit.timeZone, + first_period: habit.firstPeriod, + last_period: habit.lastPeriod, + }, + ], + }; + } + if (text.includes('RETURNING habit_id')) { + return { rows: [{ habit_id: habit.id }] }; + } + return { rows: [] }; + }, + }; + const store = new PostgresMemberHabitStore(sql, addressesOf(null)); + expect( + await store.edit( + habit.id, + habit.accountId, + { name: 'Run', description: 'Outside', notes: 'secret' }, + '2026-10-05', + ), + ).toBe('ok'); + const edits = statements.filter( + (text) => + text.includes('UPDATE member_habit') && text.includes('INSERT INTO member_habit_revision'), + ); + expect(edits).toHaveLength(1); + statements.length = 0; + const vanished = { + query: async (text: string): Promise<{ rows: Record[] }> => { + statements.push(text); + if (text.includes('WHERE id = $1')) { + return { + rows: [ + { + id: habit.id, + account_id: habit.accountId, + owner_name: habit.ownerName, + role: habit.role, + name: habit.name, + description: habit.description, + notes: habit.notes, + cadence: habit.cadence, + time_zone: habit.timeZone, + first_period: habit.firstPeriod, + last_period: habit.lastPeriod, + }, + ], + }; + } + return { rows: [] }; + }, + }; + const raced = new PostgresMemberHabitStore(vanished, addressesOf(null)); + expect( + await raced.edit( + habit.id, + habit.accountId, + { name: 'Run', description: 'Outside', notes: 'secret' }, + '2026-10-05', + ), + ).toBe('missing'); + expect(statements).toHaveLength(2); + }); + it('edits, archives, logs, lists, comments, and reads lightning through scripted rows', async () => { const id = '11111111-1111-1111-1111-111111111111'; const accountId = '22222222-2222-2222-2222-222222222222'; diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts index 99fbc0589..bd41e3967 100644 --- a/src/lib/member-habit-store.ts +++ b/src/lib/member-habit-store.ts @@ -598,6 +598,10 @@ export class InMemoryMemberHabitStore implements MemberHabitStore { * Postgres `MemberHabitStore`. Lightning addresses are stored via `addresses`. */ export class PostgresMemberHabitStore implements MemberHabitStore { + /** + * @param sql - Parameter-bound SQL client (already migrated). + * @param addresses - Account Lightning addresses. Habit tables do not store them. + */ constructor( private readonly sql: SqlClient, private readonly addresses: LightningAddresses, @@ -605,10 +609,15 @@ export class PostgresMemberHabitStore implements MemberHabitStore { async add(habit: MemberHabit): Promise { await this.sql.query( - `INSERT INTO member_habit ( - id, account_id, owner_name, role, name, description, notes, - cadence, time_zone, first_period, last_period - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + `WITH habit AS ( + INSERT INTO member_habit ( + id, account_id, owner_name, role, name, description, notes, + cadence, time_zone, first_period, last_period + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id + ) + INSERT INTO member_habit_revision (habit_id, period, name, description) + SELECT id, $10, $5, $6 FROM habit`, [ habit.id, habit.accountId, @@ -623,11 +632,6 @@ export class PostgresMemberHabitStore implements MemberHabitStore { habit.lastPeriod, ], ); - await this.sql.query( - `INSERT INTO member_habit_revision (habit_id, period, name, description) - VALUES ($1, $2, $3, $4)`, - [habit.id, habit.firstPeriod, habit.name, habit.description], - ); } async edit( @@ -640,18 +644,24 @@ export class PostgresMemberHabitStore implements MemberHabitStore { if (habit === null) { return 'missing'; } - await this.sql.query( - `UPDATE member_habit SET name = $1, description = $2, notes = $3 WHERE id = $4`, - [patch.name, patch.description, patch.notes, id], - ); - await this.sql.query( - `INSERT INTO member_habit_revision (habit_id, period, name, description) - VALUES ($1, $2, $3, $4) + const written = await this.sql.query( + `WITH updated AS ( + UPDATE member_habit + SET name = $1, description = $2, notes = $3 + WHERE id = $4 + RETURNING id + ) + INSERT INTO member_habit_revision (habit_id, period, name, description) + SELECT id, $5, $1, $2 FROM updated ON CONFLICT (habit_id, period) DO UPDATE SET name = EXCLUDED.name, - description = EXCLUDED.description`, - [id, atPeriod, patch.name, patch.description], + description = EXCLUDED.description + RETURNING habit_id`, + [patch.name, patch.description, patch.notes, id, atPeriod], ); + if (firstRow(written.rows) === null) { + return 'missing'; + } return 'ok'; } From f273569c94f98804d689dc83884b2feaf7db249e Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:24:19 +0200 Subject: [PATCH 11/23] 01a10b01 - Check habit ownership inside the wording update. The owner predicate sits in the same statement as the wording update and the period revision. A missing or unowned habit writes nothing. --- docs/handbook/functions.md | 2 +- src/__tests__/lib/member-habit-store.test.ts | 110 +++++++------------ src/lib/member-habit-store.ts | 10 +- 3 files changed, 45 insertions(+), 77 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 01b83a688..416a26d07 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3398,5 +3398,5 @@ Builds the operator-only external-pubkey inspection route. - **Purpose:** `MemberHabitStore` against the `member_habit*` tables. Lightning addresses go through the injected address port, not a habit column. - **Inputs:** A SQL client whose `query` returns `{ rows }`, and a Lightning address port. -- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. Adding a habit inserts the row and its first revision in one statement. Editing the wording updates the row and upserts that period's revision in one statement. A failed statement leaves both unchanged. +- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. Adding a habit inserts the row and its first revision in one statement. Editing the wording updates the owned row and upserts that period's revision in one statement, matching the habit id and the owner together. A missing or unowned habit writes nothing. A failed statement leaves both unchanged. - **Used by:** `openBootStores` when `DATABASE_URL` is set. diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts index ac044d281..fa23819f6 100644 --- a/src/__tests__/lib/member-habit-store.test.ts +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -127,14 +127,16 @@ function scriptedSql(initial: { habits: Row[]; revisions: Row[]; logs: Row[]; co } if (text.includes('member_habit_revision')) { if (text.includes('UPDATE member_habit')) { - const found = state.habits.find((row) => row['id'] === params[3]); + const found = state.habits.find( + (row) => row['id'] === params[3] && row['account_id'] === params[4], + ); if (found === undefined) { return { rows: [] }; } found['name'] = params[0]; found['description'] = params[1]; found['notes'] = params[2]; - const period = params[4]; + const period = params[5]; const existing = state.revisions.find( (row) => row['habit_id'] === params[3] && row['period'] === period, ); @@ -510,86 +512,56 @@ describe('PostgresMemberHabitStore', () => { }); it('edits the wording and the period revision in one statement', async () => { - const statements: string[] = []; + const calls: Array<{ text: string; params: unknown[] }> = []; const habit = sampleHabit(); + const patch = { name: 'Run', description: 'Outside', notes: 'secret' }; + const atPeriod = '2026-10-05'; + const expected = [ + patch.name, + patch.description, + patch.notes, + habit.id, + habit.accountId, + atPeriod, + ]; const sql = { - query: async (text: string): Promise<{ rows: Record[] }> => { - statements.push(text); - if (text.includes('WHERE id = $1')) { - return { - rows: [ - { - id: habit.id, - account_id: habit.accountId, - owner_name: habit.ownerName, - role: habit.role, - name: habit.name, - description: habit.description, - notes: habit.notes, - cadence: habit.cadence, - time_zone: habit.timeZone, - first_period: habit.firstPeriod, - last_period: habit.lastPeriod, - }, - ], - }; - } - if (text.includes('RETURNING habit_id')) { + query: async ( + text: string, + params: unknown[] = [], + ): Promise<{ rows: Record[] }> => { + calls.push({ text, params }); + if (text.includes('RETURNING habit_id') && text.includes('account_id = $5')) { return { rows: [{ habit_id: habit.id }] }; } return { rows: [] }; }, }; const store = new PostgresMemberHabitStore(sql, addressesOf(null)); - expect( - await store.edit( - habit.id, - habit.accountId, - { name: 'Run', description: 'Outside', notes: 'secret' }, - '2026-10-05', - ), - ).toBe('ok'); - const edits = statements.filter( - (text) => - text.includes('UPDATE member_habit') && text.includes('INSERT INTO member_habit_revision'), - ); - expect(edits).toHaveLength(1); - statements.length = 0; + expect(await store.edit(habit.id, habit.accountId, patch, atPeriod)).toBe('ok'); + expect(calls).toHaveLength(1); + const only = calls[0]; + if (only === undefined) { + throw new Error('expected the edit statement'); + } + expect(only.text).toContain('UPDATE member_habit'); + expect(only.text).toContain('INSERT INTO member_habit_revision'); + expect(only.text).toContain('WHERE id = $4 AND account_id = $5'); + expect(only.text).not.toContain('FROM member_habit\n'); + expect(only.params).toEqual(expected); + const missed: Array<{ text: string; params: unknown[] }> = []; const vanished = { - query: async (text: string): Promise<{ rows: Record[] }> => { - statements.push(text); - if (text.includes('WHERE id = $1')) { - return { - rows: [ - { - id: habit.id, - account_id: habit.accountId, - owner_name: habit.ownerName, - role: habit.role, - name: habit.name, - description: habit.description, - notes: habit.notes, - cadence: habit.cadence, - time_zone: habit.timeZone, - first_period: habit.firstPeriod, - last_period: habit.lastPeriod, - }, - ], - }; - } + query: async ( + text: string, + params: unknown[] = [], + ): Promise<{ rows: Record[] }> => { + missed.push({ text, params }); return { rows: [] }; }, }; const raced = new PostgresMemberHabitStore(vanished, addressesOf(null)); - expect( - await raced.edit( - habit.id, - habit.accountId, - { name: 'Run', description: 'Outside', notes: 'secret' }, - '2026-10-05', - ), - ).toBe('missing'); - expect(statements).toHaveLength(2); + expect(await raced.edit(habit.id, habit.accountId, patch, atPeriod)).toBe('missing'); + expect(missed).toHaveLength(1); + expect(missed[0]?.params).toEqual(expected); }); it('edits, archives, logs, lists, comments, and reads lightning through scripted rows', async () => { diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts index bd41e3967..bb2bdced9 100644 --- a/src/lib/member-habit-store.ts +++ b/src/lib/member-habit-store.ts @@ -640,24 +640,20 @@ export class PostgresMemberHabitStore implements MemberHabitStore { patch: { name: string; description: string; notes: string }, atPeriod: string, ): Promise<'ok' | 'missing'> { - const habit = await this.ownedHabit(id, accountId); - if (habit === null) { - return 'missing'; - } const written = await this.sql.query( `WITH updated AS ( UPDATE member_habit SET name = $1, description = $2, notes = $3 - WHERE id = $4 + WHERE id = $4 AND account_id = $5 RETURNING id ) INSERT INTO member_habit_revision (habit_id, period, name, description) - SELECT id, $5, $1, $2 FROM updated + SELECT id, $6, $1, $2 FROM updated ON CONFLICT (habit_id, period) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description RETURNING habit_id`, - [patch.name, patch.description, patch.notes, id, atPeriod], + [patch.name, patch.description, patch.notes, id, accountId, atPeriod], ); if (firstRow(written.rows) === null) { return 'missing'; From 641ff8b4b71a963dc427d7e12dee11c4f4b7e608 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:27:21 +0200 Subject: [PATCH 12/23] 01a10b01 - Cover an unowned habit on archive and log. The wording update no longer reads the row first, so the owner mismatch on the remaining read has to be reached from archive and log. --- src/__tests__/lib/member-habit-store.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts index fa23819f6..fe5b608c8 100644 --- a/src/__tests__/lib/member-habit-store.test.ts +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -642,6 +642,8 @@ describe('PostgresMemberHabitStore', () => { '2026-09-28', ), ).toBe('missing'); + expect(await store.archive('weekly', accountId, '2026-10-05')).toBe('missing'); + expect(await store.log('weekly', accountId, '2026-09-28', 'achieved')).toBe('missing'); expect(await store.archive('missing', accountId, '2026-10-05')).toBe('missing'); expect(await store.archive('ended', accountId, '2026-10-05')).toBe('ok'); expect(await store.archive(id, accountId, '2026-10-05')).toBe('ok'); @@ -662,7 +664,9 @@ describe('PostgresMemberHabitStore', () => { expect(owned?.comments.map((row) => row.id)).toEqual(['c-a', 'c-b']); const weekly = listed.find((row) => row.id === 'weekly'); expect(weekly?.notes).toBeUndefined(); + expect(weekly?.lastPeriod).toBeNull(); expect(weekly?.periods[0]?.period).toBe('2026-09-28'); + expect(weekly?.periods[0]?.logged).toBe(false); expect(await store.findComment('missing')).toBeNull(); expect((await store.findComment('c-a'))?.text).toBe('earlier'); await store.comment({ From 784bc38419df237d9d2d6093cee5cd980ff08566 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:14:11 +0200 Subject: [PATCH 13/23] 01a10b01 - Check comment deletion with roleAtLeast only. --- src/__tests__/routes/member-habits.test.ts | 10 +++++----- src/routes/member-habits.ts | 14 ++------------ 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index 9e8b8c3d8..d7f8d09be 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { InMemoryMemberHabitStore, type MemberHabit } from '@/lib/member-habit-store'; import { memberHabitRoutes } from '@/routes/member-habits'; import type { FetchFn } from '@/lib/lnurlp'; -import { InMemoryAuthStore } from '@/lib/auth/store'; +import { InMemoryAuthStore, type AccountRole } from '@/lib/auth/store'; const NOW_OPEN = Date.parse('2026-10-05T08:00:00.000Z'); const SUNDAY_ZURICH = Date.parse('2026-09-27T12:00:00.000Z'); @@ -10,7 +10,7 @@ const AUTH = { Authorization: 'Bearer tok' }; type AccountView = { id: string; - role: string; + role: AccountRole; name: string | null; lightningAddress: string | null; }; @@ -73,7 +73,7 @@ function sampleHabit( function mount(opts: { store?: InMemoryMemberHabitStore; - account?: { id: string; role: string; name: string | null } | null; + account?: { id: string; role: AccountRole; name: string | null } | null; accounts?: Record; now?: () => number; fetchImpl?: FetchFn; @@ -839,7 +839,7 @@ describe('memberHabitRoutes', () => { }); it('deleteComment allows moderator and founder, rejects verified and an unknown role, and 404s when missing', async () => { - async function seed(role: string): Promise { + async function seed(role: AccountRole): Promise { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h', accountId: 'owner', role: 'basis' })); await store.comment({ @@ -861,7 +861,7 @@ describe('memberHabitRoutes', () => { expect((await seed('verified')).status).toBe(403); expect((await seed('moderator')).status).toBe(200); expect((await seed('founder')).status).toBe(200); - expect((await seed('guest')).status).toBe(403); + expect((await seed('guest' as AccountRole)).status).toBe(403); const store = new InMemoryMemberHabitStore(); const missing = await post( mount({ store, account: INITIATOR }), diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 7bf9c87b8..4eacea49d 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -91,17 +91,7 @@ const postBody = z.discriminatedUnion('action', [ invoiceBody, ]); -type Viewer = { id: string; role: string; name: string | null }; - -function isAccountRole(role: string): role is AccountRole { - return ( - role === 'basis' || - role === 'verified' || - role === 'moderator' || - role === 'initiator' || - role === 'founder' - ); -} +type Viewer = { id: string; role: AccountRole; name: string | null }; function isHabitStatus(status: string): status is 'achieved' | 'partial' | 'missed' { return status === 'achieved' || status === 'partial' || status === 'missed'; @@ -430,7 +420,7 @@ export function memberHabitRoutes(deps: { if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { return c.json({ error: 'SUNDAY_REST' }, 403); } - if (!isAccountRole(account.role) || !roleAtLeast(account.role, 'initiator')) { + if (!roleAtLeast(account.role, 'initiator')) { return c.json({ error: 'Forbidden' }, 403); } const comment = await deps.store.findComment(body.id); From ff012b55f87854240773f917f09776ec58351dc4 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:53:54 +0200 Subject: [PATCH 14/23] 01a10b01 - Count habit text in Unicode code points. --- src/__tests__/routes/member-habits.test.ts | 63 ++++++++++++++++++++++ src/routes/member-habits.ts | 13 +++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index d7f8d09be..ef7406c81 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -671,6 +671,69 @@ describe('memberHabitRoutes', () => { expect(await longNotes.json()).toEqual({ error: 'Invalid notes' }); }); + it('add and comment count Unicode code points, not UTF-16 units', async () => { + const emoji = '😀'; + expect(emoji.length).toBe(2); + expect([...emoji].length).toBe(1); + const app = mount({ account: BASIS }); + const headers = { ...AUTH, 'Time-Zone': 'Asia/Manila' }; + const name = emoji.repeat(80); + const description = emoji.repeat(2000); + const notes = emoji.repeat(2000); + const created = await post( + app, + { action: 'add', name, description, notes, cadence: 'daily' }, + headers, + ); + expect(created.status).toBe(201); + const added = (await created.json()) as { ok: boolean; id: string }; + expect(added.ok).toBe(true); + const listed = await app.request('/', { headers: AUTH }); + const body = (await listed.json()) as { + habits: Array<{ id: string; name: string; description: string; notes?: string }>; + }; + const habit = body.habits.find((row) => row.id === added.id); + expect(habit?.name).toBe(name); + expect(habit?.description).toBe(description); + expect(habit?.notes).toBe(notes); + + const longName = await post( + app, + { action: 'add', name: emoji.repeat(81), cadence: 'daily' }, + headers, + ); + expect(longName.status).toBe(400); + expect(await longName.json()).toEqual({ error: 'Invalid name' }); + const longDescription = await post( + app, + { action: 'add', name: 'Walk', description: emoji.repeat(2001), cadence: 'daily' }, + headers, + ); + expect(longDescription.status).toBe(400); + expect(await longDescription.json()).toEqual({ error: 'Invalid description' }); + const longNotes = await post( + app, + { action: 'add', name: 'Walk', notes: emoji.repeat(2001), cadence: 'daily' }, + headers, + ); + expect(longNotes.status).toBe(400); + expect(await longNotes.json()).toEqual({ error: 'Invalid notes' }); + + const comment = await post( + app, + { action: 'comment', habitId: added.id, text: emoji.repeat(2000) }, + headers, + ); + expect(comment.status).toBe(201); + const longComment = await post( + app, + { action: 'comment', habitId: added.id, text: emoji.repeat(2001) }, + headers, + ); + expect(longComment.status).toBe(400); + expect(await longComment.json()).toEqual({ error: 'Invalid comment' }); + }); + it('add with a null account name stores an empty owner name', async () => { const store = new InMemoryMemberHabitStore(); const app = mount({ store, account: { id: 'acc-noname', role: 'basis', name: null } }); diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 4eacea49d..9425de115 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -117,19 +117,24 @@ function roleGroup(role: string): number { return 2; } +/** Unicode code points, the same count as PostgreSQL `char_length`. */ +function unicodeLength(value: string): number { + return [...value].length; +} + function invalidText( name: string, description: string, notes: string, ): 'Invalid name' | 'Invalid description' | 'Invalid notes' | null { const trimmed = name.trim(); - if (trimmed.length < 1 || trimmed.length > 80) { + if (unicodeLength(trimmed) < 1 || unicodeLength(trimmed) > 80) { return 'Invalid name'; } - if (description.length > 2000) { + if (unicodeLength(description) > 2000) { return 'Invalid description'; } - if (notes.length > 2000) { + if (unicodeLength(notes) > 2000) { return 'Invalid notes'; } return null; @@ -395,7 +400,7 @@ export function memberHabitRoutes(deps: { return c.json({ error: 'SUNDAY_REST' }, 403); } const text = body.text.trim(); - if (text.length < 1 || text.length > 2000) { + if (unicodeLength(text) < 1 || unicodeLength(text) > 2000) { return c.json({ error: 'Invalid comment' }, 400); } const habits = await deps.store.listPublic(null, nowMs); From 4862e3aaec4bb4a46aa9c0438bfd25769b99eac8 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:03:34 +0200 Subject: [PATCH 15/23] 01a10b01 - Reject a bad habit comment id and bound revision text. A comment id that is not a UUID is Not found before the store, and a well-formed id that is missing is the same response. Revision name and description get the same length checks as the habit row, including a table created before those checks. --- docs/handbook/endpoints.md | 2 +- docs/handbook/functions.md | 8 +- docs/schema/member-habit.sql | 31 ++++- integration/sql-driver.test.ts | 99 ++++++++++++++- src/__tests__/lib/member-habit-store.test.ts | 29 ++++- src/__tests__/routes/member-habits.test.ts | 120 +++++++++++++------ src/lib/member-habit-store.ts | 70 +++++++++-- src/routes/member-habits.ts | 11 ++ 8 files changed, 312 insertions(+), 58 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index d6f713875..1217f5d24 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1162,5 +1162,5 @@ Operator inspection of external Nostr identities that have earned visibility or - **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment, including a comment id that is not a UUID on `deleteComment` and `invoice`. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 416a26d07..798d3084f 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3319,7 +3319,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint is 502 `{ error: 'Lightning Address could not be resolved' }`. Does not pay. Add, edit, archive, and log stay open on Sunday. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. @@ -3382,9 +3382,9 @@ Builds the operator-only external-pubkey inspection route. ## Function: migrateMemberHabitSchema -- **Purpose:** Run the idempotent `CREATE TABLE IF NOT EXISTS` statements for `member_habit`, `member_habit_revision`, `member_habit_log`, and `member_habit_comment`. +- **Purpose:** Run the idempotent statements for `member_habit`, `member_habit_revision`, `member_habit_log`, and `member_habit_comment`. Revision `name` is `char_length` 1–80 and `description` is at most 2000, the same limits as the habit row. A table created before those checks receives them on the next run. - **Inputs:** A SQL client whose `query` returns `{ rows }`. -- **Returns / side effects:** Resolves when the four statements have run. Safe to call more than once. +- **Returns / side effects:** Resolves when the five statements have run. Safe to call more than once. - **Used by:** `openBootStores` when `DATABASE_URL` is set. ## Function: InMemoryMemberHabitStore @@ -3398,5 +3398,5 @@ Builds the operator-only external-pubkey inspection route. - **Purpose:** `MemberHabitStore` against the `member_habit*` tables. Lightning addresses go through the injected address port, not a habit column. - **Inputs:** A SQL client whose `query` returns `{ rows }`, and a Lightning address port. -- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. Adding a habit inserts the row and its first revision in one statement. Editing the wording updates the owned row and upserts that period's revision in one statement, matching the habit id and the owner together. A missing or unowned habit writes nothing. A failed statement leaves both unchanged. +- **Returns / side effects:** Same port as the in-memory store, persisted in Postgres. Adding a habit inserts the row and its first revision in one statement. Editing the wording updates the owned row and upserts that period's revision in one statement, matching the habit id and the owner together. A missing or unowned habit writes nothing. A failed statement leaves both unchanged. A comment id Postgres rejects as uuid text is missing, not an error. Revision `name` and `description` use the same `char_length` checks as the habit row. - **Used by:** `openBootStores` when `DATABASE_URL` is set. diff --git a/docs/schema/member-habit.sql b/docs/schema/member-habit.sql index 5242ba13e..c52198a6a 100644 --- a/docs/schema/member-habit.sql +++ b/docs/schema/member-habit.sql @@ -16,8 +16,8 @@ CREATE TABLE IF NOT EXISTS member_habit ( CREATE TABLE IF NOT EXISTS member_habit_revision ( habit_id uuid NOT NULL REFERENCES member_habit (id), period text NOT NULL, - name text NOT NULL, - description text NOT NULL, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), + description text NOT NULL CHECK (char_length(description) <= 2000), PRIMARY KEY (habit_id, period) ); @@ -38,3 +38,30 @@ CREATE TABLE IF NOT EXISTS member_habit_comment ( created_at double precision NOT NULL, deleted_at double precision NULL ); + +-- A table created before the revision length checks receives them on the next run. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'member_habit_revision'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%char_length(name)%' + ) THEN + ALTER TABLE member_habit_revision + ADD CONSTRAINT member_habit_revision_name_len + CHECK (char_length(name) BETWEEN 1 AND 80); + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'member_habit_revision'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%char_length(description)%' + ) THEN + ALTER TABLE member_habit_revision + ADD CONSTRAINT member_habit_revision_description_len + CHECK (char_length(description) <= 2000); + END IF; +END $$; diff --git a/integration/sql-driver.test.ts b/integration/sql-driver.test.ts index 3f634e74c..4c7cac4f7 100644 --- a/integration/sql-driver.test.ts +++ b/integration/sql-driver.test.ts @@ -1,9 +1,10 @@ import { SQL } from 'bun'; import { describe, expect, test } from 'bun:test'; import { migrateAuthSchema } from '@/lib/auth/postgres-store'; -import type { SqlClient } from '@/lib/auth/sql'; +import { sqlState, type SqlClient } from '@/lib/auth/sql'; import type { FundingGrant } from '@/lib/funding'; import { migrateFundingSchema, PostgresFundingStore } from '@/lib/funding-store'; +import { migrateMemberHabitSchema, PostgresMemberHabitStore } from '@/lib/member-habit-store'; import { postgresTextArrayLiteral } from '@/lib/postgres-text-array'; const databaseUrl = process.env['DATABASE_URL']; @@ -143,3 +144,99 @@ VALUES ($1, 'verified', false, false, $2)`, } }); }); + +describe('member habit revision checks', () => { + test('a reused table gains char_length checks and a bad comment id is missing', async () => { + const { client, sql } = createBunSqlClient(databaseUrl); + const habitSql = { + async query(text: string, params?: unknown[]) { + const rows = await client.query>(text, params); + return { rows }; + }, + }; + const accountId = crypto.randomUUID(); + const habitId = crypto.randomUUID(); + let ready = false; + try { + await migrateAuthSchema(client); + await migrateMemberHabitSchema(habitSql); + await migrateMemberHabitSchema(habitSql); + ready = true; + const defs = await client.query<{ def: string }>( + `SELECT pg_get_constraintdef(oid) AS def + FROM pg_constraint + WHERE conrelid = 'member_habit_revision'::regclass AND contype = 'c'`, + ); + const text = defs.map((row) => row.def).join('\n'); + expect(text).toContain('char_length(name)'); + expect(text).toContain('char_length(description)'); + + await client.execute( + `INSERT INTO account (id, role, lightning_address_verified, forum_laws_dismissed, created_at) +VALUES ($1, 'basis', false, false, $2)`, + [accountId, new Date()], + ); + const store = new PostgresMemberHabitStore(habitSql, { + get: async () => null, + set: async () => { + return; + }, + }); + await store.add({ + id: habitId, + accountId, + ownerName: 'Owner', + role: 'basis', + name: 'Walk', + description: '', + notes: '', + cadence: 'daily', + timeZone: 'Asia/Manila', + firstPeriod: '2026-10-01', + lastPeriod: null, + }); + await client.execute( + `INSERT INTO member_habit_revision (habit_id, period, name, description) +VALUES ($1, '2026-10-02', $2, 'ok')`, + [habitId, 'a'.repeat(80)], + ); + + let tooLongName: unknown; + try { + await client.execute( + `INSERT INTO member_habit_revision (habit_id, period, name, description) +VALUES ($1, '2026-10-03', $2, 'ok')`, + [habitId, 'a'.repeat(81)], + ); + } catch (error) { + tooLongName = error; + } + expect(sqlState(tooLongName)).toBe('23514'); + + let tooLongDescription: unknown; + try { + await client.execute( + `INSERT INTO member_habit_revision (habit_id, period, name, description) +VALUES ($1, '2026-10-04', 'ok', $2)`, + [habitId, 'b'.repeat(2001)], + ); + } catch (error) { + tooLongDescription = error; + } + expect(sqlState(tooLongDescription)).toBe('23514'); + + expect(await store.findComment('nope')).toBeNull(); + expect(await store.deleteComment('nope')).toBe(false); + expect(await store.findComment(crypto.randomUUID())).toBeNull(); + } finally { + if (ready) { + await client.execute(`DELETE FROM member_habit_revision WHERE habit_id = $1`, [habitId]); + await client.execute(`DELETE FROM member_habit_log WHERE habit_id = $1`, [habitId]); + await client.execute(`DELETE FROM member_habit_comment WHERE habit_id = $1`, [habitId]); + await client.execute(`DELETE FROM member_habit WHERE id = $1`, [habitId]); + await client.execute(`DELETE FROM account WHERE id = $1`, [accountId]); + } + await closeIfPossible(sql); + } + }); +}); diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts index fe5b608c8..fff11c88a 100644 --- a/src/__tests__/lib/member-habit-store.test.ts +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -484,7 +484,11 @@ describe('PostgresMemberHabitStore', () => { await migrateMemberHabitSchema(sql); expect(statements).toEqual([...MEMBER_HABIT_SCHEMA_SQL]); - expect(MEMBER_HABIT_SCHEMA_SQL).toHaveLength(4); + expect(MEMBER_HABIT_SCHEMA_SQL).toHaveLength(5); + expect(MEMBER_HABIT_SCHEMA_SQL[1]).toMatch(/char_length\(name\) BETWEEN 1 AND 80/); + expect(MEMBER_HABIT_SCHEMA_SQL[1]).toMatch(/char_length\(description\) <= 2000/); + expect(MEMBER_HABIT_SCHEMA_SQL[4]).toMatch(/member_habit_revision_name_len/); + expect(MEMBER_HABIT_SCHEMA_SQL[4]).toMatch(/member_habit_revision_description_len/); expect(MEMBER_HABIT_SCHEMA_SQL[0]).toMatch( /account_id uuid NOT NULL REFERENCES account \(id\)/, ); @@ -511,6 +515,29 @@ describe('PostgresMemberHabitStore', () => { expect(statements.some((text) => text.includes('FROM member_habit'))).toBe(true); }); + it('treats a postgres uuid syntax error on a comment id as missing', async () => { + const invalid = Object.assign(new Error('invalid input syntax for type uuid'), { + code: '22P02', + }); + const other = new Error('connection refused'); + const sql = { + query: async (): Promise<{ rows: Record[] }> => { + throw invalid; + }, + }; + const store = new PostgresMemberHabitStore(sql, addressesOf(null)); + expect(await store.findComment('nope')).toBeNull(); + expect(await store.deleteComment('nope')).toBe(false); + const failing = { + query: async (): Promise<{ rows: Record[] }> => { + throw other; + }, + }; + const broken = new PostgresMemberHabitStore(failing, addressesOf(null)); + await expect(broken.findComment('nope')).rejects.toThrow('connection refused'); + await expect(broken.deleteComment('nope')).rejects.toThrow('connection refused'); + }); + it('edits the wording and the period revision in one statement', async () => { const calls: Array<{ text: string; params: unknown[] }> = []; const habit = sampleHabit(); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index ef7406c81..aba407e70 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -330,7 +330,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); await store.comment({ - id: 'c-1', + id: '11111111-1111-4111-8111-111111111111', habitId: 'h-comment', accountId: BASIS.id, name: 'Basis', @@ -341,7 +341,7 @@ describe('memberHabitRoutes', () => { }); const res = await post( mount({ store, account: BASIS }), - { action: 'deleteComment', id: 'c-1' }, + { action: 'deleteComment', id: '11111111-1111-4111-8111-111111111111' }, AUTH, ); expect(res.status).toBe(403); @@ -352,7 +352,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); await store.comment({ - id: 'c-1', + id: '11111111-1111-4111-8111-111111111111', habitId: 'h-comment', accountId: BASIS.id, name: 'Basis', @@ -363,7 +363,7 @@ describe('memberHabitRoutes', () => { }); const res = await post( mount({ store, account: INITIATOR, now: () => SUNDAY_ZURICH }), - { action: 'deleteComment', id: 'c-1' }, + { action: 'deleteComment', id: '11111111-1111-4111-8111-111111111111' }, { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, ); expect(res.status).toBe(403); @@ -374,7 +374,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); await store.comment({ - id: 'c-1', + id: '11111111-1111-4111-8111-111111111111', habitId: 'h-comment', accountId: BASIS.id, name: 'Basis', @@ -385,7 +385,7 @@ describe('memberHabitRoutes', () => { }); const res = await post( mount({ store, account: INITIATOR }), - { action: 'deleteComment', id: 'c-1' }, + { action: 'deleteComment', id: '11111111-1111-4111-8111-111111111111' }, AUTH, ); expect(res.status).toBe(200); @@ -396,7 +396,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: BASIS.id, role: 'basis' })); await store.comment({ - id: 'c-own', + id: '22222222-2222-4222-8222-222222222222', habitId: 'h-comment', accountId: BASIS.id, name: 'Basis', @@ -407,7 +407,7 @@ describe('memberHabitRoutes', () => { }); const res = await post( mount({ store, account: BASIS }), - { action: 'invoice', commentId: 'c-own', amountSats: 1 }, + { action: 'invoice', commentId: '22222222-2222-4222-8222-222222222222', amountSats: 1 }, AUTH, ); expect(res.status).toBe(400); @@ -418,7 +418,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h-comment', accountId: ALICE.id, name: 'Alice', @@ -433,7 +433,7 @@ describe('memberHabitRoutes', () => { account: BASIS, accounts: { [ALICE.id]: { ...ALICE, lightningAddress: null } }, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(res.status).toBe(409); @@ -446,7 +446,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h-comment', accountId: ALICE.id, name: 'Alice', @@ -462,7 +462,7 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: ALICE }, fetchImpl: throwingFetch, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(res.status).toBe(502); @@ -473,7 +473,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h-comment', accountId: ALICE.id, name: 'Alice', @@ -489,7 +489,7 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: ALICE }, fetchImpl: successFetch, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(res.status).toBe(200); @@ -500,7 +500,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h-comment', accountId: ALICE.id, name: 'Alice', @@ -517,7 +517,7 @@ describe('memberHabitRoutes', () => { fetchImpl: successFetch, now: () => SUNDAY_ZURICH, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 0 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 0 }, { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, ); expect(res.status).toBe(403); @@ -528,7 +528,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h-comment', accountId: ALICE.id, name: 'Alice', @@ -545,7 +545,7 @@ describe('memberHabitRoutes', () => { fetchImpl: successFetch, now: () => SUNDAY_ZURICH, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(res.status).toBe(200); @@ -556,7 +556,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h-comment', accountId: ALICE.id, name: 'Alice', @@ -573,7 +573,7 @@ describe('memberHabitRoutes', () => { fetchImpl: successFetch, now: () => SUNDAY_ZURICH, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, { ...AUTH, 'Time-Zone': 'Not/AZone' }, ); expect(res.status).toBe(200); @@ -906,7 +906,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h', accountId: 'owner', role: 'basis' })); await store.comment({ - id: 'c-1', + id: '11111111-1111-4111-8111-111111111111', habitId: 'h', accountId: 'owner', name: 'Owner', @@ -917,7 +917,7 @@ describe('memberHabitRoutes', () => { }); return post( mount({ store, account: { id: 'staff', role, name: 'Staff' } }), - { action: 'deleteComment', id: 'c-1' }, + { action: 'deleteComment', id: '11111111-1111-4111-8111-111111111111' }, AUTH, ); } @@ -932,9 +932,27 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(missing.status).toBe(404); + const unknownId = await post( + mount({ store, account: INITIATOR }), + { action: 'deleteComment', id: '55555555-5555-4555-8555-555555555555' }, + AUTH, + ); + expect(unknownId.status).toBe(404); + expect(await unknownId.json()).toEqual({ error: 'Not found' }); + const malformed = new InMemoryMemberHabitStore(); + malformed.findComment = async () => { + throw new Error('uuid syntax'); + }; + const refused = await post( + mount({ store: malformed, account: INITIATOR }), + { action: 'deleteComment', id: 'nope' }, + AUTH, + ); + expect(refused.status).toBe(404); + expect(await refused.json()).toEqual({ error: 'Not found' }); await store.add(sampleHabit({ id: 'h', accountId: 'owner', role: 'basis' })); await store.comment({ - id: 'c-stuck', + id: '44444444-4444-4444-8444-444444444444', habitId: 'h', accountId: 'owner', name: 'Owner', @@ -946,7 +964,7 @@ describe('memberHabitRoutes', () => { store.deleteComment = async () => false; const stuck = await post( mount({ store, account: INITIATOR }), - { action: 'deleteComment', id: 'c-stuck' }, + { action: 'deleteComment', id: '44444444-4444-4444-8444-444444444444' }, AUTH, ); expect(stuck.status).toBe(404); @@ -956,7 +974,7 @@ describe('memberHabitRoutes', () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h', accountId: ALICE.id, role: 'basis' })); await store.comment({ - id: 'c-alice', + id: '33333333-3333-4333-8333-333333333333', habitId: 'h', accountId: ALICE.id, name: 'Alice', @@ -971,20 +989,28 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: { ...ALICE, lightningAddress: '' } }, fetchImpl: successFetch, }); - const zero = await post(app, { action: 'invoice', commentId: 'c-alice', amountSats: 0 }, AUTH); + const zero = await post( + app, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 0 }, + AUTH, + ); expect(zero.status).toBe(400); expect(await zero.json()).toEqual({ error: 'Expected a JSON body with an integer "amountSats"', }); const fraction = await post( app, - { action: 'invoice', commentId: 'c-alice', amountSats: 1.5 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1.5 }, AUTH, ); expect(fraction.status).toBe(400); const huge = await post( app, - { action: 'invoice', commentId: 'c-alice', amountSats: 10_000_001 }, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: 10_000_001, + }, AUTH, ); expect(huge.status).toBe(400); @@ -993,9 +1019,27 @@ describe('memberHabitRoutes', () => { }); const missing = await post(app, { action: 'invoice', commentId: 'nope', amountSats: 1 }, AUTH); expect(missing.status).toBe(404); + const unknownComment = await post( + app, + { action: 'invoice', commentId: '55555555-5555-4555-8555-555555555555', amountSats: 1 }, + AUTH, + ); + expect(unknownComment.status).toBe(404); + expect(await unknownComment.json()).toEqual({ error: 'Not found' }); + const malformed = new InMemoryMemberHabitStore(); + malformed.findComment = async () => { + throw new Error('uuid syntax'); + }; + const refused = await post( + mount({ store: malformed, account: BASIS, fetchImpl: successFetch }), + { action: 'invoice', commentId: 'nope', amountSats: 1 }, + AUTH, + ); + expect(refused.status).toBe(404); + expect(await refused.json()).toEqual({ error: 'Not found' }); const emptyWallet = await post( app, - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(emptyWallet.status).toBe(409); @@ -1004,7 +1048,7 @@ describe('memberHabitRoutes', () => { }); const stillNoWallet = await post( app, - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(stillNoWallet.status).toBe(409); @@ -1018,7 +1062,11 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: { ...ALICE, lightningAddress: '' } }, fetchImpl: successFetch, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 10_000_000 }, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: 10_000_000, + }, AUTH, ); expect(atCeiling.status).toBe(409); @@ -1031,13 +1079,13 @@ describe('memberHabitRoutes', () => { }); const first = await post( paying, - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(first.status).toBe(200); const second = await post( paying, - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(second.status).toBe(429); @@ -1051,7 +1099,7 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: ALICE }, fetchImpl: failFetch, }), - { action: 'invoice', commentId: 'c-alice', amountSats: 1 }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, AUTH, ); expect(failed.status).toBe(502); @@ -1063,7 +1111,7 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: ALICE }, fetchImpl: successFetch, }), - { action: 'invoice', commentId: 'c-alice', amountSats: '21' }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: '21' }, AUTH, ); expect(textAmount.status).toBe(400); @@ -1077,7 +1125,7 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: ALICE }, fetchImpl: successFetch, }), - { action: 'invoice', commentId: 'c-alice' }, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333' }, AUTH, ); expect(missingAmount.status).toBe(400); diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts index bb2bdced9..ba7b2889a 100644 --- a/src/lib/member-habit-store.ts +++ b/src/lib/member-habit-store.ts @@ -1,3 +1,4 @@ +import { sqlState } from '@/lib/auth/sql'; import { comparePeriod, dayKey, nextPeriod, weeklyRatableThrough } from '@/lib/member-habit'; /** @@ -123,7 +124,10 @@ export interface MemberHabitStore { } /** - * Idempotent `CREATE TABLE IF NOT EXISTS` statements for the member-habit tables. + * Idempotent member-habit schema statements. + * + * The first four create the tables. The last adds the revision length checks + * when a table created before those checks is still missing them. */ export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ `CREATE TABLE IF NOT EXISTS member_habit ( @@ -142,8 +146,8 @@ export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ `CREATE TABLE IF NOT EXISTS member_habit_revision ( habit_id uuid NOT NULL REFERENCES member_habit (id), period text NOT NULL, - name text NOT NULL, - description text NOT NULL, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), + description text NOT NULL CHECK (char_length(description) <= 2000), PRIMARY KEY (habit_id, period) );`, `CREATE TABLE IF NOT EXISTS member_habit_log ( @@ -162,6 +166,31 @@ export const MEMBER_HABIT_SCHEMA_SQL: readonly string[] = [ created_at double precision NOT NULL, deleted_at double precision NULL );`, + `DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'member_habit_revision'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%char_length(name)%' + ) THEN + ALTER TABLE member_habit_revision + ADD CONSTRAINT member_habit_revision_name_len + CHECK (char_length(name) BETWEEN 1 AND 80); + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'member_habit_revision'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%char_length(description)%' + ) THEN + ALTER TABLE member_habit_revision + ADD CONSTRAINT member_habit_revision_description_len + CHECK (char_length(description) <= 2000); + END IF; +END $$;`, ]; /** @@ -756,12 +785,20 @@ export class PostgresMemberHabitStore implements MemberHabitStore { } async findComment(id: string): Promise { - const result = await this.sql.query( - `SELECT id, habit_id, account_id, name, "text", week, created_at, deleted_at - FROM member_habit_comment - WHERE id = $1 AND deleted_at IS NULL`, - [id], - ); + let result: { rows: Record[] }; + try { + result = await this.sql.query( + `SELECT id, habit_id, account_id, name, "text", week, created_at, deleted_at + FROM member_habit_comment + WHERE id = $1 AND deleted_at IS NULL`, + [id], + ); + } catch (error) { + if (sqlState(error) === '22P02') { + return null; + } + throw error; + } const row = firstRow(result.rows); if (row === null) { return null; @@ -788,10 +825,17 @@ export class PostgresMemberHabitStore implements MemberHabitStore { } async deleteComment(id: string): Promise { - const result = await this.sql.query( - `SELECT deleted_at FROM member_habit_comment WHERE id = $1`, - [id], - ); + let result: { rows: Record[] }; + try { + result = await this.sql.query(`SELECT deleted_at FROM member_habit_comment WHERE id = $1`, [ + id, + ]); + } catch (error) { + if (sqlState(error) === '22P02') { + return false; + } + throw error; + } const row = firstRow(result.rows); if (row === null) { return false; diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 9425de115..41346134f 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -21,6 +21,11 @@ import { isSundayRestHeader } from '@/lib/sunday-rest'; import type { MemberHabit, MemberHabitStore } from '@/lib/member-habit-store'; const PERIOD_RE = /^\d{4}-\d{2}-\d{2}$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(value: string): boolean { + return UUID_RE.test(value); +} const addBody = z .object({ @@ -428,6 +433,9 @@ export function memberHabitRoutes(deps: { if (!roleAtLeast(account.role, 'initiator')) { return c.json({ error: 'Forbidden' }, 403); } + if (!isUuid(body.id)) { + return c.json({ error: 'Not found' }, 404); + } const comment = await deps.store.findComment(body.id); if (comment === null) { return c.json({ error: 'Not found' }, 404); @@ -451,6 +459,9 @@ export function memberHabitRoutes(deps: { ) { return c.json({ error: 'Expected a JSON body with an integer "amountSats"' }, 400); } + if (!isUuid(body.commentId)) { + return c.json({ error: 'Not found' }, 404); + } const comment = await deps.store.findComment(body.commentId); if (comment === null) { return c.json({ error: 'Not found' }, 404); From 9b85d79eaeefb9ec8948c383d9322cd9c47acac5 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:44:23 +0200 Subject: [PATCH 16/23] 01a10b01 - Prove a reused habit revision table gains its length checks. Creating the table twice never reached the follow-up constraint, so a revision table from before those checks could ship without them. --- integration/sql-driver.test.ts | 51 ++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/integration/sql-driver.test.ts b/integration/sql-driver.test.ts index 4c7cac4f7..71a4cc437 100644 --- a/integration/sql-driver.test.ts +++ b/integration/sql-driver.test.ts @@ -160,16 +160,51 @@ describe('member habit revision checks', () => { try { await migrateAuthSchema(client); await migrateMemberHabitSchema(habitSql); - await migrateMemberHabitSchema(habitSql); ready = true; - const defs = await client.query<{ def: string }>( - `SELECT pg_get_constraintdef(oid) AS def - FROM pg_constraint - WHERE conrelid = 'member_habit_revision'::regclass AND contype = 'c'`, + // A table created before the length checks has no char_length constraint. + // CREATE TABLE IF NOT EXISTS does not add one, so only the DO block can. + await client.execute( + `DO $$ +DECLARE + constraint_name text; +BEGIN + FOR constraint_name IN + SELECT con.conname + FROM pg_constraint AS con + WHERE con.conrelid = 'member_habit_revision'::regclass + AND con.contype = 'c' + AND ( + pg_get_constraintdef(con.oid) LIKE '%char_length(name)%' + OR pg_get_constraintdef(con.oid) LIKE '%char_length(description)%' + ) + LOOP + EXECUTE format('ALTER TABLE member_habit_revision DROP CONSTRAINT %I', constraint_name); + END LOOP; +END $$;`, ); - const text = defs.map((row) => row.def).join('\n'); - expect(text).toContain('char_length(name)'); - expect(text).toContain('char_length(description)'); + const revisionChecks = async (): Promise<{ name: string; def: string }[]> => { + return client.query<{ name: string; def: string }>( + `SELECT conname AS name, pg_get_constraintdef(oid) AS def + FROM pg_constraint + WHERE conrelid = 'member_habit_revision'::regclass AND contype = 'c'`, + ); + }; + const dropped = await revisionChecks(); + expect(dropped.map((row) => row.def).join('\n')).not.toContain('char_length('); + await migrateMemberHabitSchema(habitSql); + const added = await revisionChecks(); + expect(added.map((row) => row.name).sort()).toEqual([ + 'member_habit_revision_description_len', + 'member_habit_revision_name_len', + ]); + expect(added.map((row) => row.def).join('\n')).toContain('char_length(name)'); + expect(added.map((row) => row.def).join('\n')).toContain('char_length(description)'); + await migrateMemberHabitSchema(habitSql); + const again = await revisionChecks(); + expect(again.map((row) => row.name).sort()).toEqual([ + 'member_habit_revision_description_len', + 'member_habit_revision_name_len', + ]); await client.execute( `INSERT INTO account (id, role, lightning_address_verified, forum_laws_dismissed, created_at) From 2b783d34aa29969dcbb995e195dc30d9b07ed358 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:47:42 +0200 Subject: [PATCH 17/23] 01a10b01 - Reject a habit invoice that is not the requested amount. A habit gift returns the payment request only when it decodes to exactly the requested millisatoshis, the same check the existing pay invoice already uses. --- SPEC.md | 9 +- docs/handbook/endpoints.md | 2 +- docs/handbook/functions.md | 2 +- src/__tests__/routes/member-habits.test.ts | 117 +++++++++++++++++---- src/routes/member-habits.ts | 8 +- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/SPEC.md b/SPEC.md index ef3102eb0..110a98e92 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5467,12 +5467,17 @@ Success is the same gift body as `POST /pay/:username/invoice`: { "pr": "lnbc...", "amountSats": 21 } ``` +The `pr` is returned only when it decodes to exactly `amountSats * 1000` +millisatoshis, the same rule as `POST /pay/:username/invoice`. + Donating to the caller's own comment is **400** `{ "error": "Cannot donate to yourself" }`. No Lightning Address on the author is **409** `{ "error": "The author's wallet cannot receive this Bitcoin payment" }`. The existing invoice limiter answers **429** `{ "error": "Too many payments" }`. A failed -mint is **502** `{ "error": "Lightning Address could not be resolved" }`, -the same failure as `POST /pay/:username/invoice`. +mint, or a BOLT11 that is missing, not a safe integer amount, or not the +requested amount, is **502** +`{ "error": "Lightning Address could not be resolved" }`, the same failure +as `POST /pay/:username/invoice`. Missing bearer → **401** `{ "error": "Unauthorized" }`. A body that is not one of the actions → **400** `{ "error": "Invalid body" }`. Unknown diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 1217f5d24..31583ffb3 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1162,5 +1162,5 @@ Operator inspection of external Nostr identities that have earned visibility or - **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. -- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment, including a comment id that is not a UUID on `deleteComment` and `invoice`. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }`. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). +- **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment, including a comment id that is not a UUID on `deleteComment` and `invoice`. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }` when the mint fails or the BOLT11 does not decode to exactly `amountSats * 1000` millisatoshis. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 798d3084f..7b903dbeb 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3319,7 +3319,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint, or a BOLT11 that does not decode to exactly `amountSats * 1000` millisatoshis, is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index aba407e70..9fed409d3 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -38,18 +38,27 @@ const ALICE: AccountView = { const unusedFetch: FetchFn = async () => new Response(null, { status: 500 }); -const successFetch: FetchFn = async (input) => { - const url = String(input); - if (url.includes('/.well-known/lnurlp/')) { - return Response.json({ - callback: 'https://wallet.example/callback', - minSendable: 1000, - maxSendable: 100000000000, - metadata: '[]', - }); - } - return Response.json({ pr: 'lnbc1' }); -}; +/** BOLT11 spec example: 2500 uBTC = 250_000 sats = 250_000_000 msat. */ +const MATCHING_PR = + 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'; +const MATCHING_SATS = 250_000; + +function invoiceFetch(pr: string): FetchFn { + return async (input) => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return Response.json({ + callback: 'https://wallet.example/callback', + minSendable: 1000, + maxSendable: 100000000000, + metadata: '[]', + }); + } + return Response.json({ pr }); + }; +} + +const successFetch = invoiceFetch(MATCHING_PR); const throwingFetch: FetchFn = async () => { throw new Error('network'); @@ -489,11 +498,69 @@ describe('memberHabitRoutes', () => { accounts: { [ALICE.id]: ALICE }, fetchImpl: successFetch, }), - { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: MATCHING_SATS, + }, AUTH, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc1', amountSats: 1 }); + expect(await res.json()).toEqual({ pr: MATCHING_PR, amountSats: MATCHING_SATS }); + }); + + it('invoice when the BOLT11 does not decode is 502', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: '33333333-3333-4333-8333-333333333333', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: invoiceFetch('not-an-invoice'), + }), + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Lightning Address could not be resolved' }); + }); + + it('invoice when the BOLT11 amount is not the requested amount is 502', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); + await store.comment({ + id: '33333333-3333-4333-8333-333333333333', + habitId: 'h-comment', + accountId: ALICE.id, + name: 'Alice', + text: 'nice', + week: '2026-09-28', + createdAt: NOW_OPEN, + deletedAt: null, + }); + const res = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + }), + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Lightning Address could not be resolved' }); }); it('invoice on Sunday in Europe/Zurich is 403 SUNDAY_REST before the amount check', async () => { @@ -545,11 +612,15 @@ describe('memberHabitRoutes', () => { fetchImpl: successFetch, now: () => SUNDAY_ZURICH, }), - { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: MATCHING_SATS, + }, AUTH, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc1', amountSats: 1 }); + expect(await res.json()).toEqual({ pr: MATCHING_PR, amountSats: MATCHING_SATS }); }); it('invoice on Sunday with an invalid Time-Zone is 200', async () => { @@ -573,11 +644,15 @@ describe('memberHabitRoutes', () => { fetchImpl: successFetch, now: () => SUNDAY_ZURICH, }), - { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: MATCHING_SATS, + }, { ...AUTH, 'Time-Zone': 'Not/AZone' }, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc1', amountSats: 1 }); + expect(await res.json()).toEqual({ pr: MATCHING_PR, amountSats: MATCHING_SATS }); }); it('a store whose listPublic throws returns 503 and the body error Habits are unavailable', async () => { @@ -1079,7 +1154,11 @@ describe('memberHabitRoutes', () => { }); const first = await post( paying, - { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: MATCHING_SATS, + }, AUTH, ); expect(first.status).toBe(200); diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 41346134f..a9e1e3edb 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -6,6 +6,7 @@ import { roleAtLeast } from '@/lib/auth/roles'; import type { AccountRole } from '@/lib/auth/store'; import { InvoiceRateLimiter } from '@/lib/nostr/rate-limit'; import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; +import { decodeBolt11 } from '@/lib/bolt11'; import { requestGiftInvoice } from '@/lib/gift-invoice'; import type { FetchFn } from '@/lib/lnurlp'; import { @@ -481,11 +482,12 @@ export function memberHabitRoutes(deps: { return c.json({ error: 'Too many payments' }, 429); } const address = author.lightningAddress; + const amountMsat = amountSats * 1000; let invoice: { ok: true; pr: string } | { ok: false }; try { invoice = await requestGiftInvoice({ address, - amountMsat: amountSats * 1000, + amountMsat, fetchImpl: deps.fetchImpl, }); /* v8 ignore next 3 -- requestGiftInvoice returns ok:false instead of throwing */ @@ -495,6 +497,10 @@ export function memberHabitRoutes(deps: { if (!invoice.ok) { return c.json({ error: 'Lightning Address could not be resolved' }, 502); } + const decoded = decodeBolt11(invoice.pr); + if (decoded === null || decoded.amountMsat !== amountMsat) { + return c.json({ error: 'Lightning Address could not be resolved' }, 502); + } return c.json({ pr: invoice.pr, amountSats }, 200); } catch { console.warn(JSON.stringify({ ts: new Date().toISOString(), event: 'habits.failed' })); From ae3820de62cdca4cb29b21e1e16993c164f8cf45 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:42:29 +0200 Subject: [PATCH 18/23] 01a10b01 - Keep the route table separate from the auth note. --- SPEC.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SPEC.md b/SPEC.md index 110a98e92..cdbb1d1d8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -230,6 +230,7 @@ Public base URLs used in examples: | GET | `/invoices/eligible` | Bearer `SPEND_API_TOKEN` | Whether the address is funding-eligible today, plus effective grant `status` | | POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey, funding grant, and forum post required) | | POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | + Auth column: "Bearer (X+)" means minimum role X — X or any higher role. ## Role hierarchy From b795a59dc0c32c47e017a2cf4bbe9ed642ae7b76 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:55:28 +0200 Subject: [PATCH 19/23] 01a10b01 - Refuse another member's habit log before the period check. --- src/__tests__/routes/member-habits.test.ts | 22 ++++++++++++++++++++++ src/routes/member-habits.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index 9fed409d3..7b05fe875 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -880,6 +880,15 @@ describe('memberHabitRoutes', () => { }), ); await store.add(sampleHabit({ id: 'theirs', accountId: ALICE.id, role: 'basis' })); + await store.add( + sampleHabit({ + id: 'theirs-week', + accountId: ALICE.id, + role: 'basis', + cadence: 'weekly', + firstPeriod: '2026-09-28', + }), + ); const app = mount({ store, account: BASIS }); const partial = await post( app, @@ -946,6 +955,19 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(other.status).toBe(404); + const otherFuture = await post( + app, + { action: 'log', id: 'theirs', period: '2026-10-06', status: 'achieved' }, + AUTH, + ); + expect(otherFuture.status).toBe(404); + expect(await otherFuture.json()).toEqual({ error: 'Not found' }); + const otherTuesday = await post( + app, + { action: 'log', id: 'theirs-week', period: '2026-09-29', status: 'achieved' }, + AUTH, + ); + expect(otherTuesday.status).toBe(404); const unknown = await post( app, { action: 'log', id: 'missing', period: '2026-10-01', status: 'achieved' }, diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index a9e1e3edb..13168cf26 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -372,7 +372,7 @@ export function memberHabitRoutes(deps: { } const habits = await deps.store.listPublic(account.id, nowMs); const habit = habits.find((row) => row.id === body.id); - if (habit === undefined) { + if (habit === undefined || habit.accountId !== account.id) { return c.json({ error: 'Not found' }, 404); } if (!isRealYmd(body.period)) { From 81192fb7ce83eb61e8dc5af0898dcf084a457828 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:08:22 +0200 Subject: [PATCH 20/23] 01a10b01 - Cover a habit log the store no longer has. --- src/__tests__/routes/member-habits.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index 7b05fe875..e5078771d 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -977,6 +977,19 @@ describe('memberHabitRoutes', () => { expect(await unknown.json()).toEqual({ error: 'Not found' }); }); + it('log of an owned habit the store then misses is not found', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h-log', accountId: BASIS.id, role: 'basis' })); + store.log = async () => 'missing'; + const res = await post( + mount({ store, account: BASIS }), + { action: 'log', id: 'h-log', period: '2026-10-05', status: 'achieved' }, + AUTH, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + it('comment rejects blank text, text over 2000, a missing habit, and a null author name', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'h-comment', accountId: ALICE.id, role: 'basis' })); From 67bbacb6777fa9bebe8d653f9196b9d6b4fef505 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:18:15 +0200 Subject: [PATCH 21/23] 01a10b01 - Reject another member's habit log before the period shape. --- src/__tests__/routes/member-habits.test.ts | 7 +++++++ src/routes/member-habits.ts | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index e5078771d..a2f803a11 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -968,6 +968,13 @@ describe('memberHabitRoutes', () => { AUTH, ); expect(otherTuesday.status).toBe(404); + const otherNope = await post( + app, + { action: 'log', id: 'theirs', period: 'nope', status: 'achieved' }, + AUTH, + ); + expect(otherNope.status).toBe(404); + expect(await otherNope.json()).toEqual({ error: 'Not found' }); const unknown = await post( app, { action: 'log', id: 'missing', period: '2026-10-01', status: 'achieved' }, diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 13168cf26..ac32b3224 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -367,14 +367,14 @@ export function memberHabitRoutes(deps: { if (!isHabitStatus(body.status)) { return c.json({ error: 'Invalid status' }, 400); } - if (!PERIOD_RE.test(body.period)) { - return c.json({ error: 'Invalid period' }, 400); - } const habits = await deps.store.listPublic(account.id, nowMs); const habit = habits.find((row) => row.id === body.id); if (habit === undefined || habit.accountId !== account.id) { return c.json({ error: 'Not found' }, 404); } + if (!PERIOD_RE.test(body.period)) { + return c.json({ error: 'Invalid period' }, 400); + } if (!isRealYmd(body.period)) { return c.json({ error: 'Invalid period' }, 400); } From 6f701152cfe13c338324e99bbb65d0b18f403167 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:04:28 +0200 Subject: [PATCH 22/23] 01a10b01 - Refuse a habit edit after the archived period. --- docs/handbook/endpoints.md | 2 +- docs/handbook/functions.md | 2 +- integration/sql-driver.test.ts | 21 ++++++- src/__tests__/lib/member-habit-store.test.ts | 63 ++++++++++++++++---- src/__tests__/routes/member-habits.test.ts | 47 +++++++++++++++ src/lib/member-habit-store.ts | 58 ++++++++++++------ src/routes/member-habits.ts | 11 +++- 7 files changed, 168 insertions(+), 36 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 31583ffb3..11c48c015 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1160,7 +1160,7 @@ Operator inspection of external Nostr identities that have earned visibility or ## Endpoint: POST /habits -- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. +- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. An edit whose current period is after `lastPeriod` is 409 `{ error: 'Period is closed' }` and does not change the stored wording. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. - **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment, including a comment id that is not a UUID on `deleteComment` and `invoice`. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }` when the mint fails or the BOLT11 does not decode to exactly `amountSats * 1000` millisatoshis. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 7b903dbeb..f8ca72fa3 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3319,7 +3319,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint, or a BOLT11 that does not decode to exactly `amountSats * 1000` millisatoshis, is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint, or a BOLT11 that does not decode to exactly `amountSats * 1000` millisatoshis, is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. An edit whose current period is after `lastPeriod` is 409 `{ error: 'Period is closed' }` and does not change the stored wording. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. diff --git a/integration/sql-driver.test.ts b/integration/sql-driver.test.ts index 71a4cc437..51d1095fb 100644 --- a/integration/sql-driver.test.ts +++ b/integration/sql-driver.test.ts @@ -230,6 +230,25 @@ VALUES ($1, 'basis', false, false, $2)`, firstPeriod: '2026-10-01', lastPeriod: null, }); + expect( + await store.edit( + habitId, + accountId, + { name: 'Run', description: 'Out', notes: 'n' }, + '2026-10-05', + ), + ).toBe('ok'); + expect(await store.archive(habitId, accountId, '2026-10-05')).toBe('ok'); + expect( + await store.edit( + habitId, + accountId, + { name: 'Later', description: 'Out', notes: 'n' }, + '2026-10-06', + ), + ).toBe('closed'); + const kept = await store.listPublic(accountId, Date.parse('2026-10-07T04:00:00.000Z')); + expect(kept.find((row) => row.id === habitId)?.name).toBe('Run'); await client.execute( `INSERT INTO member_habit_revision (habit_id, period, name, description) VALUES ($1, '2026-10-02', $2, 'ok')`, @@ -261,7 +280,7 @@ VALUES ($1, '2026-10-04', 'ok', $2)`, expect(sqlState(tooLongDescription)).toBe('23514'); expect(await store.findComment('nope')).toBeNull(); - expect(await store.deleteComment('nope')).toBe(false); + expect(await store.deleteComment('nope', 1)).toBe(false); expect(await store.findComment(crypto.randomUUID())).toBeNull(); } finally { if (ready) { diff --git a/src/__tests__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts index fff11c88a..22c954c58 100644 --- a/src/__tests__/lib/member-habit-store.test.ts +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -131,12 +131,16 @@ function scriptedSql(initial: { habits: Row[]; revisions: Row[]; logs: Row[]; co (row) => row['id'] === params[3] && row['account_id'] === params[4], ); if (found === undefined) { - return { rows: [] }; + return { rows: [{ status: 'missing' }] }; + } + const period = params[5]; + const last = found['last_period']; + if (typeof last === 'string' && typeof period === 'string' && period > last) { + return { rows: [{ status: 'closed' }] }; } found['name'] = params[0]; found['description'] = params[1]; found['notes'] = params[2]; - const period = params[5]; const existing = state.revisions.find( (row) => row['habit_id'] === params[3] && row['period'] === period, ); @@ -151,7 +155,7 @@ function scriptedSql(initial: { habits: Row[]; revisions: Row[]; logs: Row[]; co existing['name'] = params[0]; existing['description'] = params[1]; } - return { rows: [{ habit_id: params[3] }] }; + return { rows: [{ status: 'ok' }] }; } if (text.includes('INSERT INTO member_habit (')) { return { rows: [] }; @@ -409,6 +413,33 @@ describe('InMemoryMemberHabitStore', () => { expect(revised[0]?.notes).toBe('n4'); }); + it('refuses an edit after the archived period and keeps the wording', async () => { + const store = new InMemoryMemberHabitStore(); + const habit = sampleHabit(); + await store.add(habit); + expect(await store.archive(habit.id, habit.accountId, '2026-10-04')).toBe('ok'); + expect( + await store.edit( + habit.id, + habit.accountId, + { name: 'Later', description: 'x', notes: 'y' }, + '2026-10-05', + ), + ).toBe('closed'); + const view = await store.listPublic(habit.accountId, nowMs); + expect(view[0]?.name).toBe('Walk'); + expect(view[0]?.notes).toBe('keep a secret'); + expect( + await store.edit( + habit.id, + habit.accountId, + { name: 'Same', description: '', notes: '' }, + '2026-10-04', + ), + ).toBe('ok'); + expect((await store.listPublic(habit.accountId, nowMs))[0]?.name).toBe('Same'); + }); + it('archive and log of an unknown id or another owner are missing', async () => { const store = new InMemoryMemberHabitStore(); const habit = sampleHabit(); @@ -445,12 +476,12 @@ describe('InMemoryMemberHabitStore', () => { const habit = sampleHabit(); await store.add(habit); expect(await store.findComment('missing')).toBeNull(); - expect(await store.deleteComment('missing')).toBe(false); + expect(await store.deleteComment('missing', 1)).toBe(false); await store.comment(comment({ id: 'c-live', habitId: habit.id })); expect((await store.findComment('c-live'))?.text).toBe('nice'); - expect(await store.deleteComment('c-live')).toBe(true); + expect(await store.deleteComment('c-live', 1)).toBe(true); expect(await store.findComment('c-live')).toBeNull(); - expect(await store.deleteComment('c-live')).toBe(false); + expect(await store.deleteComment('c-live', 1)).toBe(false); }); it('stores and clears a lightning address', async () => { @@ -527,7 +558,7 @@ describe('PostgresMemberHabitStore', () => { }; const store = new PostgresMemberHabitStore(sql, addressesOf(null)); expect(await store.findComment('nope')).toBeNull(); - expect(await store.deleteComment('nope')).toBe(false); + expect(await store.deleteComment('nope', 1)).toBe(false); const failing = { query: async (): Promise<{ rows: Record[] }> => { throw other; @@ -535,7 +566,7 @@ describe('PostgresMemberHabitStore', () => { }; const broken = new PostgresMemberHabitStore(failing, addressesOf(null)); await expect(broken.findComment('nope')).rejects.toThrow('connection refused'); - await expect(broken.deleteComment('nope')).rejects.toThrow('connection refused'); + await expect(broken.deleteComment('nope', 1)).rejects.toThrow('connection refused'); }); it('edits the wording and the period revision in one statement', async () => { @@ -558,7 +589,7 @@ describe('PostgresMemberHabitStore', () => { ): Promise<{ rows: Record[] }> => { calls.push({ text, params }); if (text.includes('RETURNING habit_id') && text.includes('account_id = $5')) { - return { rows: [{ habit_id: habit.id }] }; + return { rows: [{ status: 'ok' }] }; } return { rows: [] }; }, @@ -674,6 +705,14 @@ describe('PostgresMemberHabitStore', () => { expect(await store.archive('missing', accountId, '2026-10-05')).toBe('missing'); expect(await store.archive('ended', accountId, '2026-10-05')).toBe('ok'); expect(await store.archive(id, accountId, '2026-10-05')).toBe('ok'); + expect( + await store.edit( + id, + accountId, + { name: 'Later', description: 'x', notes: 'y' }, + '2026-10-06', + ), + ).toBe('closed'); expect(await store.log('missing', accountId, '2026-10-01', 'achieved')).toBe('missing'); expect(await store.log(id, accountId, '2026-09-01', 'achieved')).toBe('closed'); expect(await store.log('ended', accountId, '2026-10-08', 'missed')).toBe('closed'); @@ -706,9 +745,9 @@ describe('PostgresMemberHabitStore', () => { createdAt: 3, deletedAt: null, }); - expect(await store.deleteComment('missing')).toBe(false); - expect(await store.deleteComment('c-new')).toBe(true); - expect(await store.deleteComment('c-new')).toBe(false); + expect(await store.deleteComment('missing', 1)).toBe(false); + expect(await store.deleteComment('c-new', 1)).toBe(true); + expect(await store.deleteComment('c-new', 1)).toBe(false); expect(await store.lightning(accountId)).toBe('ada@wallet.example'); await store.setLightning(accountId, null); expect(await store.lightning(accountId)).toBeNull(); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index a2f803a11..44aa88263 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -857,6 +857,53 @@ describe('memberHabitRoutes', () => { expect(await longName.json()).toEqual({ error: 'Invalid name' }); }); + it('edit on the archive period still saves', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'mine', accountId: BASIS.id, role: 'basis' })); + const app = mount({ store, account: BASIS }); + expect((await post(app, { action: 'archive', id: 'mine' }, AUTH)).status).toBe(200); + const sameDay = await post( + app, + { action: 'edit', id: 'mine', name: 'Run', description: '', notes: '' }, + AUTH, + ); + expect(sameDay.status).toBe(200); + const listed = await app.request('/', { headers: AUTH }); + const body = (await listed.json()) as { habits: Array<{ name: string }> }; + expect(body.habits[0]?.name).toBe('Run'); + }); + + it('edit after the archived period is closed and does not change the wording', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'mine', accountId: BASIS.id, role: 'basis' })); + expect(await store.archive('mine', BASIS.id, '2026-10-04')).toBe('ok'); + const app = mount({ store, account: BASIS }); + const closed = await post( + app, + { action: 'edit', id: 'mine', name: 'Later', description: 'Nope', notes: 'x' }, + AUTH, + ); + expect(closed.status).toBe(409); + expect(await closed.json()).toEqual({ error: 'Period is closed' }); + const listed = await app.request('/', { headers: AUTH }); + const body = (await listed.json()) as { habits: Array<{ name: string; notes?: string }> }; + expect(body.habits[0]?.name).toBe('Walk'); + expect(body.habits[0]?.notes).toBe('secret'); + }); + + it('edit the store then closes is closed', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'mine', accountId: BASIS.id, role: 'basis' })); + store.edit = async () => 'closed'; + const res = await post( + mount({ store, account: BASIS }), + { action: 'edit', id: 'mine', name: 'Run', description: '', notes: '' }, + AUTH, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Period is closed' }); + }); + it('archive of an unknown id is 404 and archive of another person is 404', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'theirs', accountId: ALICE.id, role: 'basis' })); diff --git a/src/lib/member-habit-store.ts b/src/lib/member-habit-store.ts index ba7b2889a..c127e2458 100644 --- a/src/lib/member-habit-store.ts +++ b/src/lib/member-habit-store.ts @@ -107,7 +107,7 @@ export interface MemberHabitStore { accountId: string, patch: { name: string; description: string; notes: string }, atPeriod: string, - ): Promise<'ok' | 'missing'>; + ): Promise<'ok' | 'missing' | 'closed'>; archive(id: string, accountId: string, lastPeriod: string): Promise<'ok' | 'missing'>; log( habitId: string, @@ -118,7 +118,7 @@ export interface MemberHabitStore { listPublic(viewerAccountId: string | null, nowMs: number): Promise; findComment(id: string): Promise; comment(row: MemberHabitComment): Promise; - deleteComment(id: string): Promise; + deleteComment(id: string, deletedAt: number): Promise; setLightning(accountId: string, address: string | null): Promise; lightning(accountId: string): Promise; } @@ -493,11 +493,14 @@ export class InMemoryMemberHabitStore implements MemberHabitStore { accountId: string, patch: { name: string; description: string; notes: string }, atPeriod: string, - ): Promise<'ok' | 'missing'> { + ): Promise<'ok' | 'missing' | 'closed'> { const habit = this.habits.get(id); if (habit === undefined || habit.accountId !== accountId) { return 'missing'; } + if (habit.lastPeriod !== null && comparePeriod(atPeriod, habit.lastPeriod) > 0) { + return 'closed'; + } habit.notes = patch.notes; if (habit.name !== patch.name || habit.description !== patch.description) { habit.name = patch.name; @@ -597,12 +600,12 @@ export class InMemoryMemberHabitStore implements MemberHabitStore { this.comments.set(row.id, cloneComment(row)); } - async deleteComment(id: string): Promise { + async deleteComment(id: string, deletedAt: number): Promise { const row = this.comments.get(id); if (row === undefined || row.deletedAt !== null) { return false; } - row.deletedAt = Date.now(); + row.deletedAt = deletedAt; return true; } @@ -668,25 +671,42 @@ export class PostgresMemberHabitStore implements MemberHabitStore { accountId: string, patch: { name: string; description: string; notes: string }, atPeriod: string, - ): Promise<'ok' | 'missing'> { + ): Promise<'ok' | 'missing' | 'closed'> { const written = await this.sql.query( - `WITH updated AS ( - UPDATE member_habit + `WITH target AS ( + SELECT id, last_period FROM member_habit WHERE id = $4 AND account_id = $5 + ), + updated AS ( + UPDATE member_habit AS habit SET name = $1, description = $2, notes = $3 - WHERE id = $4 AND account_id = $5 - RETURNING id + FROM target + WHERE habit.id = target.id + AND (target.last_period IS NULL OR $6 <= target.last_period) + RETURNING habit.id + ), + revision AS ( + INSERT INTO member_habit_revision (habit_id, period, name, description) + SELECT id, $6, $1, $2 FROM updated + ON CONFLICT (habit_id, period) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description + RETURNING habit_id ) - INSERT INTO member_habit_revision (habit_id, period, name, description) - SELECT id, $6, $1, $2 FROM updated - ON CONFLICT (habit_id, period) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description - RETURNING habit_id`, + SELECT CASE + WHEN NOT EXISTS (SELECT 1 FROM target) THEN 'missing' + WHEN NOT EXISTS (SELECT 1 FROM revision) THEN 'closed' + ELSE 'ok' + END AS status`, [patch.name, patch.description, patch.notes, id, accountId, atPeriod], ); - if (firstRow(written.rows) === null) { + const row = firstRow(written.rows); + if (row === null) { return 'missing'; } + const status = stringColumn(row, 'status'); + if (status === 'closed' || status === 'missing') { + return status; + } return 'ok'; } @@ -824,7 +844,7 @@ export class PostgresMemberHabitStore implements MemberHabitStore { ); } - async deleteComment(id: string): Promise { + async deleteComment(id: string, deletedAt: number): Promise { let result: { rows: Record[] }; try { result = await this.sql.query(`SELECT deleted_at FROM member_habit_comment WHERE id = $1`, [ @@ -845,7 +865,7 @@ export class PostgresMemberHabitStore implements MemberHabitStore { } await this.sql.query( `UPDATE member_habit_comment SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL`, - [Date.now(), id], + [deletedAt, id], ); return true; } diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index ac32b3224..51291952e 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -334,15 +334,22 @@ export function memberHabitRoutes(deps: { if (habit === undefined) { return c.json({ error: 'Not found' }, 404); } + const atPeriod = periodKey(nowMs, habit.cadence, habit.timeZone); + if (habit.lastPeriod !== null && comparePeriod(atPeriod, habit.lastPeriod) > 0) { + return c.json({ error: 'Period is closed' }, 409); + } const result = await deps.store.edit( body.id, account.id, { name: body.name.trim(), description: body.description, notes: body.notes }, - periodKey(nowMs, habit.cadence, habit.timeZone), + atPeriod, ); if (result === 'missing') { return c.json({ error: 'Not found' }, 404); } + if (result === 'closed') { + return c.json({ error: 'Period is closed' }, 409); + } return c.json({ ok: true }, 200); } @@ -441,7 +448,7 @@ export function memberHabitRoutes(deps: { if (comment === null) { return c.json({ error: 'Not found' }, 404); } - const deleted = await deps.store.deleteComment(body.id); + const deleted = await deps.store.deleteComment(body.id, nowMs); if (!deleted) { return c.json({ error: 'Not found' }, 404); } From b1d68535777393483351c11d66dd4e9b085a7f3a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:20:33 +0200 Subject: [PATCH 23/23] 01a10b01 - Return not found before a closed period on someone else's habit. --- docs/handbook/endpoints.md | 2 +- docs/handbook/functions.md | 2 +- src/__tests__/routes/member-habits.test.ts | 29 ++++++++++++++++++++++ src/routes/member-habits.ts | 2 +- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 11c48c015..b92176292 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -1160,7 +1160,7 @@ Operator inspection of external Nostr identities that have earned visibility or ## Endpoint: POST /habits -- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. An edit whose current period is after `lastPeriod` is 409 `{ error: 'Period is closed' }` and does not change the stored wording. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. +- **Purpose:** One strict JSON action: `add`, `edit`, `archive`, `log`, `comment`, `deleteComment`, `invoice`. Bearer required. `add` requires `Time-Zone`. `comment`, `deleteComment`, and `invoice` follow the same Sunday rest as other public writing and as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names an IANA zone that is Sunday. The invoice check is before the amount check. A missing, blank, or invalid zone does not refuse. Rating, add, edit, and archive are not that refusal. An edit whose current period is after `lastPeriod` is 409 `{ error: 'Period is closed' }` and does not change the stored wording. An edit of another member's habit is 404, including when that period is already closed. `invoice` returns `{ pr, amountSats }` and does not pay. Comments are not forum posts. - **Inputs:** Bearer session. Body is one action object. `add`, `comment`, `deleteComment`, and `invoice` also send `Time-Zone`. - **Returns / side effects:** 201 `{ ok: true, id }` for add. 201 `{ ok: true }` for comment, with no id. 200 `{ ok: true }` for edit, archive, log, and deleteComment. 200 `{ pr, amountSats }` for invoice. 400 invalid body, name, description, notes, time zone, status, period, or comment, self-donation, or `{ error: 'Expected a JSON body with an integer "amountSats"' }` when `amountSats` is missing, not a number, not a positive integer, or above 10_000_000. 401 missing bearer. 403 `{ error: 'SUNDAY_REST' }` for `comment`, `deleteComment`, and `invoice` on Sunday, or `{ error: 'Forbidden' }` for `deleteComment` below initiator rank. 404 missing habit or comment, including a comment id that is not a UUID on `deleteComment` and `invoice`. 409 `{ error: 'Period is closed' }` or `{ error: "The author's wallet cannot receive this Bitcoin payment" }`. 429 `{ error: 'Too many payments' }`. 502 `{ error: 'Lightning Address could not be resolved' }` when the mint fails or the BOLT11 does not decode to exactly `amountSats * 1000` millisatoshis. 503 `{ error: 'Habits are unavailable' }` when the store throws (`habits.failed`). - **Used by:** the habit tracker page. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index f8ca72fa3..b2ac9eb4a 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -3319,7 +3319,7 @@ Builds the operator-only external-pubkey inspection route. ## Function: memberHabitRoutes -- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint, or a BOLT11 that does not decode to exactly `amountSats * 1000` millisatoshis, is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. An edit whose current period is after `lastPeriod` is 409 `{ error: 'Period is closed' }` and does not change the stored wording. +- **Purpose:** Hono routes `GET /habits` and `POST /habits` for member habits, comments, and a Lightning invoice. `invoice` uses the same Sunday rest as `POST /messages/:id/invoice`: 403 `{ error: 'SUNDAY_REST' }` when `Time-Zone` names a Sunday, before the amount check. A missing, blank, or invalid zone does not refuse. Success is `{ pr, amountSats }`. A missing, non-numeric, non-integer, or over-cap `amountSats` is 400 `{ error: 'Expected a JSON body with an integer "amountSats"' }` (ceiling 10_000_000). The limiter answer is 429 `{ error: 'Too many payments' }`. A failed mint, or a BOLT11 that does not decode to exactly `amountSats * 1000` millisatoshis, is 502 `{ error: 'Lightning Address could not be resolved' }`. A comment id that is not a UUID is 404 `{ error: 'Not found' }` on `deleteComment` and `invoice`, not 503. Does not pay. Add, edit, archive, and log stay open on Sunday. An edit whose current period is after `lastPeriod` is 409 `{ error: 'Period is closed' }` and does not change the stored wording. An edit of another member's habit is 404, including when that period is already closed. - **Inputs:** `{ store, authStore, now, fetchImpl }`. - **Returns / side effects:** Hono app. Writes through `MemberHabitStore`. Logs only `{ event: 'habits.failed', ts }` on failure. - **Used by:** `createApp`. diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts index 44aa88263..05abd26c1 100644 --- a/src/__tests__/routes/member-habits.test.ts +++ b/src/__tests__/routes/member-habits.test.ts @@ -891,6 +891,35 @@ describe('memberHabitRoutes', () => { expect(body.habits[0]?.notes).toBe('secret'); }); + it('edit of another member archived habit is not found', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'theirs', accountId: ALICE.id, role: 'basis' })); + expect(await store.archive('theirs', ALICE.id, '2026-10-04')).toBe('ok'); + const closed = await post( + mount({ store, account: BASIS }), + { action: 'edit', id: 'theirs', name: 'Later', description: 'Nope', notes: 'x' }, + AUTH, + ); + expect(closed.status).toBe(404); + expect(await closed.json()).toEqual({ error: 'Not found' }); + const listed = await mount({ store, account: BASIS }).request('/', { headers: AUTH }); + const body = (await listed.json()) as { habits: Array<{ id: string; name: string }> }; + expect(body.habits.find((row) => row.id === 'theirs')?.name).toBe('Walk'); + }); + + it('edit the store then misses is not found', async () => { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'mine', accountId: BASIS.id, role: 'basis' })); + store.edit = async () => 'missing'; + const res = await post( + mount({ store, account: BASIS }), + { action: 'edit', id: 'mine', name: 'Run', description: '', notes: '' }, + AUTH, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + it('edit the store then closes is closed', async () => { const store = new InMemoryMemberHabitStore(); await store.add(sampleHabit({ id: 'mine', accountId: BASIS.id, role: 'basis' })); diff --git a/src/routes/member-habits.ts b/src/routes/member-habits.ts index 51291952e..4916da8bf 100644 --- a/src/routes/member-habits.ts +++ b/src/routes/member-habits.ts @@ -331,7 +331,7 @@ export function memberHabitRoutes(deps: { } const habits = await deps.store.listPublic(account.id, nowMs); const habit = habits.find((row) => row.id === body.id); - if (habit === undefined) { + if (habit === undefined || habit.accountId !== account.id) { return c.json({ error: 'Not found' }, 404); } const atPeriod = periodKey(nowMs, habit.cadence, habit.timeZone);