diff --git a/SPEC.md b/SPEC.md index 61516279f..cdbb1d1d8 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 }` | @@ -5414,6 +5416,76 @@ 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\"" }`. +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 +{ "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, 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 +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 adf37a475..b92176292 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 @@ -1149,3 +1149,18 @@ 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. 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. 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 28b4ee011..b2ac9eb4a 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 @@ -1215,7 +1214,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`, `deleteComment`, and `invoice`. ## Function: isSundayInZone @@ -3317,3 +3316,87 @@ 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. `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`. + +## 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. + +## 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: migrateMemberHabitSchema + +- **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 five 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. 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 new file mode 100644 index 000000000..c52198a6a --- /dev/null +++ b/docs/schema/member-habit.sql @@ -0,0 +1,67 @@ +-- Mirrors MEMBER_HABIT_SCHEMA_SQL. +CREATE TABLE IF NOT EXISTS member_habit ( + id uuid PRIMARY KEY, + 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), + 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 REFERENCES member_habit (id), + period 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 ( + 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 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, + 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/e2e/functions.spec.ts b/e2e/functions.spec.ts index ffb49a0c5..80c8902be 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -3064,3 +3064,67 @@ 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 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 }) => { + 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: 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/integration/sql-driver.test.ts b/integration/sql-driver.test.ts index 3f634e74c..51d1095fb 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,153 @@ 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); + ready = true; + // 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 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) +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, + }); + 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')`, + [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', 1)).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/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/boot-stores.test.ts b/src/__tests__/lib/boot-stores.test.ts index 5caf7a890..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'; @@ -8,6 +10,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 +71,7 @@ describe('openBootStores', () => { translationStore, conversationTranslationStore, contactStore, + memberHabitStore, posStore, conversationStore, notificationStore, @@ -86,6 +90,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 +120,7 @@ describe('openBootStores', () => { translationStore, conversationTranslationStore, contactStore, + memberHabitStore, posStore, conversationStore, notificationStore, @@ -132,6 +138,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 +546,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); @@ -591,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__/lib/member-habit-store.test.ts b/src/__tests__/lib/member-habit-store.test.ts new file mode 100644 index 000000000..22c954c58 --- /dev/null +++ b/src/__tests__/lib/member-habit-store.test.ts @@ -0,0 +1,889 @@ +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('UPDATE member_habit')) { + const found = state.habits.find( + (row) => row['id'] === params[3] && row['account_id'] === params[4], + ); + if (found === undefined) { + 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 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: [{ status: 'ok' }] }; + } + if (text.includes('INSERT INTO member_habit (')) { + return { rows: [] }; + } + 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('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(); + 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', 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', 1)).toBe(true); + expect(await store.findComment('c-live')).toBeNull(); + expect(await store.deleteComment('c-live', 1)).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(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\)/, + ); + 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()); + await store.listPublic(null, nowMs); + + 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('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', 1)).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', 1)).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(); + 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, + params: unknown[] = [], + ): Promise<{ rows: Record[] }> => { + calls.push({ text, params }); + if (text.includes('RETURNING habit_id') && text.includes('account_id = $5')) { + return { rows: [{ status: 'ok' }] }; + } + return { rows: [] }; + }, + }; + const store = new PostgresMemberHabitStore(sql, addressesOf(null)); + 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, + 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, 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 () => { + 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('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'); + 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'); + 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?.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({ + id: 'c-new', + habitId: id, + accountId: 'bob', + name: 'Bob', + text: 'new', + week: '2026-09-28', + createdAt: 3, + deletedAt: null, + }); + 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(); + 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..610bf68fc --- /dev/null +++ b/src/__tests__/lib/member-habit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { + 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('nextPeriod rejects', () => { + it('throws when the key is not YYYY-MM-DD', () => { + expect(() => nextPeriod('bad', 'daily')).toThrow(/invalid YYYY-MM-DD/); + }); +}); diff --git a/src/__tests__/routes/member-habits.test.ts b/src/__tests__/routes/member-habits.test.ts new file mode 100644 index 000000000..05abd26c1 --- /dev/null +++ b/src/__tests__/routes/member-habits.test.ts @@ -0,0 +1,1431 @@ +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, 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'); +const AUTH = { Authorization: 'Bearer tok' }; + +type AccountView = { + id: string; + role: AccountRole; + 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 }); + +/** 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'); +}; + +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: AccountRole; 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 { + 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); + } + }); + + 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 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: () => SUNDAY_ZURICH }), + { action: 'comment', habitId: 'h-comment', text: 'nice' }, + { ...AUTH, 'Time-Zone': 'Europe/Zurich' }, + ); + expect(res.status).toBe(403); + 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 () => { + 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: '11111111-1111-4111-8111-111111111111', + 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: '11111111-1111-4111-8111-111111111111' }, + AUTH, + ); + expect(res.status).toBe(403); + 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: '11111111-1111-4111-8111-111111111111', + 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: '11111111-1111-4111-8111-111111111111' }, + { ...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' })); + await store.comment({ + id: '11111111-1111-4111-8111-111111111111', + 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: '11111111-1111-4111-8111-111111111111' }, + 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: '22222222-2222-4222-8222-222222222222', + 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: '22222222-2222-4222-8222-222222222222', 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: '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, lightningAddress: null } }, + }), + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); + }); + + 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: '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: throwingFetch, + }), + { 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 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({ + 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: MATCHING_SATS, + }, + AUTH, + ); + expect(res.status).toBe(200); + 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 () => { + 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, + now: () => SUNDAY_ZURICH, + }), + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', 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: '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, + now: () => SUNDAY_ZURICH, + }), + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: MATCHING_SATS, + }, + AUTH, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: MATCHING_PR, amountSats: MATCHING_SATS }); + }); + + 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: '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, + now: () => SUNDAY_ZURICH, + }), + { + 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: MATCHING_PR, amountSats: MATCHING_SATS }); + }); + + 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 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 } }); + 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('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 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' })); + 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' })); + 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' })); + 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, + { 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 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' }, + 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 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 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' }, + AUTH, + ); + expect(unknown.status).toBe(404); + 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' })); + 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: AccountRole): Promise { + const store = new InMemoryMemberHabitStore(); + await store.add(sampleHabit({ id: 'h', accountId: 'owner', role: 'basis' })); + await store.comment({ + id: '11111111-1111-4111-8111-111111111111', + 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: '11111111-1111-4111-8111-111111111111' }, + 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' as AccountRole)).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); + 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: '44444444-4444-4444-8444-444444444444', + 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: '44444444-4444-4444-8444-444444444444' }, + 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: '33333333-3333-4333-8333-333333333333', + 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: '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: '33333333-3333-4333-8333-333333333333', amountSats: 1.5 }, + AUTH, + ); + expect(fraction.status).toBe(400); + const huge = await post( + app, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + 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 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: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(emptyWallet.status).toBe(409); + expect(await emptyWallet.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); + const stillNoWallet = await post( + app, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(stillNoWallet.status).toBe(409); + expect(await stillNoWallet.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); + const atCeiling = await post( + mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: { ...ALICE, lightningAddress: '' } }, + fetchImpl: successFetch, + }), + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: 10_000_000, + }, + AUTH, + ); + expect(atCeiling.status).toBe(409); + + const paying = mount({ + store, + account: BASIS, + accounts: { [ALICE.id]: ALICE }, + fetchImpl: successFetch, + }); + const first = await post( + paying, + { + action: 'invoice', + commentId: '33333333-3333-4333-8333-333333333333', + amountSats: MATCHING_SATS, + }, + AUTH, + ); + expect(first.status).toBe(200); + const second = await post( + paying, + { action: 'invoice', commentId: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(second.status).toBe(429); + expect(await second.json()).toEqual({ error: 'Too many payments' }); + + 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: '33333333-3333-4333-8333-333333333333', amountSats: 1 }, + AUTH, + ); + expect(failed.status).toBe(502); + 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: '33333333-3333-4333-8333-333333333333', 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: '33333333-3333-4333-8333-333333333333' }, + 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 () => { + 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/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/lib/boot-stores.ts b/src/lib/boot-stores.ts index 056a78212..f7d6b7684 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; /** @@ -182,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`, @@ -193,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 @@ -261,6 +274,7 @@ export async function openBootStores( conversationTranslationStore: undefined, nostrKek: undefined, contactStore: undefined, + memberHabitStore: undefined, posStore: new InMemoryPosStore(), apiLogStore: undefined, diagnosticStore: undefined, @@ -283,6 +297,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 +456,15 @@ 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, { + async get() { + return null; + }, + async set() { + return undefined; + }, + }); const posStore = new PostgresPosStore(sqlClient); const apiLogStore = new PostgresApiLogStore(sqlClient); const conversationStore = new PostgresConversationStore(sqlClient, { fetchImpl, fiatRates, now }); @@ -454,6 +483,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..c127e2458 --- /dev/null +++ b/src/lib/member-habit-store.ts @@ -0,0 +1,899 @@ +import { sqlState } from '@/lib/auth/sql'; +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' | 'closed'>; + 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, deletedAt: number): Promise; + setLightning(accountId: string, address: string | null): Promise; + lightning(accountId: string): Promise; +} + +/** + * 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 ( + id uuid PRIMARY KEY, + 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), + 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 REFERENCES member_habit (id), + period 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 ( + 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 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, + 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 $$;`, +]; + +/** + * 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) { + 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' | '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; + 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, deletedAt: number): Promise { + const row = this.comments.get(id); + if (row === undefined || row.deletedAt !== null) { + return false; + } + row.deletedAt = deletedAt; + 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 { + /** + * @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, + ) {} + + async add(habit: MemberHabit): Promise { + await this.sql.query( + `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, + habit.ownerName, + habit.role, + habit.name, + habit.description, + habit.notes, + habit.cadence, + habit.timeZone, + habit.firstPeriod, + habit.lastPeriod, + ], + ); + } + + async edit( + id: string, + accountId: string, + patch: { name: string; description: string; notes: string }, + atPeriod: string, + ): Promise<'ok' | 'missing' | 'closed'> { + const written = await this.sql.query( + `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 + 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 + ) + 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], + ); + const row = firstRow(written.rows); + if (row === null) { + return 'missing'; + } + const status = stringColumn(row, 'status'); + if (status === 'closed' || status === 'missing') { + return status; + } + 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 { + 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; + } + 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, deletedAt: number): Promise { + 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; + } + 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`, + [deletedAt, 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..326dfad54 --- /dev/null +++ b/src/lib/member-habit.ts @@ -0,0 +1,192 @@ +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. + * + * @param zone - IANA time zone name, or empty. + * @returns `true` when `Intl` accepts `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`. + * + * @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); + 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. + * + * @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); + 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`. + * + * @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') { + return dayKey(nowMs, timeZone); + } + return weekKey(nowMs, timeZone); +} + +/** + * 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') { + 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`. + * + * @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) { + 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. + * + * @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); + 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') }`. + * + * @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 new file mode 100644 index 000000000..4916da8bf --- /dev/null +++ b/src/routes/member-habits.ts @@ -0,0 +1,519 @@ +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 { 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 { + comparePeriod, + dayKey, + isValidTimeZone, + manilaReviewWeek, + periodKey, + 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}$/; +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({ + 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.unknown().optional(), + }) + .strict(); + +const postBody = z.discriminatedUnion('action', [ + addBody, + editBody, + archiveBody, + logBody, + commentBody, + deleteCommentBody, + invoiceBody, +]); + +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'; +} + +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; +} + +/** 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 (unicodeLength(trimmed) < 1 || unicodeLength(trimmed) > 80) { + return 'Invalid name'; + } + if (unicodeLength(description) > 2000) { + return 'Invalid description'; + } + if (unicodeLength(notes) > 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`. + * + * @param deps - Habit store, auth store, clock, and fetch. + * @returns The Hono app 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 }, + 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 || habit.accountId !== account.id) { + 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 }, + 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); + } + + 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); + } + 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); + } + 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); + } + } + 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') { + if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { + return c.json({ error: 'SUNDAY_REST' }, 403); + } + const text = body.text.trim(); + if (unicodeLength(text) < 1 || unicodeLength(text) > 2000) { + return c.json({ error: 'Invalid comment' }, 400); + } + 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 (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { + return c.json({ error: 'SUNDAY_REST' }, 403); + } + 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); + } + const deleted = await deps.store.deleteComment(body.id, nowMs); + if (!deleted) { + return c.json({ error: 'Not found' }, 404); + } + return c.json({ ok: true }, 200); + } + + if (isSundayRestHeader(nowMs, c.req.header('Time-Zone'))) { + return c.json({ error: 'SUNDAY_REST' }, 403); + } + const amountSats = body.amountSats; + if ( + 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); + } + 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); + } + if (comment.accountId === account.id) { + return c.json({ error: 'Cannot donate to yourself' }, 400); + } + const author = await deps.authStore.getAccount(comment.accountId); + if ( + author === undefined || + author.lightningAddress === null || + author.lightningAddress === '' + ) { + 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); + } + const address = author.lightningAddress; + const amountMsat = amountSats * 1000; + let invoice: { ok: true; pr: string } | { ok: false }; + try { + invoice = await requestGiftInvoice({ + address, + amountMsat, + fetchImpl: deps.fetchImpl, + }); + /* v8 ignore next 3 -- requestGiftInvoice returns ok:false instead of throwing */ + } catch { + return c.json({ error: 'Lightning Address could not be resolved' }, 502); + } + 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' })); + return c.json({ error: 'Habits are unavailable' }, 503); + } + }); + + return app; +} diff --git a/src/server.ts b/src/server.ts index a9843709c..0ed49ed10 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. @@ -351,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`, @@ -409,6 +417,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 +704,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',