diff --git a/docs/inbound-mail-effects.md b/docs/inbound-mail-effects.md index 90bd2e6d..ddd196c1 100644 --- a/docs/inbound-mail-effects.md +++ b/docs/inbound-mail-effects.md @@ -1,7 +1,7 @@ # Mail effects and the outbound send triad **Audience:** internal (engineering). Not served publicly. -**Last updated:** 2026-09-22 (sender authentication policy; unauthenticated token replies file as unaffiliated). +**Last updated:** 2026-09-23 (an attempt with no outcome yet counts as in flight; bounce bookkeeping and its completion marker). An inbound message that correlates to a mail thread can drive **public** effects: a report status transition, a public `report_timeline` row, a report-chat system message, and a push to the reporter. @@ -175,6 +175,34 @@ The exposure is bounded and one-sided: If `report_timeline` later gains a `meta` jsonb for another reason, the fix is to stamp the mail message id into the row and make the timeline/chat/notify steps no-op when that marker is already present. +## Bounces: bookkeeping and its completion marker + +A delivery status notification (`detectBounce` in `services/api/src/services/admin/inbound-bounce.ts`: +a `mailer-daemon@` or `postmaster@` sender, a `report-type=delivery-status` content type, or an +`X-Failed-Recipients` header) is stored in the Inbox and then runs `handleBounce`. It acts only when the +DSN names both a failed recipient and an original Message-ID, the sender passes +`isPlausibleBounceSender`, and the Message-ID maps to a thread that actually sent to that recipient. It +then, in order: + +1. sets the thread status to `bounced`; +2. stamps `bounced_at` on the jurisdiction's `jurisdiction_contacts` rows for that address and enqueues + `jurisdiction.discovery` for the geoid (when a geoid is known, from the thread or the contact); +3. writes the `bounced` mail event, with meta `{ failedRecipient, originalMessageId }`. + +The `bounced` event is written **last** because it is the completion marker. When a step throws, the +object stays in `inbound/pending/` and the next sweep replays it; a replay runs `handleBounce` again, +which first asks `hasBounceEvent` (same thread, `type = 'bounced'`, same `originalMessageId`, same +`failedRecipient` ignoring case). With no marker it repeats the steps, all of which are safe to repeat. +With the marker it does nothing, so a duplicate delivery of a finished DSN cannot force a thread back to +`bounced` after an operator has changed its status. + +A legacy `contact_emails` address has no `bounced_at` column, so the event's `failedRecipient` is what +marks it unusable: `legacyContactEmailUsable` skips an address with a `bounced` event on that +jurisdiction's threads newer than `contact_updated_at`, or with a bounced per-category row for the same +address. Discovery, the jurisdiction health probe behind `resolveForPoint` and the outreach digest all +apply it, and all ignore a per-category contact whose `bounced_at` is set. Saving the contact again moves +`contact_updated_at` past the old event, which makes a corrected address usable again. + --- @@ -188,7 +216,7 @@ service and the admin report repository import. |---|---| | **Send deadline** (`outboundSendDeadlineMs`) | Total wall clock for one delivery: a phase budget (`OCI_EMAIL_SMTP_TIMEOUT_MS × 3`, covering connect + greeting + socket, all of which are INACTIVITY timeouts and so bound nothing on a trickling relay) plus the payload's time at a floor throughput (`OUTBOUND_SEND_MIN_THROUGHPUT_BPS`, default 256 KiB/s). Clamped to `2^31 - 1` so it can never overflow `setTimeout`, which Node silently clamps to 1 ms. | | **In-flight window** (`ROUTE_DEADLINE_INFLIGHT_SECONDS`, 900 s) | How long a `failed` event whose meta says `reason: "deadline"` counts as *still in flight* rather than as a delivery failure. | -| **Stale claim** (`ROUTE_CLAIM_STALE_SECONDS`, 900 s) | How long an outbound row with NO event at all counts as in flight before it is treated as a crashed claim and becomes re-routable. | +| **Stale claim** (`ROUTE_CLAIM_STALE_SECONDS`, 900 s) | How long an outbound row with no `sent` and no `failed` event counts as in flight (reply, resend and re-route are refused) before it is treated as a crashed claim and becomes re-routable. | ## Why the deadline is not an abort @@ -213,7 +241,18 @@ non-delivery**: `mail_events.message_id`), never thread-wide. Thread-wide, any earlier reason-less `failed` (attempt 1 connect timeout, say) satisfied the predicate and killed the in-flight guard for every later attempt. The verdict is: a hard `failed` on the newest attempt → failed; a `deadline` failure inside the window → -in flight; any other `failed` → failed; no event and older than the stale window → crashed claim. +in flight; any other `failed` → failed; no event and younger than the stale window → in flight; no event +and older than the stale window → crashed claim. + +`sendInFlightExpr` (`services/api/src/services/admin/outbound-send-sql.ts`) is the in-flight half of that +verdict, read from the same newest attempt: no `sent` event, and either a `deadline` failure inside the +window or no `failed` event at all while the outbound row is younger than the stale window. The outbound +row is inserted before transmission starts, so an attempt with no outcome yet is a send still on the +wire, or one whose process died mid-send; the two cannot be told apart until the stale window passes. +Until then reply and resend (`MailService`, through `hasSendInFlight`) and re-route (`assertRoutable`, +through the report's `send_in_flight`) answer 409 `SEND_IN_FLIGHT_CONFLICT`. Once a `sent` or `failed` +event lands, or the row passes the stale window, the guard lifts and `sendFailedExpr` decides whether the +attempt reads as failed. ## Why misconfiguration fails closed diff --git a/docs/media-pipeline-hardening.md b/docs/media-pipeline-hardening.md index e89cacc3..517017e0 100644 --- a/docs/media-pipeline-hardening.md +++ b/docs/media-pipeline-hardening.md @@ -31,8 +31,9 @@ serving an object the uploader can still overwrite with unchecked bytes after the asset is already published on a report. The worker also binds the row to the exact object version it inspected: the API -records the upload's ETag at finalize and passes it in the job payload, the -worker compares it to what it downloaded, and it re-HEADs the upload key +records the upload's ETag at finalize (on the row as `media_assets.upload_etag`, +migration 0184, so a stuck-sweep requeue carries it too) and passes it in the job +payload, the worker compares it to what it downloaded, and it re-HEADs the upload key immediately before publishing. A mismatch (or a vanished object) is a `rejected` outcome — never a throw, per the pipeline's never-throw invariant. diff --git a/docs/report-takedown.md b/docs/report-takedown.md index fad03d6f..1490e701 100644 --- a/docs/report-takedown.md +++ b/docs/report-takedown.md @@ -42,11 +42,12 @@ spam the queue. ## Offline / no-DB behavior -`reportOwnedBy` is DB-gated and fail-safe: with no `DATABASE_URL` (all-fakes boot) -or on any query error it returns `false`, so the route degrades to the ordinary -user-report path (the request is still filed, just not flagged as an owner -takedown). The audit write only runs when ownership was confirmed, which implies -a real DB is present. +`reportOwnedBy` is DB-gated: with no `DATABASE_URL` (all-fakes boot) it returns +`false`, so the route degrades to the ordinary user-report path (the request is +still filed, just not flagged as an owner takedown). A query error is not caught: +it propagates and the request fails with 500 (not filed), so a transient DB error +never downgrades an owner takedown to a third-party report. The audit write only +runs when ownership was confirmed, which implies a real DB is present. ## What this does NOT do (product/counsel DECISIONS) diff --git a/docs/retention-cleanup.md b/docs/retention-cleanup.md index e4ef46b7..371f6211 100644 --- a/docs/retention-cleanup.md +++ b/docs/retention-cleanup.md @@ -1,7 +1,7 @@ # Retention cleanup jobs (civfix-backend) **Audience:** internal (engineering + ops). Not served publicly. -**Last updated:** 2026-09-02 (audit-fix pass: inbound_emails TTL + doc-drift corrections). +**Last updated:** 2026-09-23 (host export reaper: abandoned runs, failed-run objects, row retention waits for the object). Backs the "written retention schedule + scheduled cleanup jobs" item in `documents/21-privacy-compliance.md` §7.1 for the TTL-able auth artifacts that @@ -386,11 +386,29 @@ a failed lane is logged and the next lane still runs. |---|---|---| | `broadcast_deliveries` rows | **180 d** from `created_at` | `host.retention.sweep` → `broadcast_deliveries` | | `broadcasts` subject + body + CTA | scrubbed **180 d** after `finished_at`; the counts and the row are kept indefinitely as the audit record that a message was sent | `host.retention.sweep` → `broadcast_content` | -| `host_exports` rows | **90 d** from `requested_at` | `host.retention.sweep` → `host_exports` | -| host export OBJECTS | **24 h** (`HOST_EXPORT_TTL_HOURS`); objects are deleted BEFORE their rows | `host.export.reap` | +| `host_exports` rows | **90 d** from `requested_at`, and only once the row no longer names an object (`r2_key IS NULL`) | `host.retention.sweep` → `host_exports` | +| host export OBJECTS | **24 h** (`HOST_EXPORT_TTL_HOURS`) for a ready export; a failed run's object at the next reaper pass; objects are deleted BEFORE their rows | `host.export.reap` | | `event_metrics_daily` | **never** — aggregates with no identifier of any kind, and the only long-run record a host has | — | | `broadcast_unsubscribes`, `email_suppressions` | **indefinite, deliberately** — a suppression list that expires re-enables mailing someone who said stop (same reasoning as `sms_opt_outs`) | — | +`host.export.reap` (`HOST_EXPORT_REAP_CRON`, default hourly at :40; up to 200 rows per +lane per pass) runs two lanes in `services/api/src/services/host/export-service.ts`: + +- **Expired.** A `ready` row whose `expires_at` has passed has its object deleted, then + becomes `expired` with `r2_key = NULL`. +- **Orphaned.** A `failed` row that still names an object, a `running` row whose + `started_at` is more than 1 hour old, and a `queued` row whose `requested_at` is more + than 1 hour old (`EXPORT_ABANDON_AFTER_MS`). Any object is deleted first; the row then + becomes `failed` with `r2_key = NULL`, keeps an existing `error_code` or else takes + `not_started` (was queued) or `build_failed` (was running), and gets `completed_at` if + it had none. The update applies only while the row's status and run token are unchanged, + so a run that claimed the row in between is not overwritten. + +When an object delete fails, the row is left as it is and the next pass retries it. A +failed run also deletes its own object straight away; the reaper finishes that cleanup +when it could not. `host.retention.sweep` deletes `host_exports` rows past 90 days only +when `r2_key IS NULL`, so it never drops the last pointer to an object still in storage. + ### Donation links, retired payments tables, and legal civfix no longer processes donations. A donation link is now a single nullable diff --git a/docs/security/2026-07-24-full-backend-security-review.md b/docs/security/2026-07-24-full-backend-security-review.md index 4c31a685..3ef1e0e1 100644 --- a/docs/security/2026-07-24-full-backend-security-review.md +++ b/docs/security/2026-07-24-full-backend-security-review.md @@ -322,7 +322,7 @@ Everything an operator has to do by hand, in order, plus the two infra facts and node dist/db/migrate.js # == pnpm --filter @civfix/api db:migrate ``` -`drizzle/` holds **165 files**, `0000_extensions.sql` … `0182_organization_invites_invited_by_idx.sql`. The nine rows +`drizzle/` holds **167 files**, `0000_extensions.sql` … `0185_inbound_bounce_attempts.sql`. The nine rows below are exactly what this change set adds — `0052`–`0060`, contiguous, no gaps — and everything from `0000` through `0051_social_posts.sql` predates it. (`0060` arrived later than the rest, with the feed redesign; it is listed here because this table is the single operator runbook. `0061`–`0064` arrived @@ -572,6 +572,8 @@ foreign key into `organizations` from `0105`. | `0180_civfix_official_account.sql` | DATA only, no DDL (the Drizzle mirror is unchanged): creates the official `@civfix` account (`users.id` `00000000-0000-4000-8000-00000000c1f1`, display name `CivFix`, role `citizen`, no email, DMs closed) that admin-panel chat posts are authored by. First it frees the handle: any OTHER row holding `civfix` (citext, so any case) gets its auto-placeholder handle `user` + the first 12 hex digits of its id, and nothing else about that row changes - not its display name, and not `handle_changed_at`, so its owner may pick a new handle at once. The INSERT conflicts on the id only, so a placeholder or handle clash fails the file instead of skipping it. At most two `users` rows are touched: milliseconds, row locks only. `SessionService` refuses to mint or resolve a session for the id, and with no email and no OAuth identity no sign-in path can reach it | Admin-panel report and event chat posts fail on the `chat_messages.sender_id` foreign key once the code that authors them as the official account is deployed; nothing else reads the row | | `0181_media_uploader.sql` | adds the nullable `media_assets.uploader` column (`u:`, `a:` or `anon`), written by upload create, so a report, post, chat or avatar claim by uploadId binds only the caller's own upload. Nullable with no default and no backfill: a catalog-only change on a hot table, lock held for milliseconds, no index (claims find the row through the unique `upload_id`). Rows from before the deploy stay NULL and are claimable only inside the claim window | Upload create fails writing a column that does not exist; claims fail on the missing column | | `0182_organization_invites_invited_by_idx.sql` | adds the partial index `organization_invites_inviter_pending_idx (invited_by) WHERE status = 'pending'`, so the account-erasure statement that revokes the pending organization invites a user sent or received (`invited_by = $1 OR user_id = $1`) can BitmapOr this index with `organization_invites_invitee_pending_idx` instead of scanning the table. `organization_invites` is not on the hot-table list, so it builds inline under the migration transaction (milliseconds at current size); if the table has grown, build it `CONCURRENTLY` by hand first and the `IF NOT EXISTS` guard makes the file a no-op | Account deletion still works, with a sequential scan of `organization_invites` inside the erasure transaction | +| `0184_media_assets_upload_etag.sql` | adds `media_assets.upload_etag`, a nullable `text` with no default, no index and no backfill, so a catalog-only `ADD COLUMN IF NOT EXISTS` on a hot table (a brief ACCESS EXCLUSIVE lock, no rewrite, no scan). Finalize now writes the HEAD etag of the uploaded object in the same UPDATE that claims `finalized_at`, and the media-worker stuck sweep reads it back so a requeued `media.checks` job still rejects bytes re-PUT after finalize. Rows finalized before this file keep NULL and requeue with no etag, which skips the comparison exactly as before. An opaque object-version string, no PII: it lives and dies with its row | Every finalize and every stuck-sweep pick fails on the missing column once the code that writes and reads it is deployed | +| `0185_inbound_bounce_attempts.sql` | creates `inbound_bounce_attempts (object_key text PRIMARY KEY, attempts int, last_attempt_at timestamptz)`, a counter of failed bounce-bookkeeping runs per pending inbound object. The inbound processor increments it when a DSN's bookkeeping fails and, at `INBOUND_BOUNCE_MAX_ATTEMPTS`, parks the object under `inbound/failed/` and deletes the row, so a DSN that can never be recorded stops taking a slot in every sweep batch. New empty table, no index beyond the key, no backfill; a row holds only the pending object's own key and is deleted on success or park | Every DSN, including one whose bookkeeping succeeds, throws on the missing table at the counter write and stays under `inbound/pending/`, so every sweep replays it | **Deferred to the NEXT release** (expand/contract, `docs/migrations-expand-contract.md`): 0.43.0 diff --git a/services/api/.env.example b/services/api/.env.example index 21b2f82d..782dc648 100644 --- a/services/api/.env.example +++ b/services/api/.env.example @@ -218,7 +218,13 @@ APNS_KEY_ID= # [OPT] APNs auth key id APNS_TEAM_ID= # [OPT] Apple team id for APNs APNS_PRIVATE_KEY= # [OPT] APNs .p8 private key contents APNS_BUNDLE_ID= # [OPT] iOS app bundle id (apns topic) -APNS_PRODUCTION= # [OPT] 1/true to use the production APNs gateway +APNS_PRODUCTION= # [BOOT] in production once all four APNS_* credentials above are set, + # USE_FAKE_PUSH or not (else [OPT]). true = production gateway + # (App Store and TestFlight builds), false = sandbox gateway. + # Parsed strictly: true/false, t/f, yes/no, y/n, on/off, 1/0 (any case); + # anything else fails boot. Unset outside production = the + # production gateway. A gateway mismatch makes APNs reject every + # token as BadDeviceToken. # ===== push: FCM (bypassed by USE_FAKE_PUSH) [OPT] ===== FCM_SERVICE_ACCOUNT_JSON= # [OPT] Firebase service account JSON (string) diff --git a/services/api/drizzle/0184_media_assets_upload_etag.sql b/services/api/drizzle/0184_media_assets_upload_etag.sql new file mode 100644 index 00000000..55f15961 --- /dev/null +++ b/services/api/drizzle/0184_media_assets_upload_etag.sql @@ -0,0 +1,28 @@ +-- ============================================================================= +-- 0184_media_assets_upload_etag.sql +-- ----------------------------------------------------------------------------- +-- WHY. Finalize HEADs the uploaded object and hands its ETag to the media.checks +-- job, and the worker rejects the upload when the bytes it downloads carry a +-- different ETag: the client's presigned PUT stays valid after finalize, so a +-- re-PUT in that window must not be processed as the bytes that were finalized. +-- The ETag only lived in that first job's payload. When the job is lost and the +-- stuck sweep requeues it from the row, the payload had no ETag and the check +-- was skipped. upload_etag keeps it on the row, written in the same UPDATE that +-- claims finalized_at, so the sweep's requeue carries it too. +-- +-- BACK-COMPAT: rows finalized before this file keep NULL, and a NULL ETag means +-- the worker skips the comparison exactly as it did before. The column is an +-- opaque object-version string for the row's own bytes (no PII, no location), +-- so it needs no retention rule of its own: it lives and dies with the row. +-- +-- HOT TABLE: media_assets. ADD COLUMN IF NOT EXISTS of a NULLable column with +-- NO default and NO index is a catalog-only change on Postgres 11+ (a brief +-- ACCESS EXCLUSIVE lock, no rewrite, no scan). No index: upload_etag is only +-- read off rows already selected by id through the stuck-sweep index. +-- +-- CANONICAL DDL: hand-authored source of truth. Mirror: schema/media.ts. +-- Forward-only, no down. Requires 0001_core.sql (media_assets). +-- ============================================================================= + +ALTER TABLE media_assets + ADD COLUMN IF NOT EXISTS upload_etag text; diff --git a/services/api/drizzle/0185_inbound_bounce_attempts.sql b/services/api/drizzle/0185_inbound_bounce_attempts.sql new file mode 100644 index 00000000..bfa0d661 --- /dev/null +++ b/services/api/drizzle/0185_inbound_bounce_attempts.sql @@ -0,0 +1,26 @@ +-- ============================================================================= +-- 0185_inbound_bounce_attempts.sql +-- ----------------------------------------------------------------------------- +-- WHY. A DSN stays under inbound/pending/ until its bounce bookkeeping succeeds, +-- so the next sweep can finish what a failed run left undone. One whose +-- bookkeeping fails every time used to stay there forever and take a slot in +-- every sweep's 200-object batch. This table counts the failed runs per pending +-- object; at the cap the processor parks the object under inbound/failed/ and +-- deletes the row. The storage seam has no custom object metadata to hold it. +-- +-- BACK-COMPAT: new table, nothing reads it until the code that writes it ships. +-- A missing row means no failed run yet. +-- +-- RETENTION: a row holds only the pending object's key (a Message-ID slug plus +-- a content digest, the same name the object already carries) and lives only +-- while that object is pending: success and parking both delete the row. +-- +-- CANONICAL DDL: hand-authored source of truth. Mirror: +-- schema/inbound_bounce_attempts.ts. Forward-only, no down. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS inbound_bounce_attempts ( + object_key text PRIMARY KEY, + attempts integer NOT NULL DEFAULT 0, + last_attempt_at timestamptz NOT NULL DEFAULT now() +); diff --git a/services/api/src/abuse/counter-store.ts b/services/api/src/abuse/counter-store.ts index 9e8f45dc..9134c61b 100644 --- a/services/api/src/abuse/counter-store.ts +++ b/services/api/src/abuse/counter-store.ts @@ -1,9 +1,14 @@ import type { RedisClient } from "../adapters/redis.js" -import { attachAtomicIncr, attachAtomicIncrBy } from "../adapters/redis-incr.js" +import { attachAtomicDecrBy, attachAtomicIncr, attachAtomicIncrBy } from "../adapters/redis-incr.js" export interface CounterStore { incr(key: string, ttlSeconds: number): Promise incrBy(key: string, by: number, ttlSeconds: number): Promise + /** + * Gives back a charge a refused action took. It never goes below zero and never creates or extends + * a key: a give-back that lands after the window ended must not open a new one. + */ + decrBy(key: string, by: number): Promise } export type Clock = () => number @@ -46,6 +51,13 @@ export class InMemoryCounterStore implements CounterStore { return Promise.resolve(existing.count) } + decrBy(key: string, by: number): Promise { + const existing = this.live(key) + if (!existing) return Promise.resolve(0) + existing.count = Math.max(0, existing.count - Math.max(0, Math.floor(by))) + return Promise.resolve(existing.count) + } + peek(key: string): number { return this.live(key)?.count ?? 0 } @@ -54,10 +66,12 @@ export class InMemoryCounterStore implements CounterStore { export class RedisCounterStore implements CounterStore { private readonly atomicIncr: (key: string, ttlSeconds: number) => Promise private readonly atomicIncrBy: (key: string, by: number, ttlSeconds: number) => Promise + private readonly atomicDecrBy: (key: string, by: number) => Promise constructor(redis: RedisClient) { this.atomicIncr = attachAtomicIncr(redis) this.atomicIncrBy = attachAtomicIncrBy(redis) + this.atomicDecrBy = attachAtomicDecrBy(redis) } incr(key: string, ttlSeconds: number): Promise { @@ -67,4 +81,8 @@ export class RedisCounterStore implements CounterStore { incrBy(key: string, by: number, ttlSeconds: number): Promise { return this.atomicIncrBy(key, by, ttlSeconds) } + + decrBy(key: string, by: number): Promise { + return this.atomicDecrBy(key, by) + } } diff --git a/services/api/src/adapters/chat-send-dedupe.redis.ts b/services/api/src/adapters/chat-send-dedupe.redis.ts index 1c3e8e7a..9875a3f5 100644 --- a/services/api/src/adapters/chat-send-dedupe.redis.ts +++ b/services/api/src/adapters/chat-send-dedupe.redis.ts @@ -22,6 +22,8 @@ const realSleep = (ms: number): Promise => if (typeof timer.unref === "function") timer.unref() }) +// Every Redis failure fails open: dedupe only suppresses retried duplicates, so an outage must not +// block chat sends, and the shared client's error handler already logs the outage itself. const OPEN: SendReservation = { state: "open" } export class RedisSendDedupeStore implements SendDedupeStore { diff --git a/services/api/src/adapters/chat-service.ws.ts b/services/api/src/adapters/chat-service.ws.ts index 88148992..48104a3f 100644 --- a/services/api/src/adapters/chat-service.ws.ts +++ b/services/api/src/adapters/chat-service.ws.ts @@ -127,8 +127,8 @@ function decodeEnvelope(payload: string): { frame: string; excludeConnId: string if (parsed.type === "message" && parsed.message !== undefined) { return { frame: JSON.stringify({ type: "message", message: parsed.message }), excludeConnId } } - } catch (ignored) { - void ignored + } catch { + // Not a JSON envelope: the payload is already a bare frame and is forwarded as-is. } return { frame: payload, excludeConnId: undefined } } diff --git a/services/api/src/adapters/jobs.pgboss.ts b/services/api/src/adapters/jobs.pgboss.ts index fa4f7a72..6096d6dd 100644 --- a/services/api/src/adapters/jobs.pgboss.ts +++ b/services/api/src/adapters/jobs.pgboss.ts @@ -2,10 +2,22 @@ import type PgBoss from "pg-boss" import type { Jobs, EnqueueOptions, JobHandler } from "@civfix/shared/interfaces" import { REGISTRATION_QUEUE_NAMES } from "../services/host/registration-queues.js" import { COMMS_QUEUE_NAMES } from "../services/host/broadcast-queues.js" +import type { JobHandlerArgWithAttempt } from "../services/job-attempt.js" + +export interface PgBossJobsLogger { + error(obj: unknown, msg?: string): void +} + +// Only for callers that construct the adapter without a logger; the API container always injects +// its pino logger so redaction applies. +const consoleLogger: PgBossJobsLogger = { + error: (obj, msg) => console.error(msg ?? "", obj), +} export interface PgBossJobsConfig { connectionString: string schema?: string + logger?: PgBossJobsLogger } export const API_QUEUE_NAMES = [ @@ -24,6 +36,24 @@ export const API_QUEUE_NAMES = [ ...COMMS_QUEUE_NAMES, ] as const +type ApiQueueName = (typeof API_QUEUE_NAMES)[number] + +type QueueRetryPolicy = Required> + +const SECONDS_PER_MINUTE = 60 + +// A data export that fails on a mail credential or approved-sender fault has to wait for an operator to +// fix the config. pg-boss's default (2 immediate retries) would rebuild and resend the whole export three +// times within seconds and then drop the request, so it backs off from a minute to hours instead; the +// handler records the request for an operator on the last attempt. +const QUEUE_RETRY_POLICIES: Partial> = { + "data.export": { retryLimit: 10, retryDelay: SECONDS_PER_MINUTE, retryBackoff: true }, +} + +function queueOptions(name: ApiQueueName): PgBoss.Queue { + return { name, policy: "short", ...QUEUE_RETRY_POLICIES[name] } +} + function toSendOptions(opts?: EnqueueOptions): PgBoss.SendOptions { const out: PgBoss.SendOptions = {} if (opts?.singletonKey !== undefined) out.singletonKey = opts.singletonKey @@ -47,11 +77,12 @@ export class PgBossJobs implements Jobs { connectionString: this.config.connectionString, ...(this.config.schema !== undefined ? { schema: this.config.schema } : {}), }) - boss.on("error", (err: Error) => console.error("pg-boss error:", err)) + const logger = this.config.logger ?? consoleLogger + boss.on("error", (err: Error) => logger.error({ err }, "pg-boss error")) await boss.start() for (const name of API_QUEUE_NAMES) { - await boss.createQueue(name, { name, policy: "short" }) - await boss.updateQueue(name, { name, policy: "short" }) + await boss.createQueue(name, queueOptions(name)) + await boss.updateQueue(name, queueOptions(name)) } this.boss = boss } @@ -77,9 +108,23 @@ export class PgBossJobs implements Jobs { } async work(name: string, handler: JobHandler): Promise { - await this.requireBoss().work(name, async (jobs: PgBoss.Job[]) => { - await Promise.all(jobs.map((j) => handler({ id: j.id, data: j.data }))) - }) + await this.requireBoss().work( + name, + { includeMetadata: true }, + async (jobs: PgBoss.JobWithMetadata[]) => { + await Promise.all( + jobs.map((j) => { + const arg: JobHandlerArgWithAttempt = { + id: j.id, + data: j.data, + retryCount: j.retryCount, + retryLimit: j.retryLimit, + } + return handler(arg) + }), + ) + }, + ) } async complete(jobId: string): Promise { diff --git a/services/api/src/adapters/mailer.oci.ts b/services/api/src/adapters/mailer.oci.ts index 5d2022b8..7622189b 100644 --- a/services/api/src/adapters/mailer.oci.ts +++ b/services/api/src/adapters/mailer.oci.ts @@ -16,6 +16,7 @@ import { import { renderEmailBody } from "./email-layout.js" import { renderMessage } from "../i18n/renderMessage.js" import { resolveLocale, type Locale } from "../i18n/locales.js" +import { OTP_TTL_SECONDS } from "../auth/otp.js" const CRLF_RE = /[\r\n\0]/ const CRLF_GLOBAL_RE = /[\r\n\0]/g @@ -46,6 +47,17 @@ export interface OciMailerConfig { fromNoReply: string fromOutreach: string timeoutMs?: number + logger?: OciMailerLogger +} + +export interface OciMailerLogger { + warn(obj: unknown, msg?: string): void +} + +// Only for callers that construct the mailer without a logger; the API container always injects its +// pino logger, whose serializers redact the SMTP response an error carries. +const consoleLogger: OciMailerLogger = { + warn: (obj, msg) => console.warn(msg ?? "", obj), } export const OCI_MAILER_DEFAULT_TIMEOUT_MS = 15_000 @@ -142,8 +154,9 @@ export class OciMailer implements Mailer { socketTimeout: timeout, }) this.transporter = transporter + const logger = this.config.logger ?? consoleLogger transporter.verify().catch((err: unknown) => { - console.warn( + logger.warn( { err }, "OCI mailer SMTP verify failed (continuing; send will surface the error)", ) @@ -202,7 +215,12 @@ export function renderOtp(passcode: string, locale: Locale): Rendered { blocks: [ paragraph(renderMessage(locale, "email.otp.html_intro")), code(passcode), - paragraph(renderMessage(locale, "email.otp.body_expiry"), { muted: true }), + paragraph( + renderMessage(locale, "email.otp.body_expiry", { + minutes: String(Math.floor(OTP_TTL_SECONDS / 60)), + }), + { muted: true }, + ), ], }) return { subject, text, html } diff --git a/services/api/src/adapters/push-fcm.ts b/services/api/src/adapters/push-fcm.ts index 85a1e491..071795a6 100644 --- a/services/api/src/adapters/push-fcm.ts +++ b/services/api/src/adapters/push-fcm.ts @@ -7,13 +7,45 @@ const FCM_MULTICAST_MAX = 500 const PRUNE_CODES = new Set([ "messaging/registration-token-not-registered", "messaging/invalid-registration-token", - "messaging/invalid-argument", ]) +// FCM v1 answers both a malformed token and a bad payload (too large, reserved data key) with +// INVALID_ARGUMENT, so it only proves a token fault when the same payload reached another token. +const AMBIGUOUS_INVALID_CODE = "messaging/invalid-argument" + export function isFcmPruneCode(code: string): boolean { return PRUNE_CODES.has(code) } +type FcmSendResponse = { success: boolean; error?: { code?: string; message?: string } } + +export interface FcmSliceVerdict { + invalidTokens: string[] + payloadRejected: { message: string | undefined } | null + failures: { code: string; token: string | undefined }[] +} + +export function classifyFcmResponses( + tokens: readonly string[], + responses: readonly FcmSendResponse[], +): FcmSliceVerdict { + const payloadAccepted = responses.some((r) => r.success) + const verdict: FcmSliceVerdict = { invalidTokens: [], payloadRejected: null, failures: [] } + responses.forEach((r, i) => { + if (r.success) return + const code = r.error?.code ?? "" + const token = tokens[i] + if (isFcmPruneCode(code) || (code === AMBIGUOUS_INVALID_CODE && payloadAccepted)) { + if (token !== undefined) verdict.invalidTokens.push(token) + } else if (code === AMBIGUOUS_INVALID_CODE) { + verdict.payloadRejected ??= { message: r.error?.message } + } else { + verdict.failures.push({ code, token }) + } + }) + return verdict +} + export function makeFcmDispatcher( fcm: NonNullable, logger: PushLogger, @@ -48,7 +80,7 @@ export function makeFcmDispatcher( tokens: string[], payload: PushPayload, invalidTokens: string[], - ): Promise { + ): Promise { const message = { tokens, notification: { @@ -61,31 +93,46 @@ export function makeFcmDispatcher( }), } try { - const resp = await messaging.sendEachForMulticast(message) - resp.responses.forEach((r: { success: boolean; error?: { code?: string } }, i: number) => { - if (r.success) return - const code: string = r.error?.code ?? "" - if (isFcmPruneCode(code)) { - const tok = tokens[i] - if (tok !== undefined) invalidTokens.push(tok) - } else { - const tok = tokens[i] - logger.warn( - { code, tokenHash: typeof tok === "string" ? hashForLog(tok) : undefined }, - "push(fcm): delivery failure", - ) - } - }) + const resp = (await messaging.sendEachForMulticast(message)) as { + responses: FcmSendResponse[] + } + const verdict = classifyFcmResponses(tokens, resp.responses) + invalidTokens.push(...verdict.invalidTokens) + for (const { code, token } of verdict.failures) { + logger.warn( + { code, tokenHash: typeof token === "string" ? hashForLog(token) : undefined }, + "push(fcm): delivery failure", + ) + } + return verdict.payloadRejected } catch (err) { logger.error({ err }, "push(fcm): send threw") + return null } } const dispatch: PlatformDispatcher = async (tokens, payload) => { const messaging = await getMessaging() const invalidTokens: string[] = [] + let payloadRejection: FcmSliceVerdict["payloadRejected"] = null + let rejectedSlices = 0 for (let i = 0; i < tokens.length; i += FCM_MULTICAST_MAX) { - await dispatchSlice(messaging, tokens.slice(i, i + FCM_MULTICAST_MAX), payload, invalidTokens) + const rejection = await dispatchSlice( + messaging, + tokens.slice(i, i + FCM_MULTICAST_MAX), + payload, + invalidTokens, + ) + if (rejection !== null) { + rejectedSlices += 1 + payloadRejection ??= rejection + } + } + if (payloadRejection !== null) { + logger.warn( + { code: AMBIGUOUS_INVALID_CODE, detail: payloadRejection.message, rejectedSlices }, + "push(fcm): payload rejected for every token in a batch; no tokens pruned", + ) } return { invalidTokens } } diff --git a/services/api/src/adapters/push-sender.ts b/services/api/src/adapters/push-sender.ts index 73ef0c56..0f3d08d4 100644 --- a/services/api/src/adapters/push-sender.ts +++ b/services/api/src/adapters/push-sender.ts @@ -46,6 +46,8 @@ export function hashForLog(value: string): string { const ACTIVE_TOKEN_SCAN_CAP_PER_USER = 20 +// Only for callers that construct the sender without a logger; the API container always injects +// its pino logger so redaction and request context apply. const consoleLogger: PushLogger = { warn: (obj, msg) => console.warn(msg ?? "", obj), error: (obj, msg) => console.error(msg ?? "", obj), diff --git a/services/api/src/adapters/redis-incr.ts b/services/api/src/adapters/redis-incr.ts index fad6fae1..6b43bb75 100644 --- a/services/api/src/adapters/redis-incr.ts +++ b/services/api/src/adapters/redis-incr.ts @@ -1,34 +1,33 @@ import type { RedisClient } from "./redis.js" -const INCR_EXPIRE_LUA = - "local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end; return n" - +// The window starts whenever the key has no expiry, not when the value equals the increment: a zero +// increment would otherwise restart it, and a key whose TTL was lost would never expire again. const INCRBY_EXPIRE_LUA = - "local n = redis.call('INCRBY', KEYS[1], ARGV[1]); if n == tonumber(ARGV[1]) then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end; return n" + "local n = redis.call('INCRBY', KEYS[1], ARGV[1]); if redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end; return n" + +const INCRBY_COMMAND_NAME = "civfixIncrByWindow" -const COMMAND_NAME = "civfixIncrExpire" +// DECRBY keeps the key's TTL, and a missing key is left missing so a late give-back cannot create a +// counter with no expiry. +const DECRBY_FLOOR_LUA = + "local n = tonumber(redis.call('GET', KEYS[1]) or '0'); if n <= 0 then return 0 end; return redis.call('DECRBY', KEYS[1], math.min(n, tonumber(ARGV[1])))" -const INCRBY_COMMAND_NAME = "civfixIncrByExpire" +const DECRBY_COMMAND_NAME = "civfixDecrByFloor" type IncrExpire = (key: string, ttlSeconds: number) => Promise type IncrByExpire = (key: string, by: number, ttlSeconds: number) => Promise +type DecrFloor = (key: string, by: number) => Promise + type WithIncrExpire = RedisClient & { - [COMMAND_NAME]?: (key: string, ttlMs: number) => Promise [INCRBY_COMMAND_NAME]?: (key: string, by: string, ttlMs: number) => Promise + [DECRBY_COMMAND_NAME]?: (key: string, by: string) => Promise } export function attachAtomicIncr(redis: RedisClient): IncrExpire { - const client = redis as WithIncrExpire - if (typeof client[COMMAND_NAME] !== "function") { - redis.defineCommand(COMMAND_NAME, { numberOfKeys: 1, lua: INCR_EXPIRE_LUA }) - } - return async (key: string, ttlSeconds: number): Promise => { - const ttlMs = Math.max(1, Math.ceil(ttlSeconds)) * 1000 - const result = await client[COMMAND_NAME]!(key, ttlMs) - return Number(result) - } + const incrBy = attachAtomicIncrBy(redis) + return (key: string, ttlSeconds: number): Promise => incrBy(key, 1, ttlSeconds) } export function attachAtomicIncrBy(redis: RedisClient): IncrByExpire { @@ -43,3 +42,15 @@ export function attachAtomicIncrBy(redis: RedisClient): IncrByExpire { return Number(result) } } + +export function attachAtomicDecrBy(redis: RedisClient): DecrFloor { + const client = redis as WithIncrExpire + if (typeof client[DECRBY_COMMAND_NAME] !== "function") { + redis.defineCommand(DECRBY_COMMAND_NAME, { numberOfKeys: 1, lua: DECRBY_FLOOR_LUA }) + } + return async (key: string, by: number): Promise => { + const amount = Math.max(0, Math.floor(by)) + const result = await client[DECRBY_COMMAND_NAME]!(key, String(amount)) + return Number(result) + } +} diff --git a/services/api/src/adapters/sms-twilio.ts b/services/api/src/adapters/sms-twilio.ts index 3e5c3af6..bc758255 100644 --- a/services/api/src/adapters/sms-twilio.ts +++ b/services/api/src/adapters/sms-twilio.ts @@ -43,55 +43,104 @@ export class TwilioSmsSender implements SmsSender { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), SMS_SEND_TIMEOUT_MS) - let response: Response try { - response = await doFetch(url, { - method: "POST", - redirect: "error", - headers: { - authorization: `Basic ${credentials}`, - "content-type": "application/x-www-form-urlencoded", - accept: "application/json", - }, - body: form.toString(), - signal: controller.signal, - }) - } catch (err) { - throw smsFailure( - "temporary", - "Text message not sent: the SMS provider did not respond in time.", - err, - ) + let response: Response + try { + response = await doFetch(url, { + method: "POST", + redirect: "error", + headers: { + authorization: `Basic ${credentials}`, + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: form.toString(), + signal: controller.signal, + }) + } catch (err) { + throw smsFailure( + "temporary", + "Text message not sent: the SMS provider did not respond in time.", + err, + ) + } + + const read = await readJsonPayload(response, controller.signal) + if (!response.ok) { + throw classifyTwilioError(response.status, read.payload) + } + if (read.interrupted) { + // A 2xx status means Twilio may already have queued the text, so retrying could send it twice. + throw smsFailure( + "permanent", + "Text message status unknown: the SMS provider accepted it but its reply was cut off.", + read.error, + ) + } + const sid = typeof read.payload.sid === "string" ? read.payload.sid : "" + if (sid === "") { + throw smsFailure( + "temporary", + "Text message not sent: the SMS provider returned no message id.", + ) + } + return { id: sid } } finally { clearTimeout(timer) } - - const payload = await readJsonPayload(response) - if (!response.ok) { - throw classifyTwilioError(response.status, payload) - } - const sid = typeof payload.sid === "string" ? payload.sid : "" - if (sid === "") { - throw smsFailure( - "temporary", - "Text message not sent: the SMS provider returned no message id.", - ) - } - return { id: sid } } } -async function readJsonPayload(response: Response): Promise { +interface PayloadRead { + payload: TwilioMessageResponse + interrupted: boolean + error?: unknown +} + +async function readJsonPayload(response: Response, signal: AbortSignal): Promise { const declared = Number(response.headers?.get?.("content-length") ?? "") if (Number.isFinite(declared) && declared > MAX_ERROR_BODY_BYTES) { await response.body?.cancel().catch(() => {}) - return {} + return { payload: {}, interrupted: false } } + const body = response.body + if (body === null) return { payload: {}, interrupted: false } + + const reader = body.getReader() + const cancelOnAbort = () => { + void reader.cancel().catch(() => {}) + } + signal.addEventListener("abort", cancelOnAbort, { once: true }) + const chunks: Uint8Array[] = [] + let total = 0 + try { + if (signal.aborted) cancelOnAbort() + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > MAX_ERROR_BODY_BYTES) { + await reader.cancel().catch(() => {}) + return { payload: {}, interrupted: false } + } + chunks.push(value) + } + } catch (error) { + return { payload: {}, interrupted: true, error } + } finally { + signal.removeEventListener("abort", cancelOnAbort) + reader.releaseLock() + } + if (signal.aborted) return { payload: {}, interrupted: true, error: signal.reason } + try { - const parsed: unknown = await response.json() - return typeof parsed === "object" && parsed !== null ? (parsed as TwilioMessageResponse) : {} + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")) + const payload = + typeof parsed === "object" && parsed !== null ? (parsed as TwilioMessageResponse) : {} + return { payload, interrupted: false } } catch { - return {} + // A gateway's HTML error page still classifies by HTTP status alone. + return { payload: {}, interrupted: false } } } diff --git a/services/api/src/adapters/storage.r2.ts b/services/api/src/adapters/storage.r2.ts index 0762a8fa..b38ffd12 100644 --- a/services/api/src/adapters/storage.r2.ts +++ b/services/api/src/adapters/storage.r2.ts @@ -22,6 +22,15 @@ export interface R2StorageConfig { export const R2_DEFAULT_GET_TTL_SEC = 15 * 60 export const R2_PUT_TTL_SEC = 15 * 60 +export const R2_CONNECT_TIMEOUT_MS = 5_000 +export const R2_SOCKET_IDLE_TIMEOUT_MS = 30_000 +export const R2_RESPONSE_TIMEOUT_MS = 30_000 +export const R2_MAX_ATTEMPTS = 3 +// The handler's timers stop once response headers arrive, so only an abort signal bounds a body +// that stalls mid-stream. Metadata calls sit on request paths; transfers move objects up to tens of MB. +export const R2_METADATA_OPERATION_TIMEOUT_MS = 15_000 +export const R2_TRANSFER_OPERATION_TIMEOUT_MS = 120_000 + export class R2Storage implements Storage { private readonly config: R2StorageConfig private client: S3Client | undefined @@ -80,7 +89,10 @@ export class R2Storage implements Storage { const { HeadObjectCommand } = await import("@aws-sdk/client-s3") const client = await this.getClient() try { - const res = await client.send(new HeadObjectCommand({ Bucket: this.config.bucket, Key: key })) + const res = await client.send( + new HeadObjectCommand({ Bucket: this.config.bucket, Key: key }), + metadataDeadline(), + ) const etag = normalizeEtag(res.ETag) return { size: typeof res.ContentLength === "number" ? res.ContentLength : 0, @@ -100,7 +112,10 @@ export class R2Storage implements Storage { const { DeleteObjectCommand } = await import("@aws-sdk/client-s3") const client = await this.getClient() try { - await client.send(new DeleteObjectCommand({ Bucket: this.config.bucket, Key: key })) + await client.send( + new DeleteObjectCommand({ Bucket: this.config.bucket, Key: key }), + metadataDeadline(), + ) } catch (err) { throw new AppError(ErrorCode.INTERNAL, "R2 delete failed", { cause: err }) } @@ -120,6 +135,7 @@ export class R2Storage implements Storage { ? { ContentDisposition: meta.contentDisposition } : {}), }), + transferDeadline(), ) } catch (err) { throw new AppError(ErrorCode.INTERNAL, "R2 put failed", { cause: err }) @@ -137,6 +153,7 @@ export class R2Storage implements Storage { ...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}), ...(opts?.limit && opts.limit > 0 ? { MaxKeys: opts.limit } : {}), }), + metadataDeadline(), ) const keys = (res.Contents ?? []) .map((o) => o.Key) @@ -153,7 +170,10 @@ export class R2Storage implements Storage { const { GetObjectCommand } = await import("@aws-sdk/client-s3") const client = await this.getClient() try { - const res = await client.send(new GetObjectCommand({ Bucket: this.config.bucket, Key: key })) + const res = await client.send( + new GetObjectCommand({ Bucket: this.config.bucket, Key: key }), + transferDeadline(), + ) if (!res.Body) return null const bytes = await res.Body.transformToByteArray() return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) @@ -170,7 +190,8 @@ export class R2Storage implements Storage { this.client = new S3ClientCtor({ region: "auto", endpoint: `https://${endpointHost}`, - ...(await proxyRequestHandler(endpointHost)), + requestHandler: await boundedRequestHandler(endpointHost), + maxAttempts: R2_MAX_ATTEMPTS, forcePathStyle: true, requestChecksumCalculation: "WHEN_REQUIRED", credentials: { @@ -183,14 +204,34 @@ export class R2Storage implements Storage { } } -async function proxyRequestHandler(host: string): Promise> { +function metadataDeadline(): { abortSignal: AbortSignal } { + return { abortSignal: AbortSignal.timeout(R2_METADATA_OPERATION_TIMEOUT_MS) } +} + +// The handler's requestTimeout runs from before the body is written until the response arrives, so +// the default response wait would cap a large upload at R2_RESPONSE_TIMEOUT_MS per attempt. +function transferDeadline(): { abortSignal: AbortSignal; requestTimeout: number } { + return { + abortSignal: AbortSignal.timeout(R2_TRANSFER_OPERATION_TIMEOUT_MS), + requestTimeout: R2_TRANSFER_OPERATION_TIMEOUT_MS, + } +} + +async function boundedRequestHandler(host: string): Promise { + const { NodeHttpHandler } = await import("@smithy/node-http-handler") const settings = readProxySettings() - if (settings === null || !shouldProxyHost(host, settings)) return {} - const [{ NodeHttpHandler }, { HttpsProxyAgent }] = await Promise.all([ - import("@smithy/node-http-handler"), - import("https-proxy-agent"), - ]) - return { requestHandler: new NodeHttpHandler({ httpsAgent: new HttpsProxyAgent(settings.url) }) } + const httpsAgent = + settings !== null && shouldProxyHost(host, settings) + ? new (await import("https-proxy-agent")).HttpsProxyAgent(settings.url) + : undefined + // Without throwOnRequestTimeout the SDK only logs a warning when requestTimeout elapses. + return new NodeHttpHandler({ + connectionTimeout: R2_CONNECT_TIMEOUT_MS, + socketTimeout: R2_SOCKET_IDLE_TIMEOUT_MS, + requestTimeout: R2_RESPONSE_TIMEOUT_MS, + throwOnRequestTimeout: true, + ...(httpsAgent !== undefined ? { httpsAgent } : {}), + }) } function joinUrl(base: string, key: string): string { diff --git a/services/api/src/auth/jwks.ts b/services/api/src/auth/jwks.ts index 3547492c..50d89717 100644 --- a/services/api/src/auth/jwks.ts +++ b/services/api/src/auth/jwks.ts @@ -166,6 +166,8 @@ const defaultFetch: FetchLike = async (url: string) => { const body = await res.json() return { ok: true, json: () => Promise.resolve(body) } } catch { + // Timeout, DNS failure and an unparseable body all mean "provider unreachable", which fetchKeys + // answers with a retryable 503 rather than a 401 that would tell the client to sign in again. return { ok: false, json: () => Promise.resolve(null) } } finally { clearTimeout(timer) diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index a952a255..b6863024 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -217,7 +217,15 @@ export class PgUserStore implements UserStore { return r ? toUserRecord(r) : null } - async updateProfile(id: string, input: UpdateProfileInput): Promise { + updateProfile(id: string, input: UpdateProfileInput): Promise { + return this.writeProfile(id, input, true) + } + + private async writeProfile( + id: string, + input: UpdateProfileInput, + retryOnLostRename: boolean, + ): Promise { const current = await this.findById(id) if (!current) throw AppError.notFound("User not found.") @@ -254,6 +262,14 @@ export class PgUserStore implements UserStore { } set.socialLinks = Object.keys(clean).length > 0 ? clean : null } + // The cooldown was decided on the handle read above; pinning the write to that handle means a + // concurrent rename that landed first makes this one miss, and the retry re-decides on fresh state. + const renameGuard = + set.handle === undefined + ? undefined + : current.handle === null + ? isNull(users.handle) + : eq(users.handle, current.handle) const avatarUploadId = input.avatarUploadId let updated: (typeof users.$inferSelect)[] try { @@ -272,7 +288,7 @@ export class PgUserStore implements UserStore { return tx .update(users) .set(set) - .where(and(eq(users.id, id), isNull(users.deletedAt))) + .where(and(eq(users.id, id), isNull(users.deletedAt), renameGuard)) .returning() }) } catch (err) { @@ -280,7 +296,10 @@ export class PgUserStore implements UserStore { throw err } const r = updated[0] - if (!r) throw AppError.notFound("User not found.") + if (!r) { + if (renameGuard !== undefined && retryOnLostRename) return this.writeProfile(id, input, false) + throw AppError.notFound("User not found.") + } return toUserRecord(r) } diff --git a/services/api/src/auth/session-service.ts b/services/api/src/auth/session-service.ts index c9dbbd9c..b6ed1e9f 100644 --- a/services/api/src/auth/session-service.ts +++ b/services/api/src/auth/session-service.ts @@ -58,6 +58,10 @@ interface UserGate { const REVOKED_STATUSES: ReadonlySet = new Set(["banned"]) +function isMintRefused(status: AccountStatus): boolean { + return status === "banned" || status === "suspended" +} + export interface SessionUserLookup { accountStatus(id: string): Promise findById(id: string): Promise<{ role: Role } | null> @@ -129,7 +133,7 @@ export class SessionService { ? this.users.accountStatus(userId) : Promise.resolve(meta.accountStatus ?? "active"), ]) - if (mintStatus === "banned" || mintStatus === "suspended") { + if (isMintRefused(mintStatus)) { throw AppError.forbidden("This account cannot start a new session.") } await this.store.insert({ @@ -142,6 +146,10 @@ export class SessionService { ip: meta.ip ?? null, }) + // A suspension bumps the epoch BEFORE it deletes the user's rows, so an insert that lands after + // that delete always observes a moved epoch here; only then is the status re-read. + const settled = await this.settleMint(userId, hash, epoch, mintStatus) + await this.writeCache( hash, { @@ -149,14 +157,30 @@ export class SessionService { roles: [...roles], expiresAtMs: expiresAt.getTime(), createdAtMs: nowMs, - epoch, - accountStatus: mintStatus, + epoch: settled.epoch, + accountStatus: settled.status, }, nowMs, ) return token } + private async settleMint( + userId: string, + hash: string, + mintEpoch: number, + mintStatus: AccountStatus, + ): Promise<{ epoch: number; status: AccountStatus }> { + const epoch = await this.currentEpoch(userId) + if (epoch === mintEpoch || !this.users) return { epoch, status: mintStatus } + const status = await this.users.accountStatus(userId) + if (isMintRefused(status)) { + await this.store.deleteById(hash) + throw AppError.forbidden("This account cannot start a new session.") + } + return { epoch, status } + } + async resolveSession(token: string): Promise { return this.resolveSessionByHash(await sha256Hex(token)) } @@ -185,7 +209,7 @@ export class SessionService { } } } - await this.cache.del(sessionKey(hash)).catch(() => {}) + await this.evictSessionCache(hash, "rejected session cache eviction failed") } const row = await this.store.findById(hash) @@ -324,7 +348,15 @@ export class SessionService { private async expireSession(hash: string): Promise { await this.store.deleteById(hash) - await this.cache.del(sessionKey(hash)).catch(() => {}) + await this.evictSessionCache(hash, "expired session cache eviction failed") + } + + // Fail-open on purpose: a projection that failed its checks keeps failing them on every later + // read (expiry, epoch and ban are re-checked each time), so a stranded entry never authenticates. + private async evictSessionCache(hash: string, msg: string): Promise { + await this.cache.del(sessionKey(hash)).catch((err: unknown) => { + this.logger?.error({ hash, err }, msg) + }) } private async maybeSlide( @@ -377,6 +409,7 @@ export class SessionService { } return null } catch { + // A corrupt entry is treated as a cache miss, so the durable row is re-read and re-checked. return null } } diff --git a/services/api/src/auth/ws-ticket.ts b/services/api/src/auth/ws-ticket.ts index 5e2e6715..e7f20280 100644 --- a/services/api/src/auth/ws-ticket.ts +++ b/services/api/src/auth/ws-ticket.ts @@ -25,6 +25,7 @@ function decodePayload(raw: string): WsTicketPayload | null { if (typeof parsed.u !== "string" || parsed.u.length === 0) return null return { userId: parsed.u, sessionHash: typeof parsed.s === "string" ? parsed.s : null } } catch { + // Fail closed: a ticket whose payload cannot be read never authenticates a socket. return null } } diff --git a/services/api/src/db/backfill-keyset.ts b/services/api/src/db/backfill-keyset.ts index ea255f32..b0199def 100644 --- a/services/api/src/db/backfill-keyset.ts +++ b/services/api/src/db/backfill-keyset.ts @@ -20,6 +20,7 @@ import type postgres from "postgres" import type { Queryable, Sql } from "./client.js" +import { TIME_CURSOR_SQL_FORMAT } from "./cursor-helpers.js" import { UNKNOWN_JURCODE } from "./reference-code.js" import { JURISDICTION_RESOLVE_ORDER_BY } from "./sql/jurisdiction.js" @@ -115,8 +116,10 @@ export async function resolveGeomJurisdictions( /** The columns every reference-code loop selects; `Row` adds whatever else its allocator needs. */ export interface ReferenceCodeRow { id: string - /** timestamptz — postgres.js returns a Date, and the driver serializes it back for the cursor bound. */ created_at: Date + // The keyset bound. A millisecond Date bound sits below its own row, so a row that keeps failing at + // the tail would be re-selected forever. + cursor_at: string | null /** jurisdictions.code joined through jurisdiction_geoid; NULL when unresolved or the row has no code. */ jur_code: number | null } @@ -147,15 +150,19 @@ export async function stampReferenceCodes( let stamped = 0 let failed = 0 // Keyset cursor over (created_at, id). NULL on the first page (no lower bound). - let cursor: { createdAt: Date; id: string } | null = null + let cursor: { at: string | null; id: string } | null = null const extraColumns: SqlFragment = spec.extraColumn === null ? sql`` : sql`, t.${sql(spec.extraColumn)}` for (;;) { const cursorFilter: SqlFragment = - cursor === null ? sql`` : sql`AND (t.created_at, t.id) > (${cursor.createdAt}, ${cursor.id})` + cursor === null + ? sql`` + : sql`AND (t.created_at, t.id) > (${cursor.at}::timestamptz, ${cursor.id})` const batch = await sql` - SELECT t.id, t.created_at, j.code AS jur_code${extraColumns} + SELECT t.id, t.created_at, + to_char(t.created_at AT TIME ZONE 'UTC', ${TIME_CURSOR_SQL_FORMAT}) AS cursor_at, + j.code AS jur_code${extraColumns} FROM ${sql(spec.table)} t LEFT JOIN jurisdictions j ON j.geoid = t.jurisdiction_geoid WHERE t.reference_code IS NULL @@ -184,7 +191,7 @@ export async function stampReferenceCodes( } const last = batch[batch.length - 1]! - cursor = { createdAt: last.created_at, id: last.id } + cursor = { at: last.cursor_at, id: last.id } console.log( `${spec.label}: ${spec.table} batch of ${batch.length} (running stamped=${stamped}, failed=${failed})`, ) diff --git a/services/api/src/db/backfill-signup-seats.ts b/services/api/src/db/backfill-signup-seats.ts index eb4c2c14..dbd7a352 100644 --- a/services/api/src/db/backfill-signup-seats.ts +++ b/services/api/src/db/backfill-signup-seats.ts @@ -32,6 +32,20 @@ type SqlFragment = postgres.Fragment export const SIGNUP_SEAT_BACKFILL_BATCH = 500 +export const SIGNUP_SEAT_BACKFILL_MAX_BATCH = 5000 + +const BATCH_FLAG = "--batch" + +// undefined = flag absent (use the default); null = a value that is not a usable LIMIT. +export function parseSignupSeatBatchArg(argv: readonly string[]): number | undefined | null { + const at = argv.indexOf(BATCH_FLAG) + if (at < 0) return undefined + const raw = argv[at + 1] + if (raw === undefined || !/^\d+$/.test(raw)) return null + const size = Number(raw) + return size >= 1 && size <= SIGNUP_SEAT_BACKFILL_MAX_BATCH ? size : null +} + class RehearsalRollback extends Error { constructor(readonly written: number) { super("rehearsal rollback") @@ -203,9 +217,13 @@ export async function backfillSignupSeats( export async function main(): Promise { const commit = process.argv.includes("--yes") - const batchIndex = process.argv.indexOf("--batch") - const batchSize = - batchIndex >= 0 ? Number(process.argv[batchIndex + 1] ?? SIGNUP_SEAT_BACKFILL_BATCH) : undefined + const batchSize = parseSignupSeatBatchArg(process.argv) + if (batchSize === null) { + console.error( + `backfill-signup-seats: ${BATCH_FLAG} takes an integer from 1 to ${SIGNUP_SEAT_BACKFILL_MAX_BATCH}`, + ) + process.exit(2) + } const signer = makeTicketTokenSigner(loadEnv().TICKET_TOKEN_SECRET.trim()) console.log( diff --git a/services/api/src/db/client.ts b/services/api/src/db/client.ts index e87e64c0..9d36e195 100644 --- a/services/api/src/db/client.ts +++ b/services/api/src/db/client.ts @@ -33,6 +33,7 @@ export function sslOptionForUrl(databaseUrl: string): DbSslOption { try { mode = new URL(databaseUrl).searchParams.get("sslmode") } catch { + // An unparseable URL cannot connect at all, and loadEnv has already vetted production's sslmode. mode = null } switch (mode?.trim().toLowerCase()) { diff --git a/services/api/src/db/cursor-helpers.ts b/services/api/src/db/cursor-helpers.ts index 1d87908f..1db551c3 100644 --- a/services/api/src/db/cursor-helpers.ts +++ b/services/api/src/db/cursor-helpers.ts @@ -1,3 +1,6 @@ +import type postgres from "postgres" +import type { Queryable } from "./client.js" + export const CURSOR_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i export function isUuid(v: string): boolean { @@ -15,6 +18,10 @@ export const MAX_UUID = "ffffffff-ffff-ffff-ffff-ffffffffffff" export const CURSOR_ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/ +// Postgres to_char pattern for a timestamptz rendered in UTC at full microsecond precision. A keyset +// cursor built from the millisecond Date that postgres.js returns sits below the row it came from. +export const TIME_CURSOR_SQL_FORMAT = 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"' + export const CURSOR_MIN_MS = Date.UTC(1970, 0, 1) export const CURSOR_MAX_MS = Date.UTC(2100, 0, 1) @@ -51,6 +58,109 @@ export function encodeTimeCursor(c: TimeCursor): string { return `${c.at.toISOString()}|${c.id}` } +/** + * A time cursor that also keeps the instant exactly as the cursor spelled it. Keyset queries bind + * `atText`, never `at`: postgres.js serializes a Date param with toISOString(), which drops the + * microseconds a timestamptz carries, so a Date-bound anchor skips (DESC) or repeats (ASC) every row + * sharing the anchor's millisecond, including every row one transaction's now() stamped. + */ +export interface KeysetCursor extends TimeCursor { + atText: string +} + +const CURSOR_INSTANT_PARTS_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(?:Z|([+-])(\d{2}):(\d{2}))$/ + +// Postgres refuses a timestamptz input whose UTC offset is past 15:59 (22009), while JS Date takes +// anything up to 23:59. +const MAX_CURSOR_OFFSET_MINUTES = 15 * 60 + 59 +const MINUTE_MS = 60_000 + +function pad(n: number, width = 2): string { + return String(n).padStart(width, "0") +} + +/** + * The cursor instant respelled in UTC from the parsed Date, keeping the input's fraction digits so a + * microsecond anchor survives. Null when the text is not a real wall-clock time: JS Date rolls + * impossible fields forward (Feb 30 becomes Mar 2, hour 24 the next day) where Postgres raises 22008, + * so the text fields must match the Date they produced. + */ +function canonicalCursorInstant(text: string, at: Date): string | null { + const parts = CURSOR_INSTANT_PARTS_RE.exec(text) + if (parts === null) return null + const [, year, month, day, hour, minute, second, fraction, sign, offsetH, offsetM] = parts + let offsetMinutes = 0 + if (sign !== undefined) { + const offsetMinutePart = Number(offsetM) + if (offsetMinutePart > 59) return null + offsetMinutes = (Number(offsetH) * 60 + offsetMinutePart) * (sign === "-" ? -1 : 1) + if (Math.abs(offsetMinutes) > MAX_CURSOR_OFFSET_MINUTES) return null + } + const wall = new Date(at.getTime() + offsetMinutes * MINUTE_MS) + if ( + wall.getUTCFullYear() !== Number(year) || + wall.getUTCMonth() + 1 !== Number(month) || + wall.getUTCDate() !== Number(day) || + wall.getUTCHours() !== Number(hour) || + wall.getUTCMinutes() !== Number(minute) || + wall.getUTCSeconds() !== Number(second) + ) { + return null + } + const date = `${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())}` + const time = `${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())}` + return `${date}T${time}${fraction !== undefined ? `.${fraction}` : ""}Z` +} + +/** + * parseTimeCursor, plus the instant as text for keysetPredicate to bind. The text is rebuilt from the + * validated instant, never passed through: it reaches Postgres as `::timestamptz`, and a forged value + * Postgres refuses would surface as a 500 instead of the first page. A legacy millisecond cursor + * rebuilds to the same text and binds the instant it always meant. + */ +export function parseKeysetCursor( + cursor: string | null | undefined, + opts?: { requireUuid?: boolean; direction?: "asc" | "desc" }, +): KeysetCursor | null { + const parsed = parseTimeCursor(cursor, opts) + if (parsed === null || cursor === null || cursor === undefined) return null + const bar = cursor.indexOf("|") + const atText = canonicalCursorInstant(bar < 0 ? cursor : cursor.slice(0, bar), parsed.at) + if (atText === null) return null + return { ...parsed, atText } +} + +export function encodeKeysetCursor(atText: string, id: string): string { + return `${atText}|${id}` +} + +/** + * The column's instant rendered by Postgres at microsecond precision, in the ISO shape the cursor + * parser accepts. Select it next to the keyset column and encode the cursor from it (paginateKeyset). + */ +export function keysetInstant(sql: Queryable, column: postgres.Fragment): postgres.Fragment { + return sql`to_char(${column} AT TIME ZONE 'UTC', ${TIME_CURSOR_SQL_FORMAT})` +} + +/** + * `(ts, id) < anchor` (or `>` for an ascending list), with the anchor instant cast from its text so the + * comparison is exact. The bare column stays on the left so a (ts, id) index still serves the scan. + */ +export function keysetPredicate( + sql: Queryable, + ts: postgres.Fragment, + id: postgres.Fragment, + anchor: { atText: string; id: string }, + opts: { direction?: "asc" | "desc"; idType?: "uuid" | "text" } = {}, +): postgres.Fragment { + const anchorAt = sql`${anchor.atText}::timestamptz` + const anchorId = opts.idType === "text" ? sql`${anchor.id}::text` : sql`${anchor.id}::uuid` + return opts.direction === "asc" + ? sql`(${ts}, ${id}) > (${anchorAt}, ${anchorId})` + : sql`(${ts}, ${id}) < (${anchorAt}, ${anchorId})` +} + export interface NameCursor { name: string id: string @@ -114,3 +224,18 @@ export function paginate( return at !== undefined ? encodeTimeCursor({ at, id: anchor.id }) : null }) } + +/** + * paginate() for rows that carry the keysetInstant text. A row whose instant is null yields no cursor, + * matching paginate() for a missing Date. + */ +export function paginateKeyset( + rows: readonly T[], + limit: number, + pick: (row: T) => { atText: string | null; id: string }, +): { items: T[]; nextCursor: string | null } { + return pageWith(rows, limit, (last) => { + const anchor = pick(last) + return anchor.atText !== null ? encodeKeysetCursor(anchor.atText, anchor.id) : null + }) +} diff --git a/services/api/src/db/demo-join-event.ts b/services/api/src/db/demo-join-event.ts index 9223f654..1c3e16a5 100644 --- a/services/api/src/db/demo-join-event.ts +++ b/services/api/src/db/demo-join-event.ts @@ -1,6 +1,7 @@ /** * demo-join-event: joins a subset of the seeded demo users (seed-demo-la.ts) to ONE existing event, - * exactly the way the live join path does — a cleanup_members row per user (role 'member'), refusing + * exactly the way the live join path does — a cleanup_members row per user (role 'member') plus, on an + * event with no ticket types, the free registration + seat the roster and check-in read, refusing * closed (done/cancelled) events and banned users, and never exceeding the event's RSVP capacity * (counting existing members + non-cancelled guests, the same sum goingCount uses). If the event has * signup slots with open capacity, some joiners also claim one (one slot per person, capacity @@ -11,7 +12,11 @@ * SAFETY: same stance as seed-demo-la — the default run is a REHEARSAL (everything runs in one * transaction, prints what it would do, then rolls back). Pass --yes to commit. * - * Usage (inside the api container, or anywhere with DATABASE_URL): + * The seat token hash is keyed by TICKET_TOKEN_SECRET, resolved exactly as the API resolves it (the + * development fallback included), or the demo seats will not scan at check-in; the api container + * already carries it. + * + * Usage (inside the api container, or anywhere with DATABASE_URL and TICKET_TOKEN_SECRET): * node dist/db/demo-join-event.js --event 1695-000006 # rehearse * node dist/db/demo-join-event.js --event 1695-000006 --yes # commit * --event accepts the EVENT reference code with or without the "EVENT-" prefix, or the cleanup's @@ -21,6 +26,7 @@ import { makeDb, type TransactionSql } from "./client.js" import { runIfMain } from "./cli.js" import { DEMO_EMAIL_DOMAIN } from "./seed-demo-domain.js" +import { demoTicketTokenHasher, mintDemoSignupSeats } from "./demo-signup-seats.js" import { deriveCleanupStatus, eventWindowOf } from "../services/cleanup-rules.js" // --- deterministic PRNG (mulberry32), same generator seed-demo-la uses --------------------------- @@ -113,6 +119,7 @@ export async function main(): Promise { throw new Error("--count must be 1..200") const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) throw new Error("DATABASE_URL is required") + const hashFor = demoTicketTokenHasher() console.log(`target database: ${new URL(databaseUrl).host}`) console.log( @@ -197,6 +204,11 @@ export async function main(): Promise { })) .sort((a, b) => a.joined_at.getTime() - b.joined_at.getTime()) await tx`INSERT INTO cleanup_members ${tx(memberRows)}` + const seats = await mintDemoSignupSeats(tx, { + cleanupId: event.id, + members: memberRows, + hashFor, + }) // Some joiners claim an open signup slot, respecting per-slot capacity + one-claim-per-person. const slots = await tx< @@ -228,7 +240,8 @@ export async function main(): Promise { const handles = joiners.map((u) => `@${u.handle}`).join(", ") console.log( - `joining ${memberRows.length} demo users${claimed > 0 ? ` (${claimed} slot claims)` : ""}:`, + `joining ${memberRows.length} demo users (${seats} registrations)` + + `${claimed > 0 ? ` (${claimed} slot claims)` : ""}:`, ) console.log(` ${handles}`) diff --git a/services/api/src/db/demo-signup-seats.ts b/services/api/src/db/demo-signup-seats.ts new file mode 100644 index 00000000..9fdda16f --- /dev/null +++ b/services/api/src/db/demo-signup-seats.ts @@ -0,0 +1,60 @@ +/** + * The free registration + seat minting both demo CLIs share (seed-demo-la.ts, demo-join-event.ts). + * Guard-free module (no run-as-main CLI guard) for the same reason as seed-demo-domain.ts: tsup + * (splitting: false) inlines imports into each bundled entry, so a runIfMain guard in an imported + * CLI fires inside the importing bundle and runs the wrong main. + */ + +import { randomUUID } from "node:crypto" +import type { Queryable } from "./client.js" +import { ensureSignupRegistrationIn } from "../services/cleanup-repository.drizzle.js" +import { makeTicketTokenSigner } from "../services/host/ticket-token.js" +import { loadRegistrationEnv } from "../env/registration-env.js" + +const TICKET_TOKEN_SECRET_KEY = "TICKET_TOKEN_SECRET" + +/** + * Resolves the secret exactly as the API does (including its development fallback), because a seat + * hashed with any other secret never scans at check-in. + */ +export function demoTicketTokenHasher( + source: Record = process.env, +): (seatId: string) => string { + const errors: string[] = [] + const secret = loadRegistrationEnv(source, errors).TICKET_TOKEN_SECRET.trim() + const secretErrors = errors.filter((error) => error.startsWith(`${TICKET_TOKEN_SECRET_KEY}:`)) + if (secretErrors.length > 0 || secret === "") { + throw new Error( + [ + `${TICKET_TOKEN_SECRET_KEY} must be the API's own secret so demo seats scan`, + ...secretErrors, + ].join("; "), + ) + } + const signer = makeTicketTokenSigner(secret) + return (seatId) => signer.hashFor(seatId) +} + +/** Mint the live join path's free registration + seat for each member; a no-op on ticketed events. */ +export async function mintDemoSignupSeats( + tx: Queryable, + args: { + cleanupId: string + members: readonly { user_id: string; joined_at: Date }[] + hashFor: (seatId: string) => string + }, +): Promise { + let minted = 0 + for (const member of args.members) { + const seatId = randomUUID() + const registrationId = await ensureSignupRegistrationIn(tx, { + cleanupId: args.cleanupId, + userId: member.user_id, + seatId, + tokenHash: args.hashFor(seatId), + now: member.joined_at, + }) + if (registrationId !== null) minted += 1 + } + return minted +} diff --git a/services/api/src/db/ingest-jurisdictions-core.ts b/services/api/src/db/ingest-jurisdictions-core.ts index fb80de67..25cb79df 100644 --- a/services/api/src/db/ingest-jurisdictions-core.ts +++ b/services/api/src/db/ingest-jurisdictions-core.ts @@ -48,6 +48,10 @@ export const LAYER_RANK: Record = { state: 2, } +export function isIngestLayer(value: string): value is IngestRow["layer"] { + return Object.hasOwn(LAYER_RANK, value) +} + const UPSERT_BATCH_SIZE = 1000 export function normalizeFeature( @@ -69,10 +73,8 @@ export function normalizeFeature( const prefixedGeoid = geoid !== null && geoidPrefix ? geoidPrefix + geoid : geoid const name = pickString(f.properties, ["name", "NAME", "UNIT_NAME", "unit_name", "Unit_Name"]) const rawLayer = pickString(f.properties, ["layer", "LAYER", "owner_type", "Own_Type"]) - const layer = - rawLayer && rawLayer.toLowerCase() in LAYER_RANK - ? (rawLayer.toLowerCase() as IngestRow["layer"]) - : defaultLayer + const loweredLayer = rawLayer?.toLowerCase() ?? null + const layer = loweredLayer !== null && isIngestLayer(loweredLayer) ? loweredLayer : defaultLayer if (!isPolygon || prefixedGeoid === null || name === null) return null return { geoid: prefixedGeoid, diff --git a/services/api/src/db/ingest-jurisdictions.ts b/services/api/src/db/ingest-jurisdictions.ts index 403cbb6b..29390f54 100644 --- a/services/api/src/db/ingest-jurisdictions.ts +++ b/services/api/src/db/ingest-jurisdictions.ts @@ -13,7 +13,12 @@ import { readFile } from "node:fs/promises" import { runDbCli, runIfMain } from "./cli.js" -import { LAYER_RANK, ingestGeoJsonFile, type IngestRow } from "./ingest-jurisdictions-core.js" +import { + LAYER_RANK, + ingestGeoJsonFile, + isIngestLayer, + type IngestRow, +} from "./ingest-jurisdictions-core.js" // Re-export the core API from the historical path so existing importers (e.g. the unit test importing // `normalizeFeatures` from this module) keep resolving against this module. @@ -35,7 +40,7 @@ async function main(): Promise { ) process.exit(2) } - if (!(defaultLayer in LAYER_RANK)) { + if (!isIngestLayer(defaultLayer)) { console.error( `ingest: unknown layer "${defaultLayer}" (expected one of ${Object.keys(LAYER_RANK).join(", ")})`, ) diff --git a/services/api/src/db/migrate.ts b/services/api/src/db/migrate.ts index bb34c532..3994c5a0 100644 --- a/services/api/src/db/migrate.ts +++ b/services/api/src/db/migrate.ts @@ -69,7 +69,11 @@ async function appliedSet(sql: Sql): Promise> { */ export async function applyMigrations(sql: Sql, dir: string = MIGRATIONS_DIR): Promise { const reserved = await sql.reserve() + let sessionClean = true + let backendPid: number | null = null try { + const pidRows = await reserved<{ pid: number }[]>`SELECT pg_backend_pid() AS pid` + backendPid = pidRows[0]?.pid ?? null await reserved`SELECT pg_advisory_lock(${MIGRATE_ADVISORY_LOCK_KEY})` await ensureBookkeeping(reserved) const already = await appliedSet(reserved) @@ -90,18 +94,41 @@ export async function applyMigrations(sql: Sql, dir: string = MIGRATIONS_DIR): P await reserved`INSERT INTO _civfix_migrations (name) VALUES (${name})` await reserved.unsafe("commit") } catch (err) { - await reserved.unsafe("rollback").catch(() => {}) + await reserved.unsafe("rollback").catch((rollbackErr: unknown) => { + sessionClean = false + console.error(`migrate: rollback after ${name} failed`, rollbackErr) + }) throw err } applied.push(name) } return applied } finally { - await reserved`SELECT pg_advisory_unlock(${MIGRATE_ADVISORY_LOCK_KEY})`.catch(() => {}) - reserved.release() + await reserved`SELECT pg_advisory_unlock(${MIGRATE_ADVISORY_LOCK_KEY})`.catch( + (unlockErr: unknown) => { + sessionClean = false + console.error("migrate: releasing the migration advisory lock failed", unlockErr) + }, + ) + if (sessionClean) { + reserved.release() + } else { + await discardSession(sql, backendPid) + } } } +// postgres.js cannot close a single reserved connection, and releasing it would park a session that +// may be mid-transaction and may still hold the session-level migration lock in the pool, where the +// next applyMigrations would wait on that lock forever. Ending the backend from another pooled +// connection frees the lock server-side; the reserved slot is deliberately never returned. +async function discardSession(sql: Sql, backendPid: number | null): Promise { + if (backendPid === null) return + await sql`SELECT pg_terminate_backend(${backendPid})`.catch((terminateErr: unknown) => { + console.error(`migrate: terminating backend ${backendPid} failed`, terminateErr) + }) +} + async function main(): Promise { await runDbCli(async (_db, sql) => { const applied = await applyMigrations(sql) diff --git a/services/api/src/db/schema/inbound_bounce_attempts.ts b/services/api/src/db/schema/inbound_bounce_attempts.ts new file mode 100644 index 00000000..25d77c8f --- /dev/null +++ b/services/api/src/db/schema/inbound_bounce_attempts.ts @@ -0,0 +1,10 @@ +import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core" + +export const inboundBounceAttempts = pgTable("inbound_bounce_attempts", { + objectKey: text("object_key").primaryKey(), + attempts: integer("attempts").notNull().default(0), + lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }).notNull().defaultNow(), +}) + +export type InboundBounceAttemptRow = typeof inboundBounceAttempts.$inferSelect +export type NewInboundBounceAttemptRow = typeof inboundBounceAttempts.$inferInsert diff --git a/services/api/src/db/schema/index.ts b/services/api/src/db/schema/index.ts index db23f70a..870d3ca5 100644 --- a/services/api/src/db/schema/index.ts +++ b/services/api/src/db/schema/index.ts @@ -64,6 +64,7 @@ export * from "./moderation_items.js" export * from "./mail.js" export * from "./forward-template-settings.js" export * from "./inbound_emails.js" +export * from "./inbound_bounce_attempts.js" export * from "./outreach_state.js" export * from "./cleanup_timeline.js" export * from "./media_reap_tombstones.js" diff --git a/services/api/src/db/schema/media.ts b/services/api/src/db/schema/media.ts index 9c69086a..3a951fc9 100644 --- a/services/api/src/db/schema/media.ts +++ b/services/api/src/db/schema/media.ts @@ -42,6 +42,7 @@ export const mediaAssets = pgTable( byteSize: bigint("byte_size", { mode: "number" }), phash: text("phash"), finalizedAt: timestamp("finalized_at", { withTimezone: true }), + uploadEtag: text("upload_etag"), stuckCheckedAt: timestamp("stuck_checked_at", { withTimezone: true }), stuckCheckCount: integer("stuck_check_count").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), diff --git a/services/api/src/db/seed-demo-la.ts b/services/api/src/db/seed-demo-la.ts index 79854340..d1fb7079 100644 --- a/services/api/src/db/seed-demo-la.ts +++ b/services/api/src/db/seed-demo-la.ts @@ -16,8 +16,10 @@ * - Volunteer hours follow the logEventHours shape: an 'event'-source volunteer_hours row per * credited attendee, the user_jurisdiction_hours rollup upsert, and one volunteer_hours_audit row. * - * CLOSED WORLD: all follows / likes / replies / memberships stay inside the seeded cohort, so no real - * user's counters are ever touched and --purge removes everything without fixups. + * CLOSED WORLD: all follows / likes / replies / memberships the seeder writes stay inside the seeded + * cohort. Real users can still interact with demo content once it is live, so --purge recomputes the + * counters of every real user and post a demo account touched, and refuses (naming the rows) when real + * replies, reposts or volunteer hours depend on demo content. * * SAFETY: the default run is a REHEARSAL — the entire seed executes in one transaction, the * verification queries run, and then everything rolls back. Pass --yes to commit. Seeded accounts use @@ -32,7 +34,9 @@ * Optional: --users N (default 250), --seed N (PRNG seed, default 20260902). * * Reads DATABASE_URL directly (not loadEnv) so it can run from a minimal shell; sslmode on the URL is - * honored by makeDb exactly as the API does. + * honored by makeDb exactly as the API does. A seed (not --purge) also needs TICKET_TOKEN_SECRET, + * resolved as the API resolves it (development fallback included), because members of upcoming events + * get the free registration + seat a live sign-up mints. */ import { randomUUID } from "node:crypto" @@ -48,6 +52,12 @@ import { reportH3Cell } from "../services/report-clustering.js" import { runIfMain } from "./cli.js" import { DEMO_EMAIL_DOMAIN } from "./seed-demo-domain.js" import { DEFAULT_EVENT_DURATION_MS, DEFAULT_EVENT_SLOT_TITLE } from "../services/cleanup-rules.js" +import { demoTicketTokenHasher, mintDemoSignupSeats } from "./demo-signup-seats.js" +import { touchUserActivity } from "./sql/user-activity.js" + +// Seeded addresses are typed by hand, the provenance the live create paths record for that case. +const SEEDED_REPORT_ADDR_SOURCE = "user" +const SEEDED_EVENT_ADDRESS_SOURCE = "manual" // --------------------------------------------------------------------------------------------------- // Deterministic PRNG (mulberry32) + sampling helpers. Seeded so a rehearsal and the committed run (or @@ -122,13 +132,39 @@ function sampleWeighted( return out } -// --------------------------------------------------------------------------------------------------- -// Time helpers. Timestamps get an LA-plausible time-of-day (evenings and weekends heavier), stored as -// UTC (LA is UTC-7 during the seeded window, which is entirely inside PDT). -// --------------------------------------------------------------------------------------------------- +// Timestamps get an LA-plausible time-of-day (evenings and weekends heavier), stored as UTC. The +// seeded window spans both PST and PDT, so the offset is resolved per date. const DAY = 24 * 60 * 60 * 1000 -const LA_UTC_OFFSET_HOURS = 7 +const HOUR = 60 * 60 * 1000 +const LA_TIME_ZONE = "America/Los_Angeles" +const LA_STANDARD_OFFSET_HOURS = -8 + +const LA_OFFSET_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: LA_TIME_ZONE, + timeZoneName: "shortOffset", +}) + +function laOffsetHoursAt(at: Date): number { + const name = LA_OFFSET_FORMAT.formatToParts(at).find((p) => p.type === "timeZoneName")?.value + const m = /^GMT([+-]\d{1,2})$/.exec(name ?? "") + return m ? Number(m[1]) : LA_STANDARD_OFFSET_HOURS +} + +/** The UTC instant of hour:minute:second LA wall-clock time on the UTC calendar day of `dayMs`. */ +export function laLocalToUtc(dayMs: number, hour: number, minute: number, second: number): Date { + const d = new Date(dayMs) + const wallAsUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + hour, + minute, + second, + ) + const offset = laOffsetHoursAt(new Date(wallAsUtc - LA_STANDARD_OFFSET_HOURS * HOUR)) + return new Date(wallAsUtc - offset * HOUR) +} /** Weighted local hour: mornings light, lunchtime medium, evenings heavy, small overnight tail. */ function localHour(): number { @@ -158,14 +194,10 @@ function localHour(): number { /** A timestamp on the given local calendar day with a realistic local time, converted to UTC. */ function atLocalTime(dayMs: number): Date { - const d = new Date(dayMs) - d.setUTCHours(0, 0, 0, 0) - const ms = - d.getTime() + - (localHour() + LA_UTC_OFFSET_HOURS) * 3600_000 + - rint(0, 59) * 60_000 + - rint(0, 59) * 1000 - return new Date(ms) + const hour = localHour() + const minute = rint(0, 59) + const second = rint(0, 59) + return laLocalToUtc(dayMs, hour, minute, second) } /** Random realistic timestamp in [start, end], weekend-boosted. */ @@ -1558,8 +1590,7 @@ function nextSaturdayish(base: Date, minDays: number, latest?: Date): Date { } } if (latest && d.getTime() >= latest.getTime()) d = new Date(latest.getTime() - DAY) - d.setUTCHours(9 + LA_UTC_OFFSET_HOURS, pick([0, 0, 30]), 0, 0) - return d + return laLocalToUtc(d.getTime(), 9, pick([0, 0, 30]), 0) } function makeReports(users: SeedUser[], count: number, now: Date): SeedReport[] { @@ -2125,9 +2156,11 @@ function chunk(arr: T[], size: number): T[][] { return out } -async function writeAll( +export async function writeAll( tx: TransactionSql, data: { + now: Date + hashFor: (seatId: string) => string users: SeedUser[] follows: SeedFollow[] events: SeedEvent[] @@ -2138,7 +2171,7 @@ async function writeAll( hours: SeedHours[] }, ): Promise { - const { users, follows, events, reports, posts, likes, saves, hours } = data + const { now, users, follows, events, reports, posts, likes, saves, hours } = data const resolver = tx as unknown as Sql // Users + prefs. @@ -2199,16 +2232,23 @@ async function writeAll( await tx` INSERT INTO cleanups ( id, organizer_user_id, type, event_kind, title, description, geom, scheduled_at, ends_at, - status, bring, address, capacity, bags, jurisdiction_geoid, reference_code, created_at + status, bring, address, address_source, capacity, bags, jurisdiction_geoid, reference_code, + created_at ) VALUES ( ${ev.id}, ${ev.organizer.id}, 'site', 'cleanup', ${ev.title}, ${ev.description}, ST_SetSRID(ST_MakePoint(${ev.lng}, ${ev.lat}), 4326), ${ev.scheduledAt}, ${ev.endsAt}, ${ev.status === "cancelled" ? "cancelled" : "upcoming"}, - ${ev.bring}, ${ev.address}, ${ev.capacity}, ${ev.bags}, + ${ev.bring}, ${ev.address}, ${SEEDED_EVENT_ADDRESS_SOURCE}, ${ev.capacity}, ${ev.bags}, ${jur?.geoid ?? null}, ${referenceCode}, ${ev.createdAt} ) ` + await touchUserActivity(tx, { + userId: ev.organizer.id, + lng: ev.lng, + lat: ev.lat, + at: ev.createdAt, + }) // Stamp resolved geoid onto pending hours rows for this event. for (const h of hours) if (h.cleanupId === ev.id) h.jurisdictionGeoid = jur?.geoid ?? null } @@ -2234,6 +2274,15 @@ async function writeAll( })), )}` } + // Same scope as the signup-seat backfill: a seat matters only while the event can still be + // checked into, so events that ended or were cancelled keep membership alone. + if (ev.status !== "cancelled" && ev.endsAt.getTime() > now.getTime()) { + await mintDemoSignupSeats(tx, { + cleanupId: ev.id, + members: ev.members.map((m) => ({ user_id: m.user.id, joined_at: m.joinedAt })), + hashFor: data.hashFor, + }) + } if (ev.claims.length > 0) { await tx`INSERT INTO cleanup_slot_claims ${tx( ev.claims.map((c) => ({ @@ -2254,15 +2303,18 @@ async function writeAll( await tx` INSERT INTO reports ( id, reporter_user_id, idempotency_key, geom, geom_source, jurisdiction_geoid, category, type, - title, description, addr, status, visibility, h3_cell, reference_code, created_at, published_at + title, description, addr, addr_source, status, visibility, h3_cell, reference_code, + created_at, published_at ) VALUES ( ${r.id}, ${r.reporter.id}, ${randomUUID()}, ST_SetSRID(ST_MakePoint(${r.lng}, ${r.lat}), 4326), ${r.geomSource}, ${jur?.geoid ?? null}, ${REPORT_TYPE_TO_CATEGORY[r.type]}, ${r.type}, - ${r.title}, ${r.description}, ${r.addr}, ${r.status}, 'public', - ${reportH3Cell(r.lat, r.lng)}, ${referenceCode}, ${r.createdAt}, ${r.publishedAt} + ${r.title}, ${r.description}, ${r.addr}, ${r.addr === null ? null : SEEDED_REPORT_ADDR_SOURCE}, + ${r.status}, 'public', ${reportH3Cell(r.lat, r.lng)}, ${referenceCode}, ${r.createdAt}, + ${r.publishedAt} ) ` + await touchUserActivity(tx, { userId: r.reporter.id, lng: r.lng, lat: r.lat, at: r.createdAt }) } const timelineRows = reports.flatMap((r) => r.timeline.map((t) => ({ @@ -2337,6 +2389,18 @@ async function writeAll( )}` } } + const linkedPostIds = posts + .filter((p) => p.reportId !== null || p.eventId !== null) + .map((p) => p.id) + for (const ids of chunk(linkedPostIds, 500)) { + await tx` + UPDATE posts p SET geom = COALESCE( + (SELECT r.geom FROM reports r WHERE r.id = p.report_id), + (SELECT c.geom FROM cleanups c WHERE c.id = p.event_id) + ) + WHERE p.id = ANY(${ids}::uuid[]) + ` + } const mentionRows = posts.flatMap((p) => p.mentions.map((m) => ({ post_id: p.id, mentioned_user_id: m })), ) @@ -2404,10 +2468,31 @@ async function writeAll( // SQL verification (inside the same transaction; throws -> rollback) // --------------------------------------------------------------------------------------------------- -async function verify(tx: TransactionSql): Promise { +async function verify(tx: TransactionSql, now: Date): Promise { const lines: string[] = [] const fail: string[] = [] + const demoEmail = "%@" + DEMO_EMAIL_DOMAIN const checks: { label: string; rows: Promise<{ n: number | string }[]> }[] = [ + { + label: "demo members of open events hold a registration", + rows: tx` + SELECT count(*)::int AS n FROM cleanup_members m + JOIN cleanups c ON c.id = m.cleanup_id + JOIN users u ON u.id = m.user_id + WHERE u.email LIKE ${demoEmail} AND c.status <> 'cancelled' AND c.ends_at > ${now} + AND NOT EXISTS (SELECT 1 FROM cleanup_ticket_types t WHERE t.cleanup_id = c.id) + AND NOT EXISTS ( + SELECT 1 FROM cleanup_registrations r + WHERE r.cleanup_id = c.id AND r.user_id = m.user_id AND r.status = 'registered' + )`, + }, + { + label: "demo posts linked to a report or event carry its point", + rows: tx` + SELECT count(*)::int AS n FROM posts p JOIN users u ON u.id = p.author_id + WHERE u.email LIKE ${demoEmail} AND p.geom IS NULL + AND (p.report_id IS NOT NULL OR p.event_id IS NOT NULL)`, + }, { label: "posts.like_count matches post_likes", rows: tx`SELECT count(*)::int AS n FROM posts p WHERE p.like_count <> (SELECT count(*) FROM post_likes l WHERE l.post_id = p.id)`, @@ -2471,12 +2556,96 @@ async function verify(tx: TransactionSql): Promise { // Purge // --------------------------------------------------------------------------------------------------- -async function purge(tx: TransactionSql): Promise> { +const PURGE_BLOCKER_SAMPLE = 20 + +// Real replies/reposts point at demo posts through ON DELETE RESTRICT, and real volunteer hours point +// at demo events and reports with no ON DELETE: purging would fail on the FK anyway, so name the rows +// the operator has to decide about instead. Real content is never rewritten here. +async function assertNothingRealDependsOnDemo(tx: TransactionSql): Promise { + const demo = tx`SELECT id FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN}` + const blockers = await tx<{ kind: string; id: string }[]>` + WITH demo_posts AS (SELECT id FROM posts WHERE author_id IN (${demo})) + SELECT 'post' AS kind, p.id::text AS id + FROM posts p + WHERE p.author_id NOT IN (${demo}) + AND ( + p.reply_to_id IN (SELECT id FROM demo_posts) + OR p.thread_root_id IN (SELECT id FROM demo_posts) + OR p.repost_of_id IN (SELECT id FROM demo_posts) + ) + UNION ALL + SELECT 'volunteer_hours' AS kind, vh.id::text AS id + FROM volunteer_hours vh + WHERE vh.user_id NOT IN (${demo}) + AND ( + vh.cleanup_id IN (SELECT id FROM cleanups WHERE organizer_user_id IN (${demo})) + OR vh.report_id IN (SELECT id FROM reports WHERE reporter_user_id IN (${demo})) + ) + LIMIT ${PURGE_BLOCKER_SAMPLE} + ` + if (blockers.length === 0) return + const listed = blockers.map((b) => `${b.kind} ${b.id}`).join(", ") + throw new Error( + `purge refused: real users' content depends on demo content (first ${blockers.length}): ${listed}`, + ) +} + +// Follows, likes, saves, replies and reposts by demo accounts on real accounts and posts vanish with +// the demo rows, so those real counters are recomputed from the remaining rows, with the same +// definitions verify() asserts. +async function recomputeRealCounters( + tx: TransactionSql, + users: readonly string[], + posts: readonly string[], +): Promise { + if (users.length > 0) { + await tx` + UPDATE users u SET follower_count = (SELECT count(*) FROM follows_people f WHERE f.followee_id = u.id), + following_count = (SELECT count(*) FROM follows_people f WHERE f.follower_id = u.id) + WHERE u.id = ANY(${users as string[]}::uuid[]) + ` + } + if (posts.length > 0) { + await tx` + UPDATE posts p SET like_count = (SELECT count(*) FROM post_likes l WHERE l.post_id = p.id), + save_count = (SELECT count(*) FROM post_saves s WHERE s.post_id = p.id), + reply_count = (SELECT count(*) FROM posts c WHERE c.reply_to_id = p.id AND c.deleted_at IS NULL), + repost_count = (SELECT count(*) FROM posts c WHERE c.repost_of_id = p.id AND c.kind = 'repost' AND c.deleted_at IS NULL) + WHERE p.id = ANY(${posts as string[]}::uuid[]) + ` + } +} + +export async function purgeDemo(tx: TransactionSql): Promise> { const counts: Record = {} const del = async (label: string, q: PromiseLike) => { counts[label] = (await q).length } const demo = tx`SELECT id FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN}` + await assertNothingRealDependsOnDemo(tx) + const touchedUsers = await tx<{ id: string }[]>` + SELECT DISTINCT x.id + FROM ( + SELECT f.follower_id AS id FROM follows_people f WHERE f.followee_id IN (${demo}) + UNION + SELECT f.followee_id AS id FROM follows_people f WHERE f.follower_id IN (${demo}) + ) x + WHERE x.id NOT IN (${demo}) + ` + const touchedPosts = await tx<{ id: string }[]>` + SELECT DISTINCT x.id + FROM ( + SELECT l.post_id AS id FROM post_likes l WHERE l.user_id IN (${demo}) + UNION + SELECT s.post_id AS id FROM post_saves s WHERE s.user_id IN (${demo}) + UNION + SELECT p.reply_to_id AS id FROM posts p WHERE p.author_id IN (${demo}) AND p.reply_to_id IS NOT NULL + UNION + SELECT p.repost_of_id AS id FROM posts p WHERE p.author_id IN (${demo}) AND p.repost_of_id IS NOT NULL + ) x + JOIN posts target ON target.id = x.id + WHERE target.author_id NOT IN (${demo}) + ` await del( "volunteer_hours_audit", tx`DELETE FROM volunteer_hours_audit WHERE user_id IN (${demo}) RETURNING 1 AS one`, @@ -2518,6 +2687,13 @@ async function purge(tx: TransactionSql): Promise> { "users", tx`DELETE FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN} RETURNING 1 AS one`, ) + await recomputeRealCounters( + tx, + touchedUsers.map((u) => u.id), + touchedPosts.map((p) => p.id), + ) + counts["real_users_recounted"] = touchedUsers.length + counts["real_posts_recounted"] = touchedPosts.length return counts } @@ -2555,7 +2731,7 @@ export async function main(): Promise { if (purgeMode) { const result = await handle.sql .begin(async (tx) => { - const counts = await purge(tx) + const counts = await purgeDemo(tx) if (!commit) throw ROLLBACK return counts }) @@ -2571,6 +2747,7 @@ export async function main(): Promise { return } + const hashFor = demoTicketTokenHasher() const now = new Date() const start = new Date(now.getTime() - 185 * DAY) @@ -2610,9 +2787,20 @@ export async function main(): Promise { ) } console.log("writing...") - await writeAll(tx, { users, follows, events, reports, posts, likes, saves, hours }) + await writeAll(tx, { + now, + hashFor, + users, + follows, + events, + reports, + posts, + likes, + saves, + hours, + }) console.log("verifying...") - const lines = await verify(tx) + const lines = await verify(tx, now) if (!commit) throw ROLLBACK return lines }) diff --git a/services/api/src/di.ts b/services/api/src/di.ts index 9d016b15..3b429aed 100644 --- a/services/api/src/di.ts +++ b/services/api/src/di.ts @@ -135,6 +135,18 @@ export interface Container { export type NotificationLogger = Pick +interface AdapterLogger { + warn(obj: unknown, msg?: string): void + error(obj: unknown, msg?: string): void +} + +// Only reached when no server logger was ever attached (a run without a database never builds the +// notification service that carries it). +const PRE_SERVER_LOGGER: AdapterLogger = { + warn: (obj, msg) => console.warn(msg ?? "", obj), + error: (obj, msg) => console.error(msg ?? "", obj), +} + export function buildContainer(env: Env): Container { let dbHandle: DbHandle | undefined let redis: RedisClient | undefined @@ -143,11 +155,30 @@ export function buildContainer(env: Env): Container { const csrf = makeCsrf(env) + // Adapters are built before buildServer hands over its logger, so they get a forwarder that resolves + // the server logger at call time instead of capturing a console fallback at construction. + const adapterLogger: AdapterLogger = { + warn: (obj, msg) => forwardLog("warn", obj, msg), + error: (obj, msg) => forwardLog("error", obj, msg), + } + function forwardLog(level: "warn" | "error", obj: unknown, msg: string | undefined): void { + if (serverLogger !== undefined) serverLogger[level](obj, msg) + else PRE_SERVER_LOGGER[level](obj, msg) + } + + let closed = false + // A getter reached after shutdown would otherwise open a fresh pool that nothing ever closes. + function assertOpen(): void { + if (closed) throw new Error("DI container is closed") + } + function getDb(): DbHandle { + assertOpen() if (!dbHandle) dbHandle = makeDb(env.DATABASE_URL) return dbHandle } function getRedis(): RedisClient { + assertOpen() if (!redis) { redis = makeRedis(env.REDIS_URL, { onError: (err) => serverLogger?.error({ err, component: "redis" }, "redis client error"), @@ -265,6 +296,7 @@ export function buildContainer(env: Env): Container { (redisCounters ??= new RedisCounterStore(getRedis())).incr(key, ttlSeconds), incrBy: (key, by, ttlSeconds) => (redisCounters ??= new RedisCounterStore(getRedis())).incrBy(key, by, ttlSeconds), + decrBy: (key, by) => (redisCounters ??= new RedisCounterStore(getRedis())).decrBy(key, by), } function getCounterStore(): CounterStore { return lazyCounters @@ -363,6 +395,7 @@ export function buildContainer(env: Env): Container { fromNoReply: env.MAIL_FROM_NOREPLY, fromOutreach: env.MAIL_FROM_OUTREACH, timeoutMs: env.OCI_EMAIL_SMTP_TIMEOUT_MS, + logger: adapterLogger, }) const smsSender: SmsSender = env.USE_FAKE_SMS @@ -413,6 +446,7 @@ export function buildContainer(env: Env): Container { ? { turnstileHostnames: env.CF_TURNSTILE_HOSTNAMES } : {}), useRealNsfw: env.USE_REAL_NSFW, + log: (line, extra) => adapterLogger.warn(extra ?? {}, line), }) let sharedPubSub: RedisChatPubSub | undefined @@ -457,7 +491,7 @@ export function buildContainer(env: Env): Container { db: getDb().db, config: buildPushConfig(env), counters: getCounterStore(), - ...(serverLogger !== undefined ? { logger: serverLogger } : {}), + logger: adapterLogger, }) } return pushSender @@ -465,7 +499,7 @@ export function buildContainer(env: Env): Container { const jobs: Jobs = env.USE_FAKE_JOBS ? new FakeJobs() - : new PgBossJobs({ connectionString: env.DATABASE_URL }) + : new PgBossJobs({ connectionString: env.DATABASE_URL, logger: adapterLogger }) async function close(): Promise { const maybePgBoss = jobs as { stop?: () => Promise } @@ -484,6 +518,9 @@ export function buildContainer(env: Env): Container { const maybePush = pushSender as { close?: () => Promise } if (typeof maybePush.close === "function") await maybePush.close() } + // Only now: a graceful jobs stop waits for running handlers, which still need the pools, and + // whatever they opened is torn down below. + closed = true if (sharedPubSub) { await sharedPubSub.close() sharedPubSub = undefined @@ -500,6 +537,16 @@ export function buildContainer(env: Env): Container { await dbHandle.close() dbHandle = undefined } + dmRepo = undefined + blocksRepo = undefined + volunteerHoursRepo = undefined + certificateRepo = undefined + postRepo = undefined + affiliationLoader = undefined + postService = undefined + notificationService = undefined + userChannel = undefined + pushSender = undefined } return { @@ -576,7 +623,9 @@ function buildPushConfig(env: Env) { teamId: env.APNS_TEAM_ID, privateKey: env.APNS_PRIVATE_KEY, bundleId: env.APNS_BUNDLE_ID, - production: env.APNS_PRODUCTION ?? false, + // App Store and TestFlight builds register production-gateway tokens; only Xcode debug builds + // need the sandbox, so an unset flag must not route real devices to it. + production: env.APNS_PRODUCTION ?? true, } } if (env.FCM_SERVICE_ACCOUNT_JSON) { diff --git a/services/api/src/env.ts b/services/api/src/env.ts index 20b774ca..09ab569b 100644 --- a/services/api/src/env.ts +++ b/services/api/src/env.ts @@ -14,6 +14,8 @@ import { parseDrainMs, parseIntOr, parsePositiveIntOr, + parseStrictBool, + STRICT_BOOL_ACCEPTED_FORMS, parseTrustProxy, } from "./env/parsers.js" @@ -50,6 +52,13 @@ const HOME_REGION_LAT_DEFAULT = 34.0522 const HOME_REGION_LNG_DEFAULT = -118.2437 const HOME_REGION_RADIUS_KM_DEFAULT = 40 +const APNS_CREDENTIAL_KEYS = [ + "APNS_KEY_ID", + "APNS_TEAM_ID", + "APNS_PRIVATE_KEY", + "APNS_BUNDLE_ID", +] as const + const NodeEnvSchema = z.enum(["development", "test", "production"]).default("development") const PortSchema = z.coerce.number().int().positive().max(65535) @@ -184,6 +193,31 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { return parsed.data } + function positiveInt(key: string, fallback: number): number { + return parsePositiveIntOr(source[key], fallback, { key, errors }) + } + + function readApnsProduction(): boolean | undefined { + const raw = (source.APNS_PRODUCTION ?? "").trim() + if (raw.length === 0) { + const apnsConfigured = APNS_CREDENTIAL_KEYS.every( + (key) => (source[key] ?? "").trim().length > 0, + ) + if (isProd && apnsConfigured) { + errors.push( + "APNS_PRODUCTION: required [BOOT] once APNs credentials are set (true for App Store and " + + "TestFlight builds; a gateway mismatch makes APNs reject every token as BadDeviceToken)", + ) + } + return undefined + } + const value = parseStrictBool(raw) + if (value === undefined) { + errors.push(`APNS_PRODUCTION: must be one of ${STRICT_BOOL_ACCEPTED_FORMS}`) + } + return value + } + function reqCron(key: string, fallback: string): string { const value = (source[key] ?? "").trim() || fallback if (!isCronish(value)) { @@ -322,9 +356,9 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { const OCI_EMAIL_SMTP_PORT = reqPort("OCI_EMAIL_SMTP_PORT", 587) const OCI_EMAIL_SMTP_USER = reqStr("OCI_EMAIL_SMTP_USER", { gatedOff: fakeFlags.USE_FAKE_MAILER }) const OCI_EMAIL_SMTP_PASS = reqStr("OCI_EMAIL_SMTP_PASS", { gatedOff: fakeFlags.USE_FAKE_MAILER }) - const OCI_EMAIL_SMTP_TIMEOUT_MS = parsePositiveIntOr(source.OCI_EMAIL_SMTP_TIMEOUT_MS, 15_000) - const OUTBOUND_SEND_MIN_THROUGHPUT_BPS = parsePositiveIntOr( - source.OUTBOUND_SEND_MIN_THROUGHPUT_BPS, + const OCI_EMAIL_SMTP_TIMEOUT_MS = positiveInt("OCI_EMAIL_SMTP_TIMEOUT_MS", 15_000) + const OUTBOUND_SEND_MIN_THROUGHPUT_BPS = positiveInt( + "OUTBOUND_SEND_MIN_THROUGHPUT_BPS", 256 * 1024, ) for (const problem of assertOutboundSendPolicy({ @@ -335,7 +369,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { } const SMS_GUEST_ENABLED = parseBool(source.SMS_GUEST_ENABLED, false) - const SMS_DAILY_CAP = parsePositiveIntOr(source.SMS_DAILY_CAP, 50) + const SMS_DAILY_CAP = positiveInt("SMS_DAILY_CAP", 50) const smsCredentialsUnused = fakeFlags.USE_FAKE_SMS || !SMS_GUEST_ENABLED const TWILIO_ACCOUNT_SID = reqStr("TWILIO_ACCOUNT_SID", { gatedOff: smsCredentialsUnused }) const TWILIO_AUTH_TOKEN = reqStr("TWILIO_AUTH_TOKEN", { gatedOff: smsCredentialsUnused }) @@ -373,6 +407,21 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { } } + const SHUTDOWN_DRAIN_MS = parseDrainMs(source.SHUTDOWN_DRAIN_MS) + const TILES_MIN_ZOOM = parseIntOr(source.TILES_MIN_ZOOM, TILES_MIN_ZOOM_DEFAULT, { + key: "TILES_MIN_ZOOM", + errors, + }) + const TILES_MAX_ZOOM = parseIntOr(source.TILES_MAX_ZOOM, TILES_MAX_ZOOM_DEFAULT, { + key: "TILES_MAX_ZOOM", + errors, + }) + const CENSUS_GEOCODER_TIMEOUT_MS = positiveInt("CENSUS_GEOCODER_TIMEOUT_MS", 2500) + const VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS = positiveInt("VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS", 60) + const OUTREACH_THROTTLE_DAYS = positiveInt("OUTREACH_THROTTLE_DAYS", 7) + + const APNS_PRODUCTION = readApnsProduction() + const comms = loadCommsEnv(source, errors) const registration = loadRegistrationEnv(source, errors) @@ -393,7 +442,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { SESSION_SIGNING_KEY, ANON_TOKEN_SIGNING_KEY, TRUST_PROXY, - SHUTDOWN_DRAIN_MS: parseDrainMs(source.SHUTDOWN_DRAIN_MS), + SHUTDOWN_DRAIN_MS, R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, @@ -403,8 +452,8 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { ...(usesLocalStorage && LOCAL_STORAGE_SIGNING_KEY.length > 0 ? { LOCAL_STORAGE_SIGNING_KEY } : {}), - TILES_MIN_ZOOM: parseIntOr(source.TILES_MIN_ZOOM, TILES_MIN_ZOOM_DEFAULT), - TILES_MAX_ZOOM: parseIntOr(source.TILES_MAX_ZOOM, TILES_MAX_ZOOM_DEFAULT), + TILES_MIN_ZOOM, + TILES_MAX_ZOOM, TILES_BOUNDS: parseBounds(source.TILES_BOUNDS, TILES_BOUNDS_DEFAULT), HOME_REGION_LAT, @@ -416,7 +465,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { CENSUS_GEOCODER_URL: (source.CENSUS_GEOCODER_URL ?? "").trim() || "https://geocoding.geo.census.gov/geocoder/geographies/coordinates", - CENSUS_GEOCODER_TIMEOUT_MS: parsePositiveIntOr(source.CENSUS_GEOCODER_TIMEOUT_MS, 2500), + CENSUS_GEOCODER_TIMEOUT_MS, OCI_EMAIL_SMTP_HOST, OCI_EMAIL_SMTP_PORT, @@ -424,10 +473,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { OCI_EMAIL_SMTP_PASS, OCI_EMAIL_SMTP_TIMEOUT_MS, OUTBOUND_SEND_MIN_THROUGHPUT_BPS, - VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS: parsePositiveIntOr( - source.VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS, - 60, - ), + VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS, MAIL_FROM_NOREPLY: (source.MAIL_FROM_NOREPLY ?? "").trim() || "no-reply@civfix.org", MAIL_FROM_OUTREACH: (source.MAIL_FROM_OUTREACH ?? "").trim() || "outreach@civfix.org", HOME_TURF_MAIL_FROM: (source.HOME_TURF_MAIL_FROM ?? "").trim() || "donotreply@civfix.org", @@ -435,7 +481,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { ADMIN_EMAILS: parseCsvLower(source.ADMIN_EMAILS), MAIL_REPLY_DOMAIN: (source.MAIL_REPLY_DOMAIN ?? "").trim() || "civfix.org", - OUTREACH_THROTTLE_DAYS: parsePositiveIntOr(source.OUTREACH_THROTTLE_DAYS, 7), + OUTREACH_THROTTLE_DAYS, OUTREACH_DIGEST_CRON, OUTREACH_DIGEST_ENABLED, REPORT_AUTOFORWARD_ENABLED, @@ -492,9 +538,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { "GLITCHTIP_DSN", "GLITCHTIP_DATABASE_URL", ]), - ...(source.APNS_PRODUCTION !== undefined && source.APNS_PRODUCTION !== "" - ? { APNS_PRODUCTION: parseBool(source.APNS_PRODUCTION, false) } - : {}), + ...(APNS_PRODUCTION !== undefined ? { APNS_PRODUCTION } : {}), ...fakeFlags, diff --git a/services/api/src/env/comms-env.ts b/services/api/src/env/comms-env.ts index 874769a1..89d04b75 100644 --- a/services/api/src/env/comms-env.ts +++ b/services/api/src/env/comms-env.ts @@ -50,7 +50,7 @@ export function loadCommsEnv(source: NodeJS.ProcessEnv, errors: string[]): Comms } function bounded(key: string, fallback: number, min: number, max: number): number { - const raw = parseIntOr(source[key], fallback) + const raw = parseIntOr(source[key], fallback, { key, errors }) if (raw < min || raw > max) { errors.push(`${key}: must be an integer between ${min} and ${max}`) return fallback @@ -148,7 +148,7 @@ export function loadCommsEnv(source: NodeJS.ProcessEnv, errors: string[]): Comms } function clampDedupeSeconds(raw: string | undefined, errors: string[]): number { - const value = parseIntOr(raw, 0) + const value = parseIntOr(raw, 0, { key: "PAGE_VIEW_DEDUPE_SEC", errors }) if (value < 0 || value > 3600) { errors.push("PAGE_VIEW_DEDUPE_SEC: must be an integer between 0 (off) and 3600") return 0 diff --git a/services/api/src/env/parsers.ts b/services/api/src/env/parsers.ts index 04d7aa2a..866872d7 100644 --- a/services/api/src/env/parsers.ts +++ b/services/api/src/env/parsers.ts @@ -14,6 +14,29 @@ export function parseBool(raw: string | undefined, fallback: boolean): boolean { return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase()) } +// The single-letter forms are accepted because deployed boxes already hold one-character values for +// strict flags, and a boot failure over an unambiguous spelling would take the API down on deploy. +const STRICT_TRUE_FORMS = ["true", "t", "yes", "y", "on", "1"] +const STRICT_FALSE_FORMS = ["false", "f", "no", "n", "off", "0"] +const STRICT_TRUE = new Set(STRICT_TRUE_FORMS) +const STRICT_FALSE = new Set(STRICT_FALSE_FORMS) + +export const STRICT_BOOL_ACCEPTED_FORMS = `${STRICT_TRUE_FORMS.join("/")} or ${STRICT_FALSE_FORMS.join("/")}` + +export function parseStrictBool(raw: string): boolean | undefined { + const value = raw.trim().toLowerCase() + if (STRICT_TRUE.has(value)) return true + if (STRICT_FALSE.has(value)) return false + return undefined +} + +export interface EnvIssueSink { + key: string + errors: string[] +} + +const INTEGER_PATTERN = /^-?\d+$/ + export function parseCsv(raw: string | undefined): string[] { if (!raw) return [] return raw @@ -30,22 +53,34 @@ export function parseCsvLower(raw: string | undefined): string[] { return [...seen] } -export function parseIntOr(raw: string | undefined, fallback: number): number { +export function parseIntOr(raw: string | undefined, fallback: number, sink?: EnvIssueSink): number { if (raw === undefined || raw.trim() === "") return fallback - const n = Number.parseInt(raw.trim(), 10) - return Number.isFinite(n) ? n : fallback + const value = raw.trim() + if (!INTEGER_PATTERN.test(value)) { + sink?.errors.push(`${sink.key}: must be an integer`) + return fallback + } + return Number.parseInt(value, 10) } -export function parsePositiveIntOr(raw: string | undefined, fallback: number): number { - const n = parseIntOr(raw, fallback) - return n >= 1 ? n : fallback +export function parsePositiveIntOr( + raw: string | undefined, + fallback: number, + sink?: EnvIssueSink, +): number { + const n = parseIntOr(raw, fallback, sink) + if (n >= 1) return n + sink?.errors.push(`${sink.key}: must be a positive integer`) + return fallback } export const SHUTDOWN_DRAIN_MS_MAX = 10_000 +// Deliberately lenient (leading digits, as parseInt reads them) unlike every other integer: a boot +// failure over shutdown timing is worse than draining for the digits the operator plainly meant. export function parseDrainMs(raw: string | undefined): number { - const n = parseIntOr(raw, 0) - if (n < 0) return 0 + const n = Number.parseInt((raw ?? "").trim(), 10) + if (!Number.isFinite(n) || n < 0) return 0 return Math.min(n, SHUTDOWN_DRAIN_MS_MAX) } diff --git a/services/api/src/errors/glitchtip.ts b/services/api/src/errors/glitchtip.ts index dddf7f0a..ca923c38 100644 --- a/services/api/src/errors/glitchtip.ts +++ b/services/api/src/errors/glitchtip.ts @@ -82,8 +82,12 @@ function isSensitiveKey(key: string): boolean { return SENSITIVE_KEY_PATTERNS.some((p) => k.includes(p)) } +const MAX_REDACT_DEPTH = 8 + function deepRedact(value: unknown, depth = 0): unknown { - if (depth > 8 || value === null || value === undefined) return value + if (value === null || value === undefined) return value + // Past the cap the keys are no longer inspected, so the whole subtree is withheld rather than sent raw. + if (depth > MAX_REDACT_DEPTH) return typeof value === "object" ? REDACTED : value if (Array.isArray(value)) return value.map((v) => deepRedact(v, depth + 1)) if (typeof value === "object") { const out: Record = {} diff --git a/services/api/src/i18n/messages/de.ts b/services/api/src/i18n/messages/de.ts index 807eb8a4..327f0f2c 100644 --- a/services/api/src/i18n/messages/de.ts +++ b/services/api/src/i18n/messages/de.ts @@ -77,7 +77,7 @@ export const de: Partial> = { "email.otp.subject": "Dein civfix-Anmeldecode", "email.otp.body_line1": "Dein civfix-Anmeldecode lautet {{code}}.", "email.otp.body_expiry": - "Er läuft in 5 Minuten ab. Falls du ihn nicht angefordert hast, kannst du diese E-Mail ignorieren.", + "Er läuft in {{minutes}} Minuten ab. Falls du ihn nicht angefordert hast, kannst du diese E-Mail ignorieren.", "email.otp.html_intro": "Dein civfix-Anmeldecode lautet:", "email.report_update.subject": "Dein civfix-Bericht wurde {{status}}", @@ -112,7 +112,7 @@ export const de: Partial> = { "certificate.seal.line": "Verifizierter Nachweis", "certificate.issuer.line": "Ausgestellt von civfix · civfix.org", "certificate.issuer.generated": "Erstellt {{timestamp}}", - "certificate.verify.prompt": "Diesen Nachweis auf civfix.org/service-record prüfen", + "certificate.verify.prompt": "Diesen Nachweis auf {{url}} prüfen", "certificate.verify.fingerprint": "Dokument-Fingerabdruck", "certificate.footer.page": "Seite {{page}} von {{total}}", "certificate.footer.timezone": "Datumsangaben in Pazifikzeit (America/Los_Angeles).", diff --git a/services/api/src/i18n/messages/en.ts b/services/api/src/i18n/messages/en.ts index 8d536e31..b8db87f9 100644 --- a/services/api/src/i18n/messages/en.ts +++ b/services/api/src/i18n/messages/en.ts @@ -155,7 +155,9 @@ export const en = { "certificate.seal.line": "Verified record", "certificate.issuer.line": "Issued by civfix · civfix.org", "certificate.issuer.generated": "Generated {{timestamp}}", - "certificate.verify.prompt": "Verify this record at civfix.org/service-record", + // {{url}} = the deployment's verify page without its scheme, so a staging PDF never sends a reader to + // production to check it. + "certificate.verify.prompt": "Verify this record at {{url}}", "certificate.verify.fingerprint": "Document fingerprint", "certificate.footer.page": "Page {{page}} of {{total}}", "certificate.footer.timezone": "Dates shown in Pacific Time (America/Los_Angeles).", @@ -167,7 +169,7 @@ export const en = { "email.otp.subject": "Your civfix sign-in code", "email.otp.body_line1": "Your civfix sign-in code is {{code}}.", "email.otp.body_expiry": - "It expires in 5 minutes. If you did not request it, you can ignore this email.", + "It expires in {{minutes}} minutes. If you did not request it, you can ignore this email.", // HTML-variant intro (the code itself is rendered in a styled block by the template). "email.otp.html_intro": "Your civfix sign-in code is:", diff --git a/services/api/src/i18n/messages/es.ts b/services/api/src/i18n/messages/es.ts index 194334b4..d76ea212 100644 --- a/services/api/src/i18n/messages/es.ts +++ b/services/api/src/i18n/messages/es.ts @@ -75,7 +75,8 @@ export const es: Partial> = { // ---- Account / OTP emails -------------------------------------------------------------------- "email.otp.subject": "Tu código de acceso a civfix", "email.otp.body_line1": "Tu código de acceso a civfix es {{code}}.", - "email.otp.body_expiry": "Caduca en 5 minutos. Si no lo solicitaste, puedes ignorar este correo.", + "email.otp.body_expiry": + "Caduca en {{minutes}} minutos. Si no lo solicitaste, puedes ignorar este correo.", "email.otp.html_intro": "Tu código de acceso a civfix es:", "email.report_update.subject": "Tu reporte en civfix fue {{status}}", @@ -110,7 +111,7 @@ export const es: Partial> = { "certificate.seal.line": "Registro verificado", "certificate.issuer.line": "Emitido por civfix · civfix.org", "certificate.issuer.generated": "Generado {{timestamp}}", - "certificate.verify.prompt": "Verifica este registro en civfix.org/service-record", + "certificate.verify.prompt": "Verifica este registro en {{url}}", "certificate.verify.fingerprint": "Huella del documento", "certificate.footer.page": "Página {{page}} de {{total}}", "certificate.footer.timezone": diff --git a/services/api/src/i18n/messages/ko.ts b/services/api/src/i18n/messages/ko.ts index 6072d276..74fb04e1 100644 --- a/services/api/src/i18n/messages/ko.ts +++ b/services/api/src/i18n/messages/ko.ts @@ -75,7 +75,8 @@ export const ko: Partial> = { // ---- Account / OTP emails -------------------------------------------------------------------- "email.otp.subject": "civfix 로그인 코드", "email.otp.body_line1": "civfix 로그인 코드는 {{code}}입니다.", - "email.otp.body_expiry": "코드는 5분 후 만료됩니다. 요청하지 않으셨다면 이 이메일을 무시하세요.", + "email.otp.body_expiry": + "코드는 {{minutes}}분 후 만료됩니다. 요청하지 않으셨다면 이 이메일을 무시하세요.", "email.otp.html_intro": "civfix 로그인 코드:", "email.report_update.subject": "civfix 제보가 {{status}} 처리되었어요", @@ -110,7 +111,7 @@ export const ko: Partial> = { "certificate.seal.line": "확인된 기록", "certificate.issuer.line": "civfix 발급 · civfix.org", "certificate.issuer.generated": "생성 {{timestamp}}", - "certificate.verify.prompt": "civfix.org/service-record에서 이 기록을 확인하세요", + "certificate.verify.prompt": "{{url}}에서 이 기록을 확인하세요", "certificate.verify.fingerprint": "문서 지문", "certificate.footer.page": "{{total}}페이지 중 {{page}}페이지", "certificate.footer.timezone": "날짜는 태평양 시간(America/Los_Angeles) 기준이에요.", diff --git a/services/api/src/routes/admin/_route-utils.ts b/services/api/src/routes/admin/_route-utils.ts index 7ab06997..1384294d 100644 --- a/services/api/src/routes/admin/_route-utils.ts +++ b/services/api/src/routes/admin/_route-utils.ts @@ -1,7 +1,17 @@ import { AppError, IdSchema, type AdminOkResponse } from "@civfix/shared" -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" +import type { FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest } from "fastify" import type { ZodTypeAny, z } from "zod" import { parse } from "../_validate.js" +import type { Container } from "../../di.js" +import { broadcastMessageUpdate } from "../../ws/gateway.js" +import { makeDrizzleChatRepository } from "../../services/chat-repository.drizzle.js" +import { makeDrizzleDmRepository } from "../../services/dm-repository.drizzle.js" +import { makePrivateMediaPresigner } from "../../services/media-presign.js" +import { findMessageRoom } from "../../services/admin/admin-report-chat-repository.drizzle.js" +import { + makeMessageUpdateAnnouncer, + type MessageUpdateAnnouncer, +} from "../../services/admin/admin-report-chat-service.js" export { parse } @@ -83,6 +93,39 @@ export function overridableService( } } +/** + * The container-backed announcer for operator message removals and restores. The message is re-read + * through the room's around-history window, the one read path that returns a tombstoned target, so the + * frame carries exactly what a member's own delete would broadcast. + */ +export function makeContainerMessageUpdateAnnouncer( + container: Container, + logger: FastifyBaseLogger, +): MessageUpdateAnnouncer { + const sql = container.getDb().sql + const presign = makePrivateMediaPresigner(container.storage) + const chatRepo = makeDrizzleChatRepository(sql, presign) + const dmRepo = makeDrizzleDmRepository(sql, presign) + const TARGET_ONLY = 1 + return makeMessageUpdateAnnouncer({ + findRoom: (messageId) => findMessageRoom(sql, messageId), + loadMessage: async (kind, roomId, messageId) => { + const page = + kind === "dm" + ? await dmRepo.history(roomId, undefined, TARGET_ONLY, null, messageId) + : kind === "report" + ? await chatRepo.reportHistory(roomId, undefined, TARGET_ONLY, null, messageId) + : kind === "group" + ? await chatRepo.groupHistory(roomId, undefined, TARGET_ONLY, null, messageId) + : await chatRepo.history(roomId, undefined, TARGET_ONLY, null, messageId) + return page.items.find((m) => m.id === messageId) ?? null + }, + broadcast: (kind, roomId, message) => + broadcastMessageUpdate(container.chatService, kind, roomId, message), + logger, + }) +} + /** * Spread an override bundle's optional clock into a service's deps: `{ ...spreadNow(overrides) }`. Absent * means "the service keeps its own default clock", which is not the same as passing `now: undefined`. diff --git a/services/api/src/routes/admin/broadcasts.routes.ts b/services/api/src/routes/admin/broadcasts.routes.ts index 0ea38e36..eed5be8c 100644 --- a/services/api/src/routes/admin/broadcasts.routes.ts +++ b/services/api/src/routes/admin/broadcasts.routes.ts @@ -13,7 +13,7 @@ import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" -import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" +import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import { makeDrizzleBroadcastRepository } from "../../services/host/broadcast-repository.drizzle.js" import type { BroadcastRepository } from "../../services/host/broadcast-repository.js" @@ -51,7 +51,6 @@ export async function registerAdminBroadcastRoutes( requireAuth(request) const query = parse(AdminBroadcastListQuerySchema, request.query) const limit = query.limit ?? ADMIN_BROADCAST_DEFAULT_LIMIT - const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) const rows = await broadcastRepo().listAdmin({ ...(query.status !== undefined ? { status: query.status } : {}), ...(query.kind !== undefined ? { kind: query.kind } : {}), @@ -59,13 +58,12 @@ export async function registerAdminBroadcastRoutes( ...(query.createdBy !== undefined ? { createdBy: query.createdBy } : {}), ...(query.from !== undefined ? { from: new Date(query.from) } : {}), ...(query.to !== undefined ? { to: new Date(query.to) } : {}), - cursor: cursor === null ? null : { createdAt: cursor.at, id: cursor.id }, + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) - const page = rows.slice(0, limit) - const last = page.at(-1) + const page = paginateKeyset(rows, limit, (row) => ({ atText: row.cursorAt, id: row.id })) const payload: AdminBroadcastListResponse = { - items: page.map((row) => ({ + items: page.items.map((row) => ({ id: row.id, cleanupId: row.cleanupId, eventTitle: row.eventTitle, @@ -92,10 +90,7 @@ export async function registerAdminBroadcastRoutes( createdAt: row.createdAt.toISOString(), finishedAt: row.finishedAt?.toISOString() ?? null, })), - nextCursor: - rows.length > limit && last !== undefined - ? encodeTimeCursor({ at: last.createdAt, id: last.id }) - : null, + nextCursor: page.nextCursor, } reply.status(200).send(payload) }) @@ -105,18 +100,16 @@ export async function registerAdminBroadcastRoutes( const query = parse(AdminHostListQuerySchema, request.query ?? {}) const limit = query.limit ?? ADMIN_HOST_DEFAULT_LIMIT const windowDays = query.windowDays ?? ADMIN_HOST_DEFAULT_WINDOW_DAYS - const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) const rows = await broadcastRepo().listAdminHosts({ ...(query.q !== undefined ? { q: query.q } : {}), ...(query.suspended !== undefined ? { suspended: query.suspended } : {}), windowStart: new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000), - cursor: cursor === null ? null : { at: cursor.at, id: cursor.id }, + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) - const page = rows.slice(0, limit) - const last = page.at(-1) + const page = paginateKeyset(rows, limit, (row) => ({ atText: row.cursorAt, id: row.userId })) const payload: AdminHostListResponse = { - items: page.map((row) => ({ + items: page.items.map((row) => ({ host: { id: row.userId, name: row.displayName, @@ -143,10 +136,7 @@ export async function registerAdminBroadcastRoutes( lastBroadcastAt: row.lastBroadcastAt?.toISOString() ?? null, eventsMessaged: row.eventsMessaged, })), - nextCursor: - rows.length > limit && last !== undefined - ? encodeTimeCursor({ at: last.sortAt, id: last.userId }) - : null, + nextCursor: page.nextCursor, } reply.status(200).send(payload) }) diff --git a/services/api/src/routes/admin/moderation.routes.ts b/services/api/src/routes/admin/moderation.routes.ts index 651be227..83849f1c 100644 --- a/services/api/src/routes/admin/moderation.routes.ts +++ b/services/api/src/routes/admin/moderation.routes.ts @@ -13,6 +13,7 @@ import { requireOperator } from "../../auth/admin-guard.js" import { route } from "../../versioning/route.js" import { idParam, + makeContainerMessageUpdateAnnouncer, overridableService, parse, parseBodyWithId, @@ -72,6 +73,7 @@ export async function registerAdminModerationRoutes( applyStatus: (userId, status) => app.authServices.sessions.applyAccountStatus(userId, status), }, + announceMessageUpdate: makeContainerMessageUpdateAnnouncer(container, app.log), }) }, ) diff --git a/services/api/src/routes/admin/pages.routes.ts b/services/api/src/routes/admin/pages.routes.ts index 93b17890..cc9c0d50 100644 --- a/services/api/src/routes/admin/pages.routes.ts +++ b/services/api/src/routes/admin/pages.routes.ts @@ -14,7 +14,7 @@ import { requireAuth } from "../../auth/context.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" import { writeAudit, type WriteAuditInput } from "../../services/admin/audit.js" -import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" +import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import { makeDrizzleAdminEventPageRepository, type AdminEventPageRepository, @@ -106,23 +106,18 @@ export async function registerAdminEventPageRoutes( requireAuth(request) const query = parse(AdminEventPageListQuerySchema, request.query) const limit = query.limit ?? ADMIN_EVENT_PAGE_DEFAULT_LIMIT - const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) const rows = await repo().list({ ...(query.q !== undefined ? { q: query.q } : {}), ...(query.status !== undefined ? { status: query.status } : {}), ...(query.flagged !== undefined ? { flagged: query.flagged } : {}), - cursor: cursor === null ? null : { at: cursor.at, id: cursor.id }, + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) - const page = rows.slice(0, limit) - const last = page.at(-1) - const payload: AdminEventPageListResponse = { - items: page.map(toDTO), - nextCursor: - rows.length > limit && last !== undefined - ? encodeTimeCursor({ at: last.sortAt, id: last.pageId }) - : null, - } + const { items, nextCursor } = paginateKeyset(rows, limit, (row) => ({ + atText: row.cursorAt, + id: row.pageId, + })) + const payload: AdminEventPageListResponse = { items: items.map(toDTO), nextCursor } reply.status(200).send(payload) }) diff --git a/services/api/src/routes/admin/report-chat.routes.ts b/services/api/src/routes/admin/report-chat.routes.ts index d526709c..e7e95926 100644 --- a/services/api/src/routes/admin/report-chat.routes.ts +++ b/services/api/src/routes/admin/report-chat.routes.ts @@ -9,7 +9,14 @@ import type { FastifyInstance } from "fastify" import { perIdentity } from "../../plugins/rate-limit.js" import type { Container } from "../../di.js" import { route } from "../../versioning/route.js" -import { idParam, overridableService, parse, sendOk, twoIdParams } from "./_route-utils.js" +import { + idParam, + makeContainerMessageUpdateAnnouncer, + overridableService, + parse, + sendOk, + twoIdParams, +} from "./_route-utils.js" import { requireOperator } from "../../auth/admin-guard.js" import { makeAdminReportChatService, @@ -86,6 +93,7 @@ export async function registerAdminReportChatRoutes( mentions: chatMentionDeps(app, container), logger: app.log, }), + announceMessageUpdate: makeContainerMessageUpdateAnnouncer(container, app.log), }) } diff --git a/services/api/src/routes/admin/reports.routes.ts b/services/api/src/routes/admin/reports.routes.ts index 10bb380a..6cfff247 100644 --- a/services/api/src/routes/admin/reports.routes.ts +++ b/services/api/src/routes/admin/reports.routes.ts @@ -98,7 +98,9 @@ export async function registerAdminReportsRoutes( }), () => { const sql = container.getDb().sql - const repo: AdminReportRepository = makeDrizzleAdminReportRepository(sql) + const repo: AdminReportRepository = makeDrizzleAdminReportRepository(sql, { + logger: app.log, + }) const outboundMail = makeContainerOutboundMailService(container, { logger: app.log }) const cleanupRepo = makeDrizzleCleanupRepository(sql) return makeAdminReportService({ diff --git a/services/api/src/routes/admin/users.routes.ts b/services/api/src/routes/admin/users.routes.ts index 15484c62..d03a8208 100644 --- a/services/api/src/routes/admin/users.routes.ts +++ b/services/api/src/routes/admin/users.routes.ts @@ -18,6 +18,7 @@ import { requireOperator } from "../../auth/admin-guard.js" import { route } from "../../versioning/route.js" import { idParam, + makeContainerMessageUpdateAnnouncer, overridableService, parse, parseBodyWithId, @@ -67,7 +68,11 @@ export async function registerAdminUsersRoutes( applyStatus: (userId, status) => sessionSvc.applyAccountStatus(userId, status), revokeAll: (userId) => sessionSvc.revokeAllForUser(userId), } - return makeAdminUserService({ repo, sessions }) + return makeAdminUserService({ + repo, + sessions, + announceMessageUpdate: makeContainerMessageUpdateAnnouncer(container, app.log), + }) }, ) diff --git a/services/api/src/routes/auth.routes.ts b/services/api/src/routes/auth.routes.ts index def8fbc6..376bba69 100644 --- a/services/api/src/routes/auth.routes.ts +++ b/services/api/src/routes/auth.routes.ts @@ -211,6 +211,7 @@ export async function registerAuthRoutes( "googleCallback", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { + if (redirectOnProviderError(request, reply, webOrigins)) return const query = parse(OAuthCallbackQuerySchema, request.query) const stash = readOAuthStash(request) if (!stash || stash.state !== query.state || stash.codeVerifier === undefined) { @@ -269,6 +270,7 @@ export async function registerAuthRoutes( "appleCallback", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { + if (redirectOnProviderError(request, reply, webOrigins)) return const body = parse(AppleCallbackBodySchema, request.body) const stash = readOAuthStash(request) if (!stash || stash.state !== body.state) { @@ -490,16 +492,18 @@ async function webCsrfToken( const sessionToken = sessionCookieValue(request) if (sessionToken === null) return null + let csrfMaxAge = services.sessions.ttl const expiresAtMs = request.sessionExpiresAtMs if (expiresAtMs !== undefined) { const remainingSeconds = Math.ceil((expiresAtMs - Date.now()) / 1000) if (remainingSeconds > 0) { setSessionCookie(reply, sessionToken, remainingSeconds) + csrfMaxAge = remainingSeconds } } const token = await csrf.tokenForSession(sessionToken) - setCsrfCookie(reply, token, services.sessions.ttl) + setCsrfCookie(reply, token, csrfMaxAge) return token } @@ -529,6 +533,40 @@ function readOAuthStash(request: FastifyRequest): OAuthStash | null { } } +// A provider-side cancel or denial arrives as a top-level navigation with `error` and no `code`; +// answering it with a JSON validation error strands the user on an API page. The stash (and its +// allowlisted target) is only honored and cleared when the state proves it is this browser's +// own sign-in, so a forged cancel link cannot abort someone else's in-flight handshake. +function redirectOnProviderError( + request: FastifyRequest, + reply: FastifyReply, + webOrigins: readonly string[], +): boolean { + const input = request.method === "POST" ? request.body : request.query + const failure = providerErrorOf(input) + if (failure === null) return false + const stash = readOAuthStash(request) + const own = stash !== null && failure.state !== undefined && stash.state === failure.state + if (own) reply.clearCookie(OAUTH_STATE_COOKIE, { path: "/" }) + request.log.info({ providerError: failure.error }, "web OAuth sign-in ended at the provider") + reply.redirect(resolvePostLoginRedirect(own ? stash.redirect : undefined, webOrigins)) + return true +} + +const MAX_PROVIDER_ERROR_LENGTH = 128 + +function providerErrorOf(input: unknown): { error: string; state?: string } | null { + if (typeof input !== "object" || input === null) return null + const fields = input as Record + if (fields.code !== undefined) return null + const { error, state } = fields + if (typeof error !== "string" || error.length === 0) return null + return { + error: error.slice(0, MAX_PROVIDER_ERROR_LENGTH), + ...(typeof state === "string" && state.length > 0 ? { state } : {}), + } +} + function appleFullNameFromUserField(user: string | undefined): string | undefined { if (!user) return undefined try { @@ -545,18 +583,23 @@ export function resolvePostLoginRedirect( redirect: string | undefined, webOrigins: readonly string[], ): string { - if (redirect !== undefined && isAllowedPostLoginRedirect(redirect, webOrigins)) { - return redirect - } - return webOrigins[0] ?? "/" + const accepted = redirect === undefined ? null : acceptedPostLoginRedirect(redirect, webOrigins) + return accepted ?? webOrigins[0] ?? "/" } const MAX_POST_LOGIN_REDIRECT_LENGTH = 2048 function isAllowedPostLoginRedirect(redirect: string, webOrigins: readonly string[]): boolean { - if (redirect.length > MAX_POST_LOGIN_REDIRECT_LENGTH) return false + return acceptedPostLoginRedirect(redirect, webOrigins) !== null +} + +function acceptedPostLoginRedirect(redirect: string, webOrigins: readonly string[]): string | null { + if (redirect.length > MAX_POST_LOGIN_REDIRECT_LENGTH) return null const value = redirect.trim() - if (value === "") return false + return value !== "" && isAllowedRedirectValue(value, webOrigins) ? value : null +} + +function isAllowedRedirectValue(value: string, webOrigins: readonly string[]): boolean { // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(value)) return false if (value.startsWith("/")) { diff --git a/services/api/src/routes/chat-gateway-wiring.ts b/services/api/src/routes/chat-gateway-wiring.ts index 728f87d2..d1ce8eef 100644 --- a/services/api/src/routes/chat-gateway-wiring.ts +++ b/services/api/src/routes/chat-gateway-wiring.ts @@ -37,6 +37,7 @@ import { } from "../services/report-chat-repository.drizzle.js" import { canPostToGroup, + GROUP_MEMBER_SCAN_CAP, makeChatGroupRepository, type ChatGroupRepository, } from "../services/chat-group-repository.drizzle.js" @@ -52,7 +53,7 @@ import { type ChatRepository, } from "../services/chat-repository.drizzle.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" -import { recordChatMentions } from "../services/chat-mentions.drizzle.js" +import { recordChatMentions, roomMemberIdsAmong } from "../services/chat-mentions.drizzle.js" import { makeDrizzleChatReadState, monotonicReadWatermarkUpdate, @@ -64,6 +65,7 @@ import { import { makeDrizzleNotificationRepository } from "../services/notification-repository.drizzle.js" import { makeConversationMutesRepository, + makeFailOpenMuteCheck, type ConversationMutesRepository, } from "../services/conversation-mutes-repository.drizzle.js" import { makeReportChatNotifier } from "../services/report-chat-notifier.js" @@ -98,7 +100,7 @@ const REPORT_SEND_LIMIT = { capacity: 30, refillPerSec: 0.5 } as const export type ChatMentionSeam = Pick< GatewayChatMentions, - "resolveChatMentions" | "recordChatMentions" + "resolveChatMentions" | "recordChatMentions" | "logger" > const mentionSeams = new WeakMap() @@ -108,11 +110,6 @@ export function chatMentionDeps(app: FastifyInstance, container: Container): Cha if (cached) return cached const overrides: ChatGatewayOverrides | undefined = app.chatOverrides - const presignMedia = makePrivateMediaPresigner(container.storage) - - let cleanups: ReturnType | undefined - let reportChat: ReportChatRepository | undefined - let groups: ChatGroupRepository | undefined const seam: ChatMentionSeam = { resolveChatMentions: makeChatMentionResolver({ @@ -120,25 +117,16 @@ export function chatMentionDeps(app: FastifyInstance, container: Container): Cha dmPeerOf: makeDmPeerOf({ getThread: (threadId) => (overrides?.dmRepo ?? container.getDmRepo()).getThread(threadId), }), - listCleanupMemberIds: (cleanupId, cap) => - (cleanups ??= makeDrizzleCleanupRepository(container.getDb().sql)).listMemberIds( - cleanupId, - cap, - ), - listReportChatMemberIds: (reportId) => - ( - overrides?.reportChat ?? - (reportChat ??= makeReportChatRepository(container.getDb().sql, presignMedia)) - ).listMemberIds(reportId), - listGroupMemberIds: (groupId) => { - const repo = overrides - ? overrides.groups - : (groups ??= makeChatGroupRepository(container.getDb().sql, presignMedia)) - return repo?.listMemberIds(groupId) ?? Promise.resolve([]) - }, + listCleanupMemberIds: (cleanupId, candidateIds) => + roomMemberIdsAmong(container.getDb().sql, "cleanup", cleanupId, candidateIds), + listReportChatMemberIds: (reportId, candidateIds) => + roomMemberIdsAmong(container.getDb().sql, "report", reportId, candidateIds), + listGroupMemberIds: (groupId, candidateIds) => + roomMemberIdsAmong(container.getDb().sql, "group", groupId, candidateIds), }), recordChatMentions: (messageId, mentionedUserIds) => recordChatMentions(container.getDb().sql, messageId, mentionedUserIds), + logger: app.log, } mentionSeams.set(app, seam) return seam @@ -313,14 +301,19 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha : undefined const groupMembersInFlight = new Map>() - const listGroupMembersShared = (groupId: string): Promise => { + const listGroupMembersShared = ( + groupId: string, + limit: number = GROUP_MEMBER_SCAN_CAP, + ): Promise => { const repo = getGroupsRepo() if (!repo) return Promise.resolve([]) - const inFlight = groupMembersInFlight.get(groupId) + const key = `${groupId}:${limit}` + const inFlight = groupMembersInFlight.get(key) if (inFlight) return inFlight - const query = repo.listMemberIds(groupId) - groupMembersInFlight.set(groupId, query) - void query.catch(() => {}).then(() => groupMembersInFlight.delete(groupId)) + const query = repo.listMemberIds(groupId, limit) + groupMembersInFlight.set(key, query) + // Only evicts the shared entry; each caller awaits `query` itself and sees the rejection. + void query.catch(() => {}).then(() => groupMembersInFlight.delete(key)) return query } @@ -341,18 +334,7 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha overrides?.conversationMutes ?? (useFakeChat ? undefined : makeConversationMutesRepository(container.getDb().sql)) - const isMutedFor = async ( - userId: string, - kind: "dm" | "cleanup" | "report" | "group", - roomId: string, - ): Promise => { - if (!conversationMutes) return false - try { - return await conversationMutes.isMuted(userId, kind, roomId) - } catch { - return false - } - } + const isMutedFor = makeFailOpenMuteCheck(conversationMutes, app.log) const mutedUserIdsForRoom = ( kind: "report" | "group", @@ -384,11 +366,9 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha const dmGatewayDeps: GatewayDmDeps = { peerOf: dmPeerOf, persist: (input) => dmRepo.persist(input), - markRead: async (threadId, userId, upToId) => { - const at = (await dmRepo.resolveMessageCreatedAt(threadId, upToId)) ?? new Date() - await dmRepo.markRead(threadId, userId, at) - await clearConversationBell("dm", threadId, userId) - }, + markRead: makeDmAckMarkRead(dmRepo, (threadId, userId) => + clearConversationBell("dm", threadId, userId), + ), } const advanceCleanupWatermark: ( @@ -527,25 +507,30 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha sendResilience, } - const canForwardCity = makeCityForwardThrottle({ - incr: (key, ttlSeconds) => container.getCounterStore().incr(key, ttlSeconds), - incrBy: (key, by, ttlSeconds) => container.getCounterStore().incrBy(key, by, ttlSeconds), - }) + const canForwardCity = makeCityForwardThrottle( + { + incr: (key, ttlSeconds) => container.getCounterStore().incr(key, ttlSeconds), + incrBy: (key, by, ttlSeconds) => container.getCounterStore().incrBy(key, by, ttlSeconds), + decrBy: (key, by) => container.getCounterStore().decrBy(key, by), + }, + app.log, + ) const notifyReportChatMembers = notificationService && conversationMutes ? makeReportChatNotifier({ notificationService, reportChatRepo: { - listMemberIds: (reportId) => getReportChatRepo().listMemberIds(reportId), + listMemberIds: (reportId, limit) => getReportChatRepo().listMemberIds(reportId, limit), }, - isMuted: (userId, roomId) => isMutedFor(userId, "report", roomId), + isMuted: (userId, roomId) => conversationMutes.isMuted(userId, "report", roomId), ...(reportMutedUserIdsFor ? { mutedUserIdsFor: reportMutedUserIdsFor } : {}), presence, roomKeyFor, isBlockedEitherWay, ...(blockedIdsForCandidates ? { blockedIdsFor: blockedIdsForCandidates } : {}), ...roomFanoutHandoff("report"), + logger: app.log, }) : undefined @@ -553,14 +538,17 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha notificationService && conversationMutes && groupWired ? makeGroupChatNotifier({ notificationService, - groupRepo: { listMemberIds: listGroupMembersShared }, - isMuted: (userId, roomId) => isMutedFor(userId, "group", roomId), + groupRepo: { + listMemberIds: (groupId, limit) => listGroupMembersShared(groupId, limit), + }, + isMuted: (userId, roomId) => conversationMutes.isMuted(userId, "group", roomId), ...(groupMutedUserIdsFor ? { mutedUserIdsFor: groupMutedUserIdsFor } : {}), presence, roomKeyFor, isBlockedEitherWay, ...(blockedIdsForCandidates ? { blockedIdsFor: blockedIdsForCandidates } : {}), ...roomFanoutHandoff("group"), + logger: app.log, }) : undefined const onGroupMessage: OnGroupMessage | undefined = notifyGroupChatMembers @@ -572,6 +560,7 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha const forwardCityMention = makeContainerReportCityForward(container, { getReportRepo, canForward: canForwardCity, + logger: app.log, }) const onReportMessage: OnReportMessage | undefined = useFakeChat ? undefined @@ -652,6 +641,18 @@ export function roomFanoutMode(input: { return { claimed, queued: claimed && !input.useFakeJobs } } +export function makeDmAckMarkRead( + dmRepo: Pick, + clearBell: (threadId: string, userId: string) => Promise, +): GatewayDmDeps["markRead"] { + return async (threadId, userId, upToId) => { + const at = await dmRepo.resolveMessageCreatedAt(threadId, upToId) + // Like the other room kinds, an ack names a message; one this thread cannot resolve advances nothing. + if (at !== null) await dmRepo.markRead(threadId, userId, at) + await clearBell(threadId, userId) + } +} + export function makeGatewayReportChat( source: GatewayReportChat, clearBell: (reportId: string, userId: string) => Promise, diff --git a/services/api/src/routes/claim.routes.ts b/services/api/src/routes/claim.routes.ts index 3761faf8..772a946b 100644 --- a/services/api/src/routes/claim.routes.ts +++ b/services/api/src/routes/claim.routes.ts @@ -82,6 +82,7 @@ export async function registerClaimRoutes( { singletonKey: reportId }, ) }, + logger: app.log, }) } diff --git a/services/api/src/routes/dm.routes.ts b/services/api/src/routes/dm.routes.ts index 848a5a02..6c5d9c0d 100644 --- a/services/api/src/routes/dm.routes.ts +++ b/services/api/src/routes/dm.routes.ts @@ -28,6 +28,7 @@ import { type ConversationMutesRepository, } from "../services/conversation-mutes-repository.drizzle.js" import { chatHistoryPayload, neutralizeChatViewerFields } from "./chat-route-helpers.js" +import { chatMentionDeps, type ChatMentionSeam } from "./chat-gateway-wiring.js" const DmIdParamsSchema = z.object({ id: IdSchema }).strict() @@ -54,6 +55,10 @@ export async function registerDmRoutes(app: FastifyInstance, container: Containe const peerOf = makeDmPeerOf({ getThread: (threadId) => dmRepo().getThread(threadId) }) + const chatMentions: ChatMentionSeam | undefined = + app.chatOverrides?.chatMentions ?? + (container.env.USE_FAKE_CHAT ? undefined : chatMentionDeps(app, container)) + const loadUser: DmUserLookup = async (userId) => { const store = app.authServices?.users if (!store) return null @@ -145,6 +150,7 @@ export async function registerDmRoutes(app: FastifyInstance, container: Containe dm: dmRepo(), dmPeerOf: peerOf, isBlockedEitherWay: (a, b) => blocksRepo().isBlockedEitherWay(a, b), + ...(chatMentions ? { chatMentions } : {}), broadcastEvent: (roomKey, frame) => container.chatService.broadcastEvent?.(roomKey, frame), }) const updated = await edits.editMessage({ diff --git a/services/api/src/routes/forms.routes.ts b/services/api/src/routes/forms.routes.ts index 57faeba8..f04621e7 100644 --- a/services/api/src/routes/forms.routes.ts +++ b/services/api/src/routes/forms.routes.ts @@ -80,17 +80,18 @@ export function canonicalizeHomeTurfEmail(email: string): string { return `${local}@${domain}` } -export async function enforceHomeTurfRecipientCap( +// The cap protects the address in the form from being mail-bombed with confirmations. It runs after the +// staff notification went out, so exceeding it drops only the confirmation: failing the request would tell +// the coach their sign-up failed when staff already have it. +export async function claimHomeTurfConfirmation( email: string, counters: CounterStore, -): Promise { +): Promise { const canonical = canonicalizeHomeTurfEmail(email) const digest = createHash("sha256").update(canonical).digest("hex") const key = HOME_TURF_EMAIL_COUNTER_PREFIX + digest const count = await counters.incr(key, HOME_TURF_EMAIL_WINDOW_SECONDS) - if (count > HOME_TURF_EMAIL_LIMIT_PER_DAY) { - throw AppError.rateLimited("Too many submissions from this network. Try again later.") - } + return count <= HOME_TURF_EMAIL_LIMIT_PER_DAY } export async function registerHomeTurfRoutes( @@ -164,7 +165,12 @@ export async function registerHomeTurfRoutes( const notification = buildNotificationEmail(form, from, notifyTo) await container.mailer.sendOutbound(notification) - await enforceHomeTurfRecipientCap(form.email, store) + if (!(await claimHomeTurfConfirmation(form.email, store))) { + request.log.info( + "home-turf form: recipient confirmation cap reached; staff notified, confirmation skipped", + ) + return reply.status(200).send({ ok: true }) + } try { await container.mailer.sendOutbound(buildConfirmationEmail(form, from, notifyTo)) diff --git a/services/api/src/routes/host/page-views.routes.ts b/services/api/src/routes/host/page-views.routes.ts index 7ea51a34..0b4585bd 100644 --- a/services/api/src/routes/host/page-views.routes.ts +++ b/services/api/src/routes/host/page-views.routes.ts @@ -46,9 +46,10 @@ export async function registerPageViewRoutes( ...(userAgent !== undefined ? { userAgent } : {}), }) } catch (err) { + // A view counter is analytics, not state the visitor depends on: the beacon never fails. request.log.warn({ err }, "page view: counter write failed (view not counted)") } - reply.status(204).send() + reply.status(200).send({ ok: true }) }, ) } diff --git a/services/api/src/routes/host/portfolio.routes.ts b/services/api/src/routes/host/portfolio.routes.ts index 8d6ae64e..083ba15e 100644 --- a/services/api/src/routes/host/portfolio.routes.ts +++ b/services/api/src/routes/host/portfolio.routes.ts @@ -7,7 +7,10 @@ import { parse } from "../_validate.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" import { makeEventMediaPresigner } from "../../services/host/event-media.js" -import { makeDrizzleHostPortfolioRepository } from "../../services/host/host-portfolio-repository.drizzle.js" +import { + hostedRegistrationTotals, + makeDrizzleHostPortfolioRepository, +} from "../../services/host/host-portfolio-repository.drizzle.js" import { HOSTED_EVENTS_DEFAULT_LIMIT, makeHostPortfolioService, @@ -19,6 +22,7 @@ import { hostedEventCounts } from "../../services/host/portfolio-counts.js" export interface HostPortfolioOverrides { repo: HostPortfolioServiceDeps["repo"] counts?: HostPortfolioServiceDeps["counts"] + totals?: HostPortfolioServiceDeps["totals"] presignEventMedia?: HostPortfolioServiceDeps["presignEventMedia"] now?: HostPortfolioServiceDeps["now"] } @@ -50,6 +54,8 @@ export async function registerHostPortfolioRoutes( return makeHostPortfolioService({ repo: overrides.repo, counts: overrides.counts ?? (() => Promise.resolve(new Map())), + totals: + overrides.totals ?? (() => Promise.resolve({ totalRegistrations: 0, totalCheckedIn: 0 })), ...(overrides.presignEventMedia !== undefined ? { presignEventMedia: overrides.presignEventMedia } : {}), @@ -60,6 +66,7 @@ export async function registerHostPortfolioRoutes( return makeHostPortfolioService({ repo: makeDrizzleHostPortfolioRepository(sql), counts: (cleanupIds) => hostedEventCounts(sql, cleanupIds), + totals: (args) => hostedRegistrationTotals(sql, args), presignEventMedia: makeEventMediaPresigner(container.storage), }) } diff --git a/services/api/src/routes/service-hours-certificates.routes.ts b/services/api/src/routes/service-hours-certificates.routes.ts index 01043b7a..bb5d0206 100644 --- a/services/api/src/routes/service-hours-certificates.routes.ts +++ b/services/api/src/routes/service-hours-certificates.routes.ts @@ -21,7 +21,9 @@ import { z } from "zod" import type { FastifyInstance } from "fastify" import { perHost, perIdentity } from "../plugins/rate-limit.js" import type { Container } from "../di.js" +import { webBaseUrlOf } from "../lib/base-url.js" import { requireAuth } from "../auth/context.js" +import { CERTIFICATE_VERIFY_PATH } from "../services/certificate-pdf.js" import { makeCertificateService, type CertificateRepository, @@ -93,6 +95,7 @@ export async function registerServiceHoursCertificateRoutes( } : container.getVolunteerHoursRepo()), storage: overrides?.storage ?? container.storage, + verifyBaseUrl: `${webBaseUrlOf(container.env)}${CERTIFICATE_VERIFY_PATH}`, logger: app.log, }) } diff --git a/services/api/src/server.ts b/services/api/src/server.ts index 36ed623f..144d3d70 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -247,6 +247,25 @@ function resolveAuthServices( return undefined } +export async function startBackgroundJobs(app: FastifyInstance, env: Env): Promise { + const startableJobs = app.container.jobs as { start?: () => Promise } + if (typeof startableJobs.start !== "function" || !env.DATABASE_URL) return + await startableJobs.start() + app.log.info("jobs: queue started") + + await registerOutreachJobs(app.container, app.log) + await registerInboundJobs(app.container) + await app.container.jobs.enqueue(INBOUND_SWEEP_JOB, {}) + await registerDiscoveryJobs(app.container) + if (env.REPORT_AUTOFORWARD_ENABLED) await registerAutoForwardJobs(app.container, app.log) + await registerDataExportJobs(app.container, { logger: app.log }) + await registerCleanupCancelFanoutJob(app.container, app.log) + await registerGuestJobs(app.container, app.log) + await registerChatRoomFanoutJob(app.container, app.log) + await registerRegistrationJobs(app.container, app.log) + await registerCommsJobs(app.container, app.log) +} + export async function start(env: Env = loadEnv()): Promise { await initErrorReporting({ ...(env.GLITCHTIP_DSN !== undefined ? { dsn: env.GLITCHTIP_DSN } : {}), @@ -255,24 +274,7 @@ export async function start(env: Env = loadEnv()): Promise { }) const app = await buildServer({ env }) - - const startableJobs = app.container.jobs as { start?: () => Promise } - if (typeof startableJobs.start === "function" && env.DATABASE_URL) { - await startableJobs.start() - app.log.info("jobs: queue started") - - await registerOutreachJobs(app.container) - await registerInboundJobs(app.container) - await app.container.jobs.enqueue(INBOUND_SWEEP_JOB, {}) - await registerDiscoveryJobs(app.container) - if (env.REPORT_AUTOFORWARD_ENABLED) await registerAutoForwardJobs(app.container, app.log) - await registerDataExportJobs(app.container, { logger: app.log }) - await registerCleanupCancelFanoutJob(app.container, app.log) - await registerGuestJobs(app.container, app.log) - await registerChatRoomFanoutJob(app.container, app.log) - await registerRegistrationJobs(app.container, app.log) - await registerCommsJobs(app.container, app.log) - } + await startBackgroundJobs(app, env) const shutdown = makeShutdown(app, { drainMs: env.SHUTDOWN_DRAIN_MS, diff --git a/services/api/src/services/admin/activity-repository.drizzle.ts b/services/api/src/services/admin/activity-repository.drizzle.ts index c600b0d9..d8b06cf6 100644 --- a/services/api/src/services/admin/activity-repository.drizzle.ts +++ b/services/api/src/services/admin/activity-repository.drizzle.ts @@ -21,8 +21,9 @@ * * KEYSET: (ts, id) with `id` compared AS TEXT in both the branch predicate and the outer ORDER BY. Every * source's pk is a uuid, so text order and uuid order coincide, and one text tuple gives the four sources a - * single TOTAL order — without the id term two rows sharing a millisecond across sources could repeat or - * skip across a page boundary. + * single TOTAL order. The id term only breaks ties between rows with an exactly equal ts; the cursor carries + * the ts at microsecond precision (every branch projects `cursor_at`), because a millisecond anchor would + * re-include the previous page's last row on `oldest` and skip same-millisecond rows on `newest`. * * FILTERING: `filter` is an ActivityKind, which is a SERVICE-side classification. Rather than re-typing the * action prefixes in SQL, the branch set comes from `sourcesForKind` and the audit predicate is BUILT from @@ -33,8 +34,15 @@ */ import type { Sql } from "../../db/client.js" -import { clampLimit, decodeCursor, paginate } from "./pagination.js" +import { + clampLimit, + decodeCursor, + keysetInstant, + keysetPredicate, + paginateKeyset, +} from "./pagination.js" import { AUDIT_READ_ACTIONS } from "./audit.js" +import { likePrefix } from "./like.js" import { ilikeAnyOf, type SqlFragment } from "./sql-fragments.js" import { AUDIT_ACTION_RULES, @@ -54,6 +62,7 @@ interface ActivityRowSelect { source: ActivitySource id: string ts: Date + cursor_at: string who: string | null where_label: string | null action: string | null @@ -93,12 +102,12 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { /** `(action LIKE 'p.%' OR action = 'x' OR ...)` over a rule list. */ function anyRule(rules: readonly AuditActionRule[]): SqlFragment { - // Prefixes are compile-time literals from AUDIT_ACTION_RULES, never user input, but they still ride as - // bound parameters — LIKE's own metacharacters are not special in these dotted namespaces. + // The prefixes contain `_` (gov_claim.), a LIKE wildcard, so they are escaped to match literally, as + // the service classifier's startsWith does. const branches = rules.map((rule) => rule.exact !== undefined ? sql`a.action = ${rule.exact}` - : sql`a.action LIKE ${`${rule.prefix}%`}`, + : sql`a.action LIKE ${likePrefix(rule.prefix)} ESCAPE '\\'`, ) const first = branches[0] if (first === undefined) return sql`(false)` @@ -130,9 +139,10 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { const keyset = anchor === null ? sql`` - : desc - ? sql`AND (${tsCol}, ${idText}) < (${anchor.createdAt}, ${anchor.id}::text)` - : sql`AND (${tsCol}, ${idText}) > (${anchor.createdAt}, ${anchor.id}::text)` + : sql`AND ${keysetPredicate(sql, tsCol, idText, anchor, { + direction: desc ? "desc" : "asc", + idType: "text", + })}` const order = desc ? sql`ORDER BY ${tsCol} DESC, ${idText} DESC` : sql`ORDER BY ${tsCol} ASC, ${idText} ASC` @@ -147,6 +157,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("audit")) { branches.push(sql`( SELECT 'audit'::text AS source, a.id::text AS id, a.created_at AS ts, + ${keysetInstant(sql, sql`a.created_at`)} AS cursor_at, u.display_name AS who, a.target AS where_label, a.action AS action, NULL::text AS event_type, a.target AS subject FROM audit_log a @@ -164,6 +175,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("report")) { branches.push(sql`( SELECT 'report'::text AS source, r.id::text AS id, r.created_at AS ts, + ${keysetInstant(sql, sql`r.created_at`)} AS cursor_at, ru.display_name AS who, j.name AS where_label, NULL::text AS action, NULL::text AS event_type, r.category AS subject FROM reports r @@ -180,6 +192,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("cleanup")) { branches.push(sql`( SELECT 'cleanup'::text AS source, c.id::text AS id, c.created_at AS ts, + ${keysetInstant(sql, sql`c.created_at`)} AS cursor_at, cu.display_name AS who, c.address AS where_label, NULL::text AS action, NULL::text AS event_type, c.title AS subject FROM cleanups c @@ -195,6 +208,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { if (wants("mail_event")) { branches.push(sql`( SELECT 'mail_event'::text AS source, e.id::text AS id, e.created_at AS ts, + ${keysetInstant(sql, sql`e.created_at`)} AS cursor_at, t.org AS who, COALESCE(t.org, t.jurisdiction_geoid) AS where_label, NULL::text AS action, e.type AS event_type, t.subject AS subject FROM mail_events e @@ -224,7 +238,10 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { ${outerOrder} LIMIT ${limit + 1} ` - const { items, nextCursor } = paginate(rows, limit, (r) => ({ at: r.ts, id: r.id })) + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) return { records: items.map(toRecord), nextCursor } }, } diff --git a/services/api/src/services/admin/activity-repository.memory.ts b/services/api/src/services/admin/activity-repository.memory.ts index c6caa161..6aec40d7 100644 --- a/services/api/src/services/admin/activity-repository.memory.ts +++ b/services/api/src/services/admin/activity-repository.memory.ts @@ -3,8 +3,8 @@ * * Faithful to the Drizzle impl's observable behavior: it merges all seeded source records, drops the L4 * read audits the SQL branch excludes, applies the same search / kind facet / direction, and keyset-pages - * on (ts, id) through the shared pageInMemoryById helper so the cursor STRINGS match the Drizzle impl for - * the same page. seedRecord appends a normalized source record; the public `records` array is inspectable. + * on (ts, id) through the shared pageInMemoryById helper so the cursors share the Drizzle impl's format + * (the Drizzle cursor carries microseconds a JS Date cannot hold, so the strings differ in precision). seedRecord appends a normalized source record; the public `records` array is inspectable. * * The kind facet is evaluated by CLASSIFYING each record (classifyActivity) rather than by re-deriving the * action prefixes — the fake is allowed the round trip the SQL branch cannot afford, and it is the sharper diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index bf6e5e48..ee2349ba 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -165,6 +165,9 @@ export function describeAuditAction(action: string): string { const map: Record = { "operator.login": "Operator signed in", "operator.logout": "Operator signed out", + "operator.login_denied": "An operator sign-in was denied", + "account.deleted": "An account was deleted", + "data_export.undeliverable": "A data export could not be delivered", "discovery.contacts_saved": "Saved routing contacts", "discovery.draft_saved": "Saved a routing draft", "discovery.note_added": "Added a discovery note", @@ -180,6 +183,8 @@ export function describeAuditAction(action: string): string { "report.message_posted": "Posted in a report chat", "report.verdict_set": "Set a report verdict", "report.routed": "Forwarded a report to the city", + "report.takedown_requested": "Requested a takedown of their report", + "report_message.removed": "Removed a report chat message", "event.status_changed": "Changed an event status", "event.flagged": "Flagged an event", "event.unflagged": "Unflagged an event", @@ -188,6 +193,7 @@ export function describeAuditAction(action: string): string { "event.outcome_logged": "Logged an event outcome", "event.reports_linked": "Linked reports to an event", "event.report_unlinked": "Unlinked a report from an event", + "event.announcement_sent": "Sent an event announcement", "user.flagged": "Flagged an account", "user.unflagged": "Unflagged an account", "user.status_changed": "Changed an account status", diff --git a/services/api/src/services/admin/admin-event-helpers.ts b/services/api/src/services/admin/admin-event-helpers.ts index 78d1c71e..4bd0e7ed 100644 --- a/services/api/src/services/admin/admin-event-helpers.ts +++ b/services/api/src/services/admin/admin-event-helpers.ts @@ -77,6 +77,13 @@ export function timelineDefaultNote(kind: string): string { } } +/** The event detail shows at most this many chat messages: the most recent ones. */ +export const ADMIN_EVENT_MESSAGE_CAP = 100 + +export function eventOutcomeNote(bags: number): string { + return bags === 1 ? "Outcome logged: 1 bag" : `Outcome logged: ${bags} bags` +} + // Map a stored cleanup_timeline kind to the design's event-timeline icon kind. The stored 'flag'/'unflag' // map to 'warn'. export function eventTimelineKind(stored: string): EventTimelineItem["kind"] { diff --git a/services/api/src/services/admin/admin-event-repository.drizzle.ts b/services/api/src/services/admin/admin-event-repository.drizzle.ts index 67607324..0e57f8b8 100644 --- a/services/api/src/services/admin/admin-event-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-event-repository.drizzle.ts @@ -1,5 +1,5 @@ import type { Sql } from "../../db/client.js" -import { decodeCursor, clampLimit, paginate } from "./pagination.js" +import { decodeCursor, clampLimit, keysetPredicate, paginateKeyset } from "./pagination.js" import { writeAudit } from "./audit.js" import { adminEventStatusExpr } from "../cleanup-sql.js" import { @@ -9,6 +9,7 @@ import { toRecord, type EventRowSelect, } from "./admin-event-sql.js" +import { ADMIN_EVENT_MESSAGE_CAP, eventOutcomeNote } from "./admin-event-helpers.js" import { andAll, type SqlFragment } from "./sql-fragments.js" import { publicReportFilter } from "../report-sql.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../auth/official-account.js" @@ -22,8 +23,6 @@ import type { import type { LinkedReportView } from "../cleanup-service.js" import type { AdminEventCounts, ReportCategory, ReportStatus } from "@civfix/shared" -const MESSAGE_CAP = 100 - const LINK_REPORTS_MAX = 100 export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository { @@ -51,14 +50,14 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository ) } if (anchor !== null) { - conds.push(sql`AND (c.scheduled_at, c.id) < (${anchor.createdAt}, ${anchor.id}::uuid)`) + conds.push(sql`AND ${keysetPredicate(sql, sql`c.scheduled_at`, sql`c.id`, anchor)}`) } const extraWhere = andAll(sql, conds) const orderLimit = sql`ORDER BY c.scheduled_at DESC, c.id DESC LIMIT ${limit + 1}` const rows = (await eventSelect(sql, extraWhere, orderLimit)) as unknown as EventRowSelect[] - const { items, nextCursor } = paginate(rows, limit, (r) => ({ - at: r.scheduled_at, + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) return { records: items.map(toRecord), nextCursor } @@ -127,10 +126,10 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id WHERE m.cleanup_id = ${id} AND m.deleted_at IS NULL - ORDER BY m.created_at ASC - LIMIT ${MESSAGE_CAP} + ORDER BY m.created_at DESC, m.id DESC + LIMIT ${ADMIN_EVENT_MESSAGE_CAP} ` - return rows.map((r) => ({ + return rows.reverse().map((r) => ({ who: r.who ?? "system", text: r.body ?? "", createdAt: r.created_at, @@ -143,6 +142,10 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository UPDATE cleanups SET bags = ${input.bags} WHERE id = ${id} RETURNING id ` if (updated.length === 0) return false + await tx` + INSERT INTO cleanup_timeline (cleanup_id, kind, note, actor_id) + VALUES (${id}, 'outcome', ${eventOutcomeNote(input.bags)}, ${input.actorId}) + ` await writeAudit(tx, { actorId: input.actorId, action: "event.outcome_logged", @@ -158,7 +161,9 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository input: { reason: string | null; actorId: string | null }, ): Promise { return sql.begin(async (tx) => { - const exists = await tx<{ id: string }[]>`SELECT id FROM cleanups WHERE id = ${id} LIMIT 1` + const exists = await tx<{ id: string }[]>` + SELECT id FROM cleanups WHERE id = ${id} FOR NO KEY UPDATE + ` if (exists.length === 0) return null const latest = await tx<{ kind: string }[]>` diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/src/services/admin/admin-event-repository.memory.ts index 7f74425f..7cb5ecf3 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -21,7 +21,11 @@ import { randomUUID } from "node:crypto" import { isUuid } from "../../db/cursor-helpers.js" import { pageInMemoryById } from "./pagination.js" -import { flaggedFromTimeline } from "./admin-event-helpers.js" +import { + ADMIN_EVENT_MESSAGE_CAP, + eventOutcomeNote, + flaggedFromTimeline, +} from "./admin-event-helpers.js" import { isPubliclyVisibleStatus } from "../report-visibility.js" import { toEventStatus } from "./event-status.js" import { CIVFIX_OFFICIAL_DISPLAY_NAME } from "../../auth/official-account.js" @@ -310,13 +314,19 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { } async listMessages(id: string): Promise { - return [...(this.messages.get(id) ?? [])] + return (this.messages.get(id) ?? []).slice(-ADMIN_EVENT_MESSAGE_CAP) } async setBags(id: string, input: { bags: number; actorId: string | null }): Promise { const seeded = this.events.get(id) if (!seeded) return false seeded.record.bags = input.bags + this.appendTimeline(id, { + kind: "outcome", + note: eventOutcomeNote(input.bags), + who: "operator", + createdAt: this.nextDate(), + }) this.audits.push({ action: "event.outcome_logged", target: `cleanup:${id}`, diff --git a/services/api/src/services/admin/admin-event-sql.ts b/services/api/src/services/admin/admin-event-sql.ts index 135a373e..ac682505 100644 --- a/services/api/src/services/admin/admin-event-sql.ts +++ b/services/api/src/services/admin/admin-event-sql.ts @@ -9,6 +9,7 @@ import { toEventStatus } from "./event-status.js" import type { AdminEventRecord, AdminOrganizerRecord } from "./admin-event-service.js" import type { EventKind } from "@civfix/shared" import { adminEventStatusExpr } from "../cleanup-sql.js" +import { keysetInstant } from "./pagination.js" // The "is flagged" boolean: the most recent cleanup_timeline flag/unflag row is a 'flag'. The ONE // definition — eventSelect + countByBucket both build their flagged column/filter from this so they can't @@ -53,6 +54,7 @@ export interface EventRowSelect { lat: number lng: number scheduled_at: Date + cursor_at: string | null organizer_id: string | null organizer_name: string | null organizer_handle: string | null @@ -118,6 +120,7 @@ export function eventSelect( ST_Y(c.geom) AS lat, ST_X(c.geom) AS lng, c.scheduled_at, + ${keysetInstant(sql, sql`c.scheduled_at`)} AS cursor_at, ${personSelect(sql, "u", "organizer")} FROM cleanups c LEFT JOIN users u ON u.id = c.organizer_user_id diff --git a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts index 7392a4a6..8624e9bb 100644 --- a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts @@ -1,4 +1,5 @@ -import type { Sql } from "../../db/client.js" +import type { RoomKind } from "@civfix/shared" +import type { Queryable, Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" export interface RemoveReportMessageInput { @@ -15,6 +16,28 @@ export interface AdminReportChatRepository { ): Promise } +/** The chat room a chat or DM message lives in, or null when the id matches no message. */ +export async function findMessageRoom( + sql: Queryable, + messageId: string, +): Promise<{ kind: RoomKind; id: string } | null> { + const rows = await sql<{ room_kind: RoomKind; room_id: string }[]>` + SELECT 'dm'::text AS room_kind, thread_id::text AS room_id + FROM dm_messages WHERE id = ${messageId} + UNION ALL + SELECT CASE + WHEN report_id IS NOT NULL THEN 'report' + WHEN group_id IS NOT NULL THEN 'group' + ELSE 'cleanup' + END AS room_kind, + COALESCE(report_id, group_id, cleanup_id)::text AS room_id + FROM chat_messages WHERE id = ${messageId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : { kind: row.room_kind, id: row.room_id } +} + export function makeDrizzleAdminReportChatRepository(sql: Sql): AdminReportChatRepository { return { async reportExists(reportId: string): Promise { diff --git a/services/api/src/services/admin/admin-report-chat-service.ts b/services/api/src/services/admin/admin-report-chat-service.ts index c160a6b3..fab445ec 100644 --- a/services/api/src/services/admin/admin-report-chat-service.ts +++ b/services/api/src/services/admin/admin-report-chat-service.ts @@ -3,7 +3,10 @@ import { type ChatHistoryResponse, type ChatMessageDTO, type ReportChatHistoryRequest, + type RoomKind, } from "@civfix/shared" +import type { FastifyBaseLogger } from "fastify" +import { neutralizeChatViewerFields } from "../chat-viewer-fields.js" import { chatHistoryPayload, type ChatHistorySource } from "../../routes/chat-route-helpers.js" import { sendReportChatMessage, type ReportChatSendDeps } from "../report-chat-send.js" import type { AdminReportChatRepository } from "./admin-report-chat-repository.drizzle.js" @@ -12,10 +15,42 @@ import { CIVFIX_OFFICIAL_USER_ID } from "../../auth/official-account.js" export const ADMIN_REPORT_CHAT_HISTORY_DEFAULT = 30 export const ADMIN_REPORT_CHAT_HISTORY_MAX = 50 +/** + * Tells connected clients that an operator changed a chat or DM message (removed it, or restored it on an + * appeal), the way a member's own delete does. Called only after the change committed; never throws. + */ +export type MessageUpdateAnnouncer = (messageId: string) => Promise + +export interface MessageUpdateAnnouncerDeps { + findRoom(messageId: string): Promise<{ kind: RoomKind; id: string } | null> + loadMessage(kind: RoomKind, roomId: string, messageId: string): Promise + broadcast(kind: RoomKind, roomId: string, message: ChatMessageDTO): void + logger?: Pick +} + +export function makeMessageUpdateAnnouncer( + deps: MessageUpdateAnnouncerDeps, +): MessageUpdateAnnouncer { + return async (messageId) => { + try { + const room = await deps.findRoom(messageId) + if (room === null) return + const message = await deps.loadMessage(room.kind, room.id, messageId) + if (message === null) return + deps.broadcast(room.kind, room.id, neutralizeChatViewerFields(message)) + } catch (err) { + // The operator's change is already committed; a failed live update only delays what clients see + // until their next history fetch, so it must not turn the request into an error. + deps.logger?.warn({ err, messageId }, "admin message update broadcast failed") + } + } +} + export interface AdminReportChatServiceDeps { repo: AdminReportChatRepository historySource: (reportId: string, viewerUserId: string | null) => ChatHistorySource send: ReportChatSendDeps + announceMessageUpdate?: MessageUpdateAnnouncer } export interface AdminReportChatService { @@ -69,6 +104,7 @@ export function makeAdminReportChatService( await assertReportExists(reportId) const removed = await deps.repo.removeMessage(reportId, messageId, input) if (!removed) throw AppError.notFound("Message not found") + await deps.announceMessageUpdate?.(messageId) }, } } diff --git a/services/api/src/services/admin/admin-report-repository.drizzle.ts b/services/api/src/services/admin/admin-report-repository.drizzle.ts index 8e55d844..c2f94583 100644 --- a/services/api/src/services/admin/admin-report-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-repository.drizzle.ts @@ -1,8 +1,21 @@ +import type { FastifyBaseLogger } from "fastify" import type { Queryable, Sql } from "../../db/client.js" -import { decodeCursor, clampLimit, paginate } from "./pagination.js" +import { + decodeCursor, + clampLimit, + keysetInstant, + keysetPredicate, + paginateKeyset, +} from "./pagination.js" import { isUuid } from "../../db/cursor-helpers.js" import { writeAudit } from "./audit.js" -import { andAll, ilikeAnyOf, type SqlFragment } from "./sql-fragments.js" +import { + andAll, + firstUsableLegacyContactExpr, + ilikeAnyOf, + usableContactRowExpr, + type SqlFragment, +} from "./sql-fragments.js" import { personSelect, toPersonRecord } from "./admin-person.js" import { STATUS_BUCKETS, toTimelineKind } from "./admin-report-status.js" import { @@ -74,6 +87,7 @@ interface ReportRowSelect { preview_key: string | null preview_thumb_key: string | null created_at: Date + cursor_at: string | null reference_code: string | null verification_verdict: "approved" | "rejected" | null verified_at: Date | null @@ -157,6 +171,7 @@ function reportSelect( pm.served_key AS preview_key, pm.thumb_key AS preview_thumb_key, r.created_at, + ${keysetInstant(sql, sql`r.created_at`)} AS cursor_at, r.reference_code, r.verification_verdict, r.verified_at, @@ -181,7 +196,10 @@ function reportSelect( ` } -export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepository { +export function makeDrizzleAdminReportRepository( + sql: Sql, + opts: { logger?: Pick } = {}, +): AdminReportRepository { return { async listReports( args: ListReportsArgs, @@ -197,13 +215,16 @@ export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepositor if (args.needsVerificationOnly) conds.push(sql`AND r.verification_verdict IS NULL`) if (args.q !== null) conds.push(searchReportsFragment(sql, args.q)) if (anchor !== null) { - conds.push(sql`AND (r.created_at, r.id) < (${anchor.createdAt}, ${anchor.id}::uuid)`) + conds.push(sql`AND ${keysetPredicate(sql, sql`r.created_at`, sql`r.id`, anchor)}`) } const extraWhere = andAll(sql, conds) const orderLimit = sql`ORDER BY r.created_at DESC, r.id DESC LIMIT ${limit + 1}` const rows = (await reportSelect(sql, extraWhere, orderLimit)) as unknown as ReportRowSelect[] - const { items, nextCursor } = paginate(rows, limit, (r) => ({ at: r.created_at, id: r.id })) + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) return { records: items.map(toRecord), nextCursor } }, @@ -313,13 +334,11 @@ export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepositor j.forward_body_template, (SELECT jc.email FROM jurisdiction_contacts jc WHERE jc.geoid = j.geoid AND jc.category = r.category - AND jc.email IS NOT NULL AND jc.email <> '' - AND jc.bounced_at IS NULL LIMIT 1) AS cat_email, + AND ${usableContactRowExpr(sql, "jc")} LIMIT 1) AS cat_email, (SELECT jc.email FROM jurisdiction_contacts jc WHERE jc.geoid = j.geoid AND jc.category IS NULL - AND jc.email IS NOT NULL AND jc.email <> '' - AND jc.bounced_at IS NULL LIMIT 1) AS default_email, - (SELECT j.contact_emails[1]) AS legacy_email + AND ${usableContactRowExpr(sql, "jc")} LIMIT 1) AS default_email, + ${firstUsableLegacyContactExpr(sql, "j")} AS legacy_email FROM reports r LEFT JOIN jurisdictions j ON j.geoid = r.jurisdiction_geoid WHERE r.id = ${id} @@ -505,7 +524,7 @@ export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepositor ): Promise { return sql.begin(async (tx) => { const exists = await tx<{ status: AdminReportStatus }[]>` - SELECT status FROM reports WHERE id = ${id} AND deleted_at IS NULL LIMIT 1 + SELECT status FROM reports WHERE id = ${id} AND deleted_at IS NULL FOR NO KEY UPDATE ` const report = exists[0] if (!report) return null @@ -595,19 +614,23 @@ export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepositor async setReportVerdict( id: string, - input: { verdict: "approved" | "rejected"; actorId: string | null }, + input: { verdict: "approved" | "rejected"; actorId: string | null; note: string }, ): Promise { return sql.begin(async (tx) => { - const updated = await tx<{ reporter_user_id: string | null }[]>` + const updated = await tx<{ reporter_user_id: string | null; status: AdminReportStatus }[]>` UPDATE reports SET verification_verdict = ${input.verdict}, verified_by = ${input.actorId}, verified_at = now() WHERE id = ${id} AND deleted_at IS NULL - RETURNING reporter_user_id + RETURNING reporter_user_id, status ` const row = updated[0] if (!row) return false + await tx` + INSERT INTO report_timeline (report_id, status, note, kind, actor_id) + VALUES (${id}, ${row.status}, ${input.note}, 'status', ${input.actorId}) + ` await writeAudit(tx, { actorId: input.actorId, action: "report.verdict_set", @@ -648,8 +671,12 @@ export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepositor await reserved`SELECT pg_advisory_lock(${ROUTE_LOCK_NAMESPACE}, hashtext(${id}))` return await fn() } finally { + // A failed unlock almost always means the session is gone, which releases the lock server-side. + // It must not mask fn()'s own outcome, but it is logged so a stuck route lock is traceable. await reserved`SELECT pg_advisory_unlock(${ROUTE_LOCK_NAMESPACE}, hashtext(${id}))`.catch( - () => {}, + (err: unknown) => { + opts.logger?.warn({ err, reportId: id }, "report route advisory unlock failed") + }, ) reserved.release() } diff --git a/services/api/src/services/admin/admin-report-repository.memory.ts b/services/api/src/services/admin/admin-report-repository.memory.ts index 99050442..7afd5047 100644 --- a/services/api/src/services/admin/admin-report-repository.memory.ts +++ b/services/api/src/services/admin/admin-report-repository.memory.ts @@ -390,12 +390,19 @@ export class InMemoryAdminReportRepository implements AdminReportRepository { async setReportVerdict( id: string, - input: { verdict: "approved" | "rejected"; actorId: string | null }, + input: { verdict: "approved" | "rejected"; actorId: string | null; note: string }, ): Promise { const seeded = this.reports.get(id) if (!seeded || seeded.deletedAt !== null) return false seeded.record.verificationVerdict = input.verdict seeded.record.verifiedAt = this.nextDate() + this.appendTimeline(id, { + status: seeded.record.status, + note: input.note, + kind: "status", + who: "operator", + createdAt: this.nextDate(), + }) this.audits.push({ action: "report.verdict_set", target: `report:${id}`, diff --git a/services/api/src/services/admin/admin-report-service.ts b/services/api/src/services/admin/admin-report-service.ts index cd64c2c8..9a03f783 100644 --- a/services/api/src/services/admin/admin-report-service.ts +++ b/services/api/src/services/admin/admin-report-service.ts @@ -279,6 +279,7 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor ): Promise { const flagged = await deps.repo.toggleFlag(id, input) if (flagged === null) throw AppError.notFound("Report not found") + // The toggle is committed; failing the request would invite a retry, and a retried toggle unflags. try { const record = await deps.repo.getReport(id) if (record) { @@ -289,8 +290,8 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor note: flagged ? "Flagged for review" : "Flag cleared", }) } - } catch { - void 0 + } catch (err) { + deps.logger?.warn({ err, reportId: id }, "report flag chat mirror failed") } return flagged }, @@ -329,18 +330,19 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor if (notifications === undefined) { throw AppError.internal("Reporter notifications are not wired on this instance") } - await notifications.createNotification(reporterId, { - type: "report_update", - title: "Update on your report", - body: input.body, - link: `/reports/${id}`, - }) + // Audit before the citizen is messaged, so no operator message ever reaches a reporter unrecorded. await recordFollowup(deps, id, { note: "Follow-up sent to the reporter", actorId: input.actorId, to: "reporter", destination: reporterId, }) + await notifications.createNotification(reporterId, { + type: "report_update", + title: "Update on your report", + body: input.body, + link: `/reports/${id}`, + }) await emitTimeline({ reportId: id, status: record.status, @@ -487,14 +489,14 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor verdict: "approved" | "rejected" actorId: string | null }): Promise { + const note = + input.verdict === "approved" ? "Approved by an operator" : "Rejected by an operator" const ok = await deps.repo.setReportVerdict(input.id, { verdict: input.verdict, actorId: input.actorId, + note, }) if (!ok) throw AppError.notFound("Report not found") - const note = - input.verdict === "approved" ? "Approved by an operator" : "Rejected by an operator" - await deps.repo.appendSystemTimeline(input.id, { note, kind: "status" }) const record = await deps.repo.getReport(input.id) await emitTimeline({ reportId: input.id, diff --git a/services/api/src/services/admin/admin-report-types.ts b/services/api/src/services/admin/admin-report-types.ts index c13fec7f..5c59896d 100644 --- a/services/api/src/services/admin/admin-report-types.ts +++ b/services/api/src/services/admin/admin-report-types.ts @@ -142,7 +142,7 @@ export interface AdminReportRepository { ): Promise setReportVerdict( id: string, - input: { verdict: "approved" | "rejected"; actorId: string | null }, + input: { verdict: "approved" | "rejected"; actorId: string | null; note: string }, ): Promise withRouteLock(id: string, fn: () => Promise): Promise } diff --git a/services/api/src/services/admin/admin-user-repository.drizzle.ts b/services/api/src/services/admin/admin-user-repository.drizzle.ts index 9f00f575..03df25b8 100644 --- a/services/api/src/services/admin/admin-user-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-user-repository.drizzle.ts @@ -1,7 +1,14 @@ import type { Queryable, Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" -import { clampLimit, decodeCursor } from "./pagination.js" -import { paginate } from "../../db/cursor-helpers.js" +import { assertTargetIsNotOperatorRole } from "../../auth/operator-target.js" +import { + clampLimit, + decodeCursor, + keysetInstant, + keysetPredicate, + paginateKeyset, + type KeysetAnchor, +} from "./pagination.js" import { andAll, ilikeAnyOf, type SqlFragment } from "./sql-fragments.js" import type { AdminUserOrganizationRecord, @@ -60,6 +67,7 @@ interface UserRowSelect { city: string | null role: Role created_at: Date | null + cursor_at: string | null last_active_at: Date | null account_status: UserStatus reports: string @@ -127,6 +135,7 @@ function userSelect( u.role, u.avatar_url, u.created_at, + ${keysetInstant(sql, sql`u.created_at`)} AS cursor_at, u.deleted_at, (SELECT MAX(s.last_seen_at) FROM sessions s WHERE s.user_id = u.id) AS last_active_at, COALESCE(um.account_status, 'active') AS account_status, @@ -166,7 +175,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { conds.push(searchUsersFragment(sql, args.q, cityUserIds)) } if (anchor !== null) { - conds.push(sql`AND (u.created_at, u.id) < (${anchor.createdAt}, ${anchor.id}::uuid)`) + conds.push(sql`AND ${keysetPredicate(sql, sql`u.created_at`, sql`u.id`, anchor)}`) } const extraWhere = andAll(sql, conds) const orderLimit = sql`ORDER BY u.created_at DESC, u.id DESC LIMIT ${limit + 1}` @@ -177,8 +186,8 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { orderLimit, false, )) as unknown as UserRowSelect[] - const { items, nextCursor } = paginate(rows, limit, (r) => ({ - createdAt: r.created_at ?? EPOCH, + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at ?? EPOCH.toISOString(), id: r.id, })) return { records: items.map(toRecord), nextCursor } @@ -276,7 +285,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { const anchor = decodeKeyset(cursor) const cursorFilter = anchor !== null - ? sql`AND (r.created_at, r.id) < (${anchor.createdAt}, ${anchor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`r.created_at`, sql`r.id`, anchor)}` : sql`` const rows = await sql< { @@ -286,9 +295,11 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { place: string | null status: AdminReportStatus created_at: Date + cursor_at: string }[] >` - SELECT r.id, r.category, r.title, j.name AS place, r.status, r.created_at + SELECT r.id, r.category, r.title, j.name AS place, r.status, r.created_at, + ${keysetInstant(sql, sql`r.created_at`)} AS cursor_at FROM reports r LEFT JOIN jurisdictions j ON j.geoid = r.jurisdiction_geoid WHERE r.reporter_user_id = ${id} AND r.deleted_at IS NULL @@ -296,8 +307,12 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { ORDER BY r.created_at DESC, r.id DESC LIMIT ${lim + 1} ` - const { items, nextCursor } = paginate( - rows.map((r) => ({ + const { items, nextCursor } = paginateKeyset(rows, lim, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) + return { + records: items.map((r) => ({ id: r.id, category: r.category, title: r.title ?? "Untitled report", @@ -305,10 +320,8 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { status: r.status, createdAt: r.created_at, })), - lim, - (r) => ({ createdAt: r.createdAt, id: r.id }), - ) - return { records: items, nextCursor } + nextCursor, + } }, async listUserEvents( @@ -320,7 +333,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { const anchor = decodeKeyset(cursor) const cursorFilter = anchor !== null - ? sql`AND (cm.joined_at, c.id) < (${anchor.createdAt}, ${anchor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`cm.joined_at`, sql`c.id`, anchor)}` : sql`` const rows = await sql< { @@ -330,6 +343,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { role: CleanupMemberRole attendees: string when_at: Date + cursor_at: string }[] >` SELECT @@ -338,7 +352,8 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { c.address AS place, cm.role, (SELECT COUNT(*) FROM cleanup_members x WHERE x.cleanup_id = c.id)::text AS attendees, - cm.joined_at AS when_at + cm.joined_at AS when_at, + ${keysetInstant(sql, sql`cm.joined_at`)} AS cursor_at FROM cleanup_members cm JOIN cleanups c ON c.id = cm.cleanup_id WHERE cm.user_id = ${id} @@ -346,8 +361,12 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { ORDER BY cm.joined_at DESC, c.id DESC LIMIT ${lim + 1} ` - const { items, nextCursor } = paginate( - rows.map((r) => ({ + const { items, nextCursor } = paginateKeyset(rows, lim, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) + return { + records: items.map((r) => ({ id: r.id, title: r.title ?? "Cleanup", place: r.place ?? "", @@ -355,10 +374,8 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { attendees: Number(r.attendees ?? "0"), whenAt: r.when_at, })), - lim, - (r) => ({ createdAt: r.whenAt, id: r.id }), - ) - return { records: items, nextCursor } + nextCursor, + } }, async listUserMessages( @@ -369,9 +386,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { const lim = clampLimit(limit) const anchor = decodeKeyset(cursor) const branchCursor = (createdAt: SqlFragment, id2: SqlFragment): SqlFragment => - anchor !== null - ? sql`AND (${createdAt}, ${id2}) < (${anchor.createdAt}, ${anchor.id}::uuid)` - : sql`` + anchor !== null ? sql`AND ${keysetPredicate(sql, createdAt, id2, anchor)}` : sql`` const probe = lim + 1 const rows = await sql< { @@ -379,12 +394,14 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { body: string | null thread: string | null created_at: Date + cursor_at: string deleted_at: Date | null source: "chat" | "group" | "dm" | "report" source_id: string | null }[] >` - SELECT m.id, m.body, m.thread, m.created_at, m.deleted_at, m.source, m.source_id + SELECT m.id, m.body, m.thread, m.created_at, ${keysetInstant(sql, sql`m.created_at`)} AS cursor_at, + m.deleted_at, m.source, m.source_id FROM ( -- Event chat: the navigable origin is the cleanup/event the message belongs to. (SELECT cm.id, cm.body, c.title AS thread, cm.created_at, cm.deleted_at, 'chat' AS source, @@ -431,8 +448,12 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { ORDER BY m.created_at DESC, m.id DESC LIMIT ${probe} ` - const { items, nextCursor } = paginate( - rows.map((r) => ({ + const { items, nextCursor } = paginateKeyset(rows, lim, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) + return { + records: items.map((r) => ({ id: r.id, text: r.body ?? "", thread: r.thread ?? threadFallback(r.source), @@ -441,10 +462,8 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { source: r.source, sourceId: r.source_id, })), - lim, - (r) => ({ createdAt: r.createdAt, id: r.id }), - ) - return { records: items, nextCursor } + nextCursor, + } }, async toggleFlag( @@ -453,7 +472,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { ): Promise { return sql.begin(async (tx) => { const exists = await tx<{ id: string }[]>` - SELECT id FROM users WHERE id = ${id} AND deleted_at IS NULL LIMIT 1 + SELECT id FROM users WHERE id = ${id} AND deleted_at IS NULL FOR NO KEY UPDATE ` if (exists.length === 0) return null @@ -494,10 +513,14 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { input: { status: UserStatus; reason: string | null; actorId: string | null }, ): Promise { return sql.begin(async (tx) => { - const exists = await tx<{ id: string }[]>` - SELECT id FROM users WHERE id = ${id} AND deleted_at IS NULL LIMIT 1 + // The service's operator check ran in an earlier query; re-check on the locked row so a target + // promoted in between cannot be banned or suspended from the console. + const target = await tx<{ id: string; role: Role }[]>` + SELECT id, role FROM users WHERE id = ${id} AND deleted_at IS NULL FOR NO KEY UPDATE ` - if (exists.length === 0) return false + const row = target[0] + if (row === undefined) return false + assertTargetIsNotOperatorRole(row.role, "ban or change the status of") await tx` INSERT INTO user_moderation (user_id, account_status, updated_at) VALUES (${id}, ${input.status}, now()) @@ -517,10 +540,11 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { async applyRole(id: string, input: { role: Role; actorId: string | null }): Promise { return sql.begin(async (tx) => { const existing = await tx<{ role: Role }[]>` - SELECT role FROM users WHERE id = ${id} AND deleted_at IS NULL LIMIT 1 + SELECT role FROM users WHERE id = ${id} AND deleted_at IS NULL FOR NO KEY UPDATE ` const priorRole = existing[0]?.role if (priorRole === undefined) return false + assertTargetIsNotOperatorRole(priorRole, "change the role of") await tx`UPDATE users SET role = ${input.role} WHERE id = ${id}` await writeAudit(tx, { actorId: input.actorId, @@ -610,7 +634,8 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { } } -const decodeKeyset = (cursor: string | null | undefined) => decodeCursor(cursor, true) +const decodeKeyset = (cursor: string | null | undefined): KeysetAnchor | null => + decodeCursor(cursor, true) function threadFallback(source: "chat" | "group" | "dm" | "report"): string { switch (source) { diff --git a/services/api/src/services/admin/admin-user-service.ts b/services/api/src/services/admin/admin-user-service.ts index f2c86b43..dccda855 100644 --- a/services/api/src/services/admin/admin-user-service.ts +++ b/services/api/src/services/admin/admin-user-service.ts @@ -22,6 +22,7 @@ import type { } from "@civfix/shared" import { toRelAbs } from "./admin-format.js" import { applyRoleChange } from "./role-change.js" +import type { MessageUpdateAnnouncer } from "./admin-report-chat-service.js" import { assertTargetIsNotOfficialAccount, assertTargetIsNotOperatorRole, @@ -165,6 +166,7 @@ export interface AdminUserServiceDeps { repo: AdminUserRepository sessions: SessionControl now?: () => Date + announceMessageUpdate?: MessageUpdateAnnouncer } export const USER_SUBLIST_DEFAULT_LIMIT = 20 @@ -382,6 +384,7 @@ export function makeAdminUserService(deps: AdminUserServiceDeps): AdminUserServi ): Promise { const ok = await deps.repo.removeUserMessage(userId, messageId, input) if (!ok) throw AppError.notFound("Message not found") + await deps.announceMessageUpdate?.(messageId) }, } } diff --git a/services/api/src/services/admin/analytics-repository.drizzle.ts b/services/api/src/services/admin/analytics-repository.drizzle.ts index fd0d6e0d..aa08ed4b 100644 --- a/services/api/src/services/admin/analytics-repository.drizzle.ts +++ b/services/api/src/services/admin/analytics-repository.drizzle.ts @@ -30,14 +30,32 @@ interface AnalyticsCacheEntry { value: Promise } -const analyticsCache = new Map() +// Module-scoped because the admin routes rebuild the repository per request; keyed by the sql handle so +// two repositories over different databases in one process never read each other's aggregates. +const analyticsCaches = new WeakMap>() -function withCache(ttlMs: number, key: string, run: () => Promise): Promise { +function cacheFor(sql: Sql): Map { + let cache = analyticsCaches.get(sql) + if (cache === undefined) { + cache = new Map() + analyticsCaches.set(sql, cache) + } + return cache +} + +function withCacheIn( + analyticsCache: Map, + ttlMs: number, + key: string, + run: () => Promise, +): Promise { if (ttlMs <= 0) return run() const hit = analyticsCache.get(key) if (hit !== undefined && Date.now() - hit.at <= ttlMs) return hit.value as Promise const value = run() analyticsCache.set(key, { at: Date.now(), value }) + // The caller awaits `value` and sees the rejection itself; this handler only evicts the failed entry so + // the next call retries instead of serving a cached failure for the whole TTL. void value.catch(() => { const cur = analyticsCache.get(key) if (cur !== undefined && cur.value === value) analyticsCache.delete(key) @@ -303,7 +321,7 @@ export function makeDrizzleAnalyticsRepository( WITH report_counts AS ( SELECT reporter_user_id AS user_id, COUNT(*)::int AS n FROM reports - WHERE deleted_at IS NULL AND reporter_user_id IS NOT NULL + WHERE deleted_at IS NULL AND visibility = 'public' AND reporter_user_id IS NOT NULL GROUP BY reporter_user_id ), cleanup_counts AS ( @@ -328,7 +346,10 @@ export function makeDrizzleAnalyticsRepository( ) t LEFT JOIN report_counts rc ON rc.user_id = t.user_id LEFT JOIN cleanup_counts cc ON cc.user_id = t.user_id - WHERE t.total > 0 + -- Filtered before the LIMIT so a deleted or banned account never takes a leaderboard slot. + JOIN users tu ON tu.id = t.user_id AND tu.deleted_at IS NULL + LEFT JOIN user_moderation tm ON tm.user_id = t.user_id + WHERE t.total > 0 AND COALESCE(tm.account_status, 'active') <> 'banned' ORDER BY t.total DESC, t.user_id ASC LIMIT ${limit} ), @@ -471,6 +492,9 @@ export function makeDrizzleAnalyticsRepository( const ttl = opts?.cacheTtlMs ?? 0 if (ttl <= 0) return base + const cache = cacheFor(sql) + const withCache = (ttlMs: number, key: string, run: () => Promise): Promise => + withCacheIn(cache, ttlMs, key, run) return { kpis: () => withCache(ttl, "kpis", () => base.kpis()), pinsByWeek: (weeks) => withCache(ttl, `pinsByWeek:${weeks}`, () => base.pinsByWeek(weeks)), diff --git a/services/api/src/services/admin/audit-repository.drizzle.ts b/services/api/src/services/admin/audit-repository.drizzle.ts index 7b26dfd3..cf74d030 100644 --- a/services/api/src/services/admin/audit-repository.drizzle.ts +++ b/services/api/src/services/admin/audit-repository.drizzle.ts @@ -1,5 +1,11 @@ import type { Sql } from "../../db/client.js" -import { clampLimit, decodeCursor, paginate } from "./pagination.js" +import { + clampLimit, + decodeCursor, + keysetInstant, + keysetPredicate, + paginateKeyset, +} from "./pagination.js" import { isUuid } from "../../db/cursor-helpers.js" import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" import { likeContains } from "./like.js" @@ -12,6 +18,7 @@ interface AuditRowSelect { target: string | null meta: Record | null created_at: Date + cursor_at: string | null } function toRecord(r: AuditRowSelect): AuditRecord { @@ -35,7 +42,7 @@ export function makeDrizzleAuditRepository(sql: Sql): AuditRepository { const anchor = decodeCursor(args.cursor, true) const cursorFilter = anchor !== null - ? sql`AND (a.created_at, a.id) < (${anchor.createdAt}, ${anchor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`a.created_at`, sql`a.id`, anchor)}` : sql`` const actorFilter = args.actor !== null @@ -55,7 +62,8 @@ export function makeDrizzleAuditRepository(sql: Sql): AuditRepository { : sql`` const rows = await sql` - SELECT a.id, a.actor_id, u.display_name AS actor_name, a.action, a.target, a.meta, a.created_at + SELECT a.id, a.actor_id, u.display_name AS actor_name, a.action, a.target, a.meta, a.created_at, + ${keysetInstant(sql, sql`a.created_at`)} AS cursor_at FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id WHERE true @@ -66,8 +74,8 @@ export function makeDrizzleAuditRepository(sql: Sql): AuditRepository { ORDER BY a.created_at DESC, a.id DESC LIMIT ${limit + 1} ` - const { items, nextCursor } = paginate(rows, limit, (r) => ({ - createdAt: r.created_at, + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) return { records: items.map(toRecord), nextCursor } diff --git a/services/api/src/services/admin/audit.ts b/services/api/src/services/admin/audit.ts index e6fa335a..cfe1e998 100644 --- a/services/api/src/services/admin/audit.ts +++ b/services/api/src/services/admin/audit.ts @@ -3,11 +3,15 @@ import type { Queryable } from "../../db/client.js" export type AdminAuditAction = | "operator.login" | "operator.logout" + | "operator.login_denied" + | "account.deleted" + | "data_export.undeliverable" | "auth.otp_refused_unverified_account" | "discovery.contacts_saved" | "discovery.draft_saved" | "discovery.note_added" | "discovery.flagged" + | "discovery.contact_suggested" | "jurisdiction.patched" | "report.status_changed" | "report.flagged" @@ -16,11 +20,18 @@ export type AdminAuditAction = | "report.followup_sent" | "report.message_posted" | "report.routed" + | "report.verdict_set" + | "report.takedown_requested" + | "report_message.removed" | "event.status_changed" | "event.flagged" | "event.unflagged" | "event.cancelled" | "event.message_posted" + | "event.outcome_logged" + | "event.reports_linked" + | "event.report_unlinked" + | "event.announcement_sent" | "user.flagged" | "user.unflagged" | "user.status_changed" @@ -28,6 +39,9 @@ export type AdminAuditAction = | "user.role_changed" | "user.verified" | "user.unverified" + | "user.report_verified" + | "user.report_unverified" + | "message.removed" | "gov_claim.verified" | "gov_claim.approved" | "gov_claim.rejected" @@ -67,6 +81,7 @@ export type AdminAuditAction = | "org.invite_created" | "org.invite_revoked" | "org.invite_accepted" + | "org.invite_declined" | "org.list_viewed" | "org.members_viewed" | "org.events_viewed" @@ -93,7 +108,6 @@ export type AdminAuditAction = | "event.roster_exported" | "host.messaging_suspended" | "host.messaging_restored" - | (string & {}) export const AUDIT_READ_ACTIONS: readonly AdminAuditAction[] = [ "user.detail_viewed", diff --git a/services/api/src/services/admin/autoforward-jobs.ts b/services/api/src/services/admin/autoforward-jobs.ts index 29195b0b..a88ebb34 100644 --- a/services/api/src/services/admin/autoforward-jobs.ts +++ b/services/api/src/services/admin/autoforward-jobs.ts @@ -22,9 +22,13 @@ export interface AutoForwardLogger { warn: (obj: unknown, msg?: string) => void } +export interface AutoForwardJobLogger extends AutoForwardLogger { + error: (obj: unknown, msg?: string) => void +} + export async function registerAutoForwardJobs( container: Container, - logger?: AutoForwardLogger, + logger?: AutoForwardJobLogger, ): Promise { await container.jobs.work(REPORT_AUTOFORWARD_JOB, async (job) => { const reportId = extractReportId(job.data) @@ -36,9 +40,9 @@ export async function registerAutoForwardJobs( export async function runAutoForward( container: Container, reportId: string, - logger?: AutoForwardLogger, + logger?: AutoForwardJobLogger, ): Promise { - await runAutoForwardWith(makeAutoForwardService(container), reportId, logger) + await runAutoForwardWith(makeAutoForwardService(container, logger), reportId, logger) } export async function runAutoForwardWith( @@ -81,17 +85,23 @@ function isTransientInfraError(err: unknown): boolean { return true } -function makeAutoForwardService(container: Container): AdminReportService { +// Reporter notifications and linked events are left out on purpose: the job only reads the routing +// contact and routes, and neither path uses them. +function makeAutoForwardService( + container: Container, + logger: AutoForwardJobLogger | undefined, +): AdminReportService { const sql = container.getDb().sql - const outboundMail = makeContainerOutboundMailService(container) + const withLogger = logger !== undefined ? { logger } : {} return makeAdminReportService({ - repo: makeDrizzleAdminReportRepository(sql), - outboundMail, + repo: makeDrizzleAdminReportRepository(sql, withLogger), + outboundMail: makeContainerOutboundMailService(container, withLogger), presignMedia: makePrivateMediaPresigner(container.storage), presignPacketMedia: makePacketMediaPresigner(container.storage), loadMediaBytes: (k) => container.storage.getObject(k), - reportChatEmitter: makeContainerReportChatEmitter(container), + reportChatEmitter: makeContainerReportChatEmitter(container, logger), forwardTemplates: makeDrizzleForwardTemplateRepository(sql), + ...withLogger, }) } diff --git a/services/api/src/services/admin/discovery-jobs.ts b/services/api/src/services/admin/discovery-jobs.ts index 967fbe17..c83e5aa0 100644 --- a/services/api/src/services/admin/discovery-jobs.ts +++ b/services/api/src/services/admin/discovery-jobs.ts @@ -14,6 +14,7 @@ import { type JurisdictionDiscoveryJob, } from "../../services/jurisdiction-service.js" import { makeDrizzleDiscoveryRepository } from "./discovery-repository.drizzle.js" +import { legacyContactEmailUsable } from "./sql-fragments.js" export async function registerDiscoveryJobs(container: Container): Promise { await container.jobs.work(JURISDICTION_DISCOVERY_JOB, async (job) => { @@ -24,16 +25,25 @@ export async function registerDiscoveryJobs(container: Container): Promise const sql = container.getDb().sql // Raced-onboarding skip: a contact may have been saved between the enqueue and this run, in which case - // the jurisdiction is already routable and there is nothing to discover. Cheap EXISTS probe over the - // per-category routing model + the legacy contact_emails[] (mirrors loadHealth's has_routing_contact). + // the jurisdiction is already routable and there is nothing to discover. Only a contact that has not + // bounced counts: the bounce handler enqueues this job for the geoid whose contact it just marked. const contactRows = await sql<{ has_contact: boolean }[]>` SELECT EXISTS ( SELECT 1 FROM jurisdiction_contacts jc WHERE jc.geoid = ${geoid} AND jc.email IS NOT NULL AND jc.email <> '' + AND jc.bounced_at IS NULL ) OR EXISTS ( SELECT 1 FROM jurisdictions j WHERE j.geoid = ${geoid} - AND j.contact_emails IS NOT NULL AND array_length(j.contact_emails, 1) > 0 + AND EXISTS ( + SELECT 1 FROM unnest(j.contact_emails) AS e + WHERE e <> '' + AND ${legacyContactEmailUsable(sql, { + email: sql`e`, + geoid: sql`j.geoid`, + contactUpdatedAt: sql`j.contact_updated_at`, + })} + ) ) AS has_contact ` if (contactRows[0]?.has_contact === true) return diff --git a/services/api/src/services/admin/discovery-repository.drizzle.ts b/services/api/src/services/admin/discovery-repository.drizzle.ts index bd1929c0..5f9ebfc1 100644 --- a/services/api/src/services/admin/discovery-repository.drizzle.ts +++ b/services/api/src/services/admin/discovery-repository.drizzle.ts @@ -288,7 +288,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { ): Promise { return sql.begin(async (tx) => { const taskRows = await tx<{ sample_report_id: string | null }[]>` - SELECT sample_report_id FROM jurisdiction_discovery_tasks WHERE id = ${id} LIMIT 1 + SELECT sample_report_id FROM jurisdiction_discovery_tasks WHERE id = ${id} FOR UPDATE ` const task = taskRows[0] if (!task) return false @@ -296,11 +296,19 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { if (task.sample_report_id !== null) { await tx` INSERT INTO abuse_flags (subject_type, subject_id, reason, source) - VALUES ('report', ${task.sample_report_id}, 'manual', 'api') + SELECT 'report', ${task.sample_report_id}, 'manual', 'api' + WHERE NOT EXISTS ( + SELECT 1 FROM abuse_flags + WHERE subject_type = 'report' AND subject_id = ${task.sample_report_id} + AND reason = 'manual' AND resolved_at IS NULL + ) ` } + // A done task stays done: re-opening it would collide with a newer open task for the same geoid + // on the one-open-task-per-geoid unique index. await tx` - UPDATE jurisdiction_discovery_tasks SET status = 'in_progress' WHERE id = ${id} + UPDATE jurisdiction_discovery_tasks SET status = 'in_progress' + WHERE id = ${id} AND status <> 'done' ` await writeAudit(tx, { actorId: input.actorId, @@ -321,7 +329,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { actorId: string | null }, ): Promise { - return sql.begin(async (tx) => { + const saved = await sql.begin(async (tx) => { const taskRows = await tx<{ geoid: string | null }[]>` SELECT geoid FROM jurisdiction_discovery_tasks WHERE id = ${id} LIMIT 1 ` @@ -348,6 +356,8 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { }) return true }) + if (saved) invalidateDirectoryFacetCache() + return saved }, async materializeDiscoveryTask(input: { @@ -423,6 +433,34 @@ async function loadGeometry( return { placeGeojson, center, zoom } } +const DIRECTORY_FACET_TTL_MS = 30_000 + +export interface DirectoryFacetAggregate { + total: number + facets: { routed: number; unrouted: number } +} + +// The directory's default-view facet counts are cached per process. The cache lives beside +// upsertJurisdictionContacts so every in-process writer of routing contacts can drop it after commit. +let directoryFacetCache: { at: number; value: DirectoryFacetAggregate } | null = null + +export function readDirectoryFacetCache(): DirectoryFacetAggregate | null { + if (directoryFacetCache === null) return null + if (Date.now() - directoryFacetCache.at > DIRECTORY_FACET_TTL_MS) { + directoryFacetCache = null + return null + } + return directoryFacetCache.value +} + +export function writeDirectoryFacetCache(value: DirectoryFacetAggregate): void { + directoryFacetCache = { at: Date.now(), value } +} + +export function invalidateDirectoryFacetCache(): void { + directoryFacetCache = null +} + export interface UpsertDefaultContactOpts { setEmail?: boolean setFormUrl?: boolean diff --git a/services/api/src/services/admin/discovery-repository.memory.ts b/services/api/src/services/admin/discovery-repository.memory.ts index 9c6e2f7e..e02a9da5 100644 --- a/services/api/src/services/admin/discovery-repository.memory.ts +++ b/services/api/src/services/admin/discovery-repository.memory.ts @@ -8,7 +8,7 @@ * - getDetail/getTask/listNotes read the seeded task + its contacts + notes; * - addNote appends a note (the Drizzle impl persists it as an audit_log discovery.note_added row; * here it is appended to the task's note list with the same observable result); - * - flagTask marks the task in_progress (+ would open an abuse_flag in the Drizzle impl); + * - flagTask marks an open task in_progress (+ would open an abuse_flag in the Drizzle impl); * - saveDraft upserts the per-category + default contacts WITHOUT routing. * Seed/inspect helpers (seedTask, the public tasks/contacts/notes maps) let tests arrange + assert * state directly. The waiting-report aggregates (perCategory/total/oldest/newest/sample pins) are seeded @@ -217,7 +217,7 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { ): Promise { const seeded = this.tasks.get(id) if (!seeded) return false - seeded.task.status = "in_progress" + if (seeded.task.status !== "done") seeded.task.status = "in_progress" return true } diff --git a/services/api/src/services/admin/gov-claims-repository.drizzle.ts b/services/api/src/services/admin/gov-claims-repository.drizzle.ts index fa88891f..a341e535 100644 --- a/services/api/src/services/admin/gov-claims-repository.drizzle.ts +++ b/services/api/src/services/admin/gov-claims-repository.drizzle.ts @@ -22,7 +22,13 @@ import type { Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" -import { decodeCursor, clampLimit, paginate } from "./pagination.js" +import { + decodeCursor, + clampLimit, + keysetInstant, + keysetPredicate, + paginateKeyset, +} from "./pagination.js" import { ilikeAnyOf } from "./sql-fragments.js" import { type GovCheckRecord, @@ -113,15 +119,15 @@ export function makeDrizzleGovClaimsRepository(sql: Sql): GovClaimsRepository { : sql`` const keyset = !anchor ? sql`` - : newestFirst - ? sql`AND (created_at, id) < (${anchor.createdAt}, ${anchor.id}::uuid)` - : sql`AND (created_at, id) > (${anchor.createdAt}, ${anchor.id}::uuid)` + : sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, anchor, { + direction: newestFirst ? "desc" : "asc", + })}` const order = newestFirst ? sql`ORDER BY created_at DESC, id DESC` : sql`ORDER BY created_at ASC, id ASC` - const rows = await sql` - SELECT ${cols} + const rows = await sql<(GovClaimRow & { cursor_at: string })[]>` + SELECT ${cols}, ${keysetInstant(sql, sql`created_at`)} AS cursor_at FROM gov_claims WHERE true ${facet} @@ -131,10 +137,8 @@ export function makeDrizzleGovClaimsRepository(sql: Sql): GovClaimsRepository { LIMIT ${limit + 1} ` - // paginate() owns the has-more split AND the cursor format; this site used to hand-concatenate - // "|" itself, so a change to the shared encoding would have silently skipped it. - const { items, nextCursor } = paginate(rows, limit, (r) => ({ - createdAt: r.created_at, + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) return { records: items.map(toRecord), nextCursor } diff --git a/services/api/src/services/admin/home-repository.drizzle.ts b/services/api/src/services/admin/home-repository.drizzle.ts index cafacd28..f682a8af 100644 --- a/services/api/src/services/admin/home-repository.drizzle.ts +++ b/services/api/src/services/admin/home-repository.drizzle.ts @@ -1,6 +1,7 @@ import type { Sql } from "../../db/client.js" import { makeDrizzleMailRepository } from "./mail-repository.drizzle.js" import { flaggedReportExpr } from "./admin-report-repository.drizzle.js" +import { flaggedEventExpr } from "./admin-event-sql.js" import { reportRoutableExpr } from "./sql-fragments.js" import { toEventStatus } from "./event-status.js" import { DISCOVERY_SLA_HOURS } from "./discovery-service.js" @@ -202,6 +203,7 @@ export function makeDrizzleHomeRepository(sql: Sql): HomeRepository { title: string place: string | null attendees: string + flagged: boolean }[] >` SELECT @@ -209,6 +211,7 @@ export function makeDrizzleHomeRepository(sql: Sql): HomeRepository { ST_Y(c.geom) AS lat, ST_X(c.geom) AS lng, ${adminEventStatusExpr(sql)} AS status, + ${flaggedEventExpr(sql)} AS flagged, c.event_kind AS event_kind, c.title AS title, c.address AS place, @@ -239,7 +242,7 @@ export function makeDrizzleHomeRepository(sql: Sql): HomeRepository { lng: e.lng, category: null, status: toEventStatus(e.status), - flagged: false, + flagged: e.flagged, title: e.title, place: e.place ?? "", attendees: num(e.attendees), diff --git a/services/api/src/services/admin/home-service.ts b/services/api/src/services/admin/home-service.ts index 1ac94ebb..6f4be94d 100644 --- a/services/api/src/services/admin/home-service.ts +++ b/services/api/src/services/admin/home-service.ts @@ -47,6 +47,8 @@ const ZERO_ANALYTICS_MINI: AnalyticsMini = { pinsByWeek: new Array(PINS_BY_WEEK_WEEKS).fill(0), } +// One failing section must not blank the whole operator home page; the failure reaches the log through +// onError. export async function safeSection( produce: () => Promise, fallback: T, diff --git a/services/api/src/services/admin/inbound-bounce.ts b/services/api/src/services/admin/inbound-bounce.ts index 763d7ed3..7fa2ac0b 100644 --- a/services/api/src/services/admin/inbound-bounce.ts +++ b/services/api/src/services/admin/inbound-bounce.ts @@ -2,6 +2,7 @@ import type { Container } from "../../di.js" import type { Sql } from "../../db/client.js" import type { ParsedMail } from "@civfix/shared/interfaces" import type { MailRepository } from "./mail-repository.drizzle.js" +import { BOUNCE_DISCOVERY_PENDING_META_KEY } from "./mail-repository.js" import { JURISDICTION_DISCOVERY_JOB, type JurisdictionDiscoveryJob, @@ -174,33 +175,47 @@ export async function handleBounce( ) { return } - const thread = await mailRepo - .findThreadByOutboundMessageIds([bounce.originalMessageId]) - .catch(() => null) + const thread = await mailRepo.findThreadByOutboundMessageIds([bounce.originalMessageId]) if (thread === null) return const sql = container.getDb().sql - const ownsRecipient = await threadSentTo(sql, thread.id, bounce.failedRecipient).catch( - () => false, - ) + const failedRecipient = bounce.failedRecipient + const ownsRecipient = await threadSentTo(sql, thread.id, failedRecipient) if (!ownsRecipient) return - await mailRepo - .recordEvent({ + // The 'bounced' event is the completion marker: a sweep re-drive after a partial failure repeats only + // idempotent steps, and a duplicate delivery of a finished DSN does not overwrite a thread status an + // operator has changed since. It is also the only bounce signal a legacy contact_emails address has, + // so it is written before discovery is enqueued (a job that ran first would still see the address as + // usable and skip). Until the enqueue lands it carries a pending flag, so a re-drive still enqueues. + const marker = { + threadId: thread.id, + failedRecipient, + originalMessageId: bounce.originalMessageId, + } + const state = await mailRepo.bounceEventState(marker) + if (state === "complete") return + + const geoid = thread.jurisdictionGeoid ?? (await geoidForContact(sql, failedRecipient)) + if (state === "none") { + await mailRepo.setThreadStatus(thread.id, "bounced") + if (geoid !== null) await markBouncedContact(sql, failedRecipient, geoid) + await mailRepo.recordEvent({ threadId: thread.id, type: "bounced", - meta: { failedRecipient: bounce.failedRecipient }, + meta: { + failedRecipient, + originalMessageId: bounce.originalMessageId, + ...(geoid !== null ? { [BOUNCE_DISCOVERY_PENDING_META_KEY]: true } : {}), + }, }) - .catch(() => {}) - await mailRepo.setThreadStatus(thread.id, "bounced").catch(() => {}) - - const geoid = thread.jurisdictionGeoid ?? (await geoidForContact(sql, bounce.failedRecipient)) - if (geoid === null) return - await markBouncedContact(sql, bounce.failedRecipient, geoid).catch(() => {}) - const data: JurisdictionDiscoveryJob = { geoid } - await container.jobs - .enqueue(JURISDICTION_DISCOVERY_JOB, data, { singletonKey: geoid }) - .catch(() => {}) + if (geoid === null) return + } + if (geoid !== null) { + const data: JurisdictionDiscoveryJob = { geoid } + await container.jobs.enqueue(JURISDICTION_DISCOVERY_JOB, data, { singletonKey: geoid }) + } + await mailRepo.markBounceDiscoveryEnqueued(marker) } export async function threadSentTo(sql: Sql, threadId: string, email: string): Promise { diff --git a/services/api/src/services/admin/inbound-html-sanitizer.ts b/services/api/src/services/admin/inbound-html-sanitizer.ts index 06894f74..aba61d55 100644 --- a/services/api/src/services/admin/inbound-html-sanitizer.ts +++ b/services/api/src/services/admin/inbound-html-sanitizer.ts @@ -283,8 +283,9 @@ function sanitizeAttributes(tag: string, raw: string): string { } if (!allowed.has(name) || seen.has(name)) continue seen.add(name) - const value = decodeEntities(rawValue) - if (name === "href" && !SAFE_URL_RE.test(value.trim())) continue + const decoded = decodeEntities(rawValue) + const value = name === "href" ? normalizeUrlAttr(decoded) : decoded + if (name === "href" && !SAFE_URL_RE.test(value)) continue out.push(`${name}="${escapeAttr(value)}"`) } return out.length > 0 ? ` ${out.join(" ")}` : "" @@ -296,18 +297,47 @@ function skipSpace(raw: string, from: number): number { return i } +// Browsers remove tab/newline anywhere in a URL and trim C0 controls and spaces at either end. The scheme +// is judged on exactly that cleaned value, which is also the href emitted, so the browser reads the same +// scheme the check saw: "java\tscript:" is refused, and "h ttp:x" (a relative link to a browser) is too. +const URL_TAB_NEWLINE_RE = /[\t\n\r]/g +// A trailing run can only start its match at the run's first character; without the lookbehind every +// character of an interior run rescans the rest of it, which is quadratic on a crafted href. +// eslint-disable-next-line no-control-regex +const URL_EDGE_NOISE_RE = /^[\u0000-\u0020]+|(? = { + amp: "&", + quot: '"', + apos: "'", + lt: "<", + gt: ">", + nbsp: "\u00a0", + tab: "\t", + newline: "\n", +} + +const ENTITY_RE = /&(?:#x([0-9a-f]+);?|#(\d+);?|(amp|quot|apos|lt|gt|nbsp|tab|newline);)/gi + +function normalizeUrlAttr(value: string): string { + return value.replace(URL_TAB_NEWLINE_RE, "").replace(URL_EDGE_NOISE_RE, "") +} + +// One pass, so a decoded "&" can never start a second entity ("&quot;" stays the text """). function decodeEntities(value: string): string { - return ( - value - .replace(/&#x([0-9a-f]+);?/gi, (_m, hex: string) => - safeFromCodePoint(Number.parseInt(hex, 16)), - ) - .replace(/&#(\d+);?/g, (_m, dec: string) => safeFromCodePoint(Number.parseInt(dec, 10))) - .replace(/&(?:tab|newline);/gi, "") - .replace(/&/gi, "&") - // eslint-disable-next-line no-control-regex - .replace(/[\u0000-\u0020]/g, "") - ) + return value + .replace( + ENTITY_RE, + (_m, hex: string | undefined, dec: string | undefined, named: string | undefined) => { + if (hex !== undefined) return safeFromCodePoint(Number.parseInt(hex, 16)) + if (dec !== undefined) return safeFromCodePoint(Number.parseInt(dec, 10)) + return NAMED_ENTITIES[(named ?? "").toLowerCase()] ?? "" + }, + ) + .replace(ATTR_CONTROL_CHARS_RE, "") } function safeFromCodePoint(code: number): string { diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index 860c5f15..a98c4a70 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -53,6 +53,10 @@ export const INBOUND_OBJECT_MAX_BYTES = 30 * 1024 * 1024 export const INBOUND_PARSE_TIMEOUT_MS = 20_000 +// At the 5-minute sweep cadence this parks a DSN after roughly half an hour of failing bookkeeping, long +// enough to ride out a transient fault and short enough that poison DSNs cannot fill the sweep batch. +export const INBOUND_BOUNCE_MAX_ATTEMPTS = 6 + const CONTENT_DIGEST_HEX_CHARS = 32 function contentDigest(bytes: Uint8Array): string { @@ -136,15 +140,22 @@ export async function processInboundObject( if (bounce.isBounce) { const bounceVerdict = readMailAuthVerdict(mail) const result = await routeInbox(storage, inboundRepo, bytes, mail, messageId, bounceVerdict) - if (result.outcome === "inbox") { + if (result.outcome !== "inbox" && result.outcome !== "replay") return result + // A replay re-runs the bookkeeping too: the object is only still pending when an earlier run + // stored the DSN but failed part-way through handleBounce. + try { await handleBounce(container, mailRepo, bounce, { fromAddr: mail.from?.address ?? null, authVerdict: bounceVerdict, - }).catch((err: unknown) => { - logger.warn({ key, err: errorText(err) }, "inbound: bounce bookkeeping failed") + }) + } catch (err) { + return deferOrParkBounce(storage, inboundRepo, logger, key, bytes, result, { + originalMessageId: bounce.originalMessageId, + err: errorText(err), }) } - if (result.outcome === "inbox" || result.outcome === "replay") await storage.delete(key) + await inboundRepo.clearBounceFailures(key) + await storage.delete(key) return result } @@ -190,6 +201,8 @@ async function correlateThread( try { token = inboundMail.extractThreadToken(mail) } catch { + // The token is parsed out of sender-controlled recipient addresses; a malformed one means only + // that this mail carries no usable token, so correlation falls through to References. token = null } @@ -239,6 +252,8 @@ async function routeThreaded( }) } + // A failed re-read only defers the side effects: the sweep re-drives every message whose effects + // were never applied. const message = existing ?? (insert === null ? null : insert.inserted ? insert.message : insert.stored) if (message !== null && message.threadId === thread.id) { @@ -440,6 +455,27 @@ function recordSkipped(oversize: string[], filename: string): void { if (oversize.length < INBOUND_ATTACHMENT_MAX_COUNT) oversize.push(filename) } +async function deferOrParkBounce( + storage: Storage, + inboundRepo: InboundRepository, + logger: InboundLogger, + key: string, + bytes: Uint8Array, + result: ProcessResult, + context: { originalMessageId: string | null; err: string }, +): Promise { + logger.warn({ key, ...context }, "inbound: bounce bookkeeping failed") + const attempts = await inboundRepo.recordBounceFailure(key) + if (attempts < INBOUND_BOUNCE_MAX_ATTEMPTS) return result + logger.error( + { key, attempts, originalMessageId: context.originalMessageId }, + "inbound: bounce bookkeeping kept failing; parked under inbound/failed/", + ) + await moveToFailed(storage, key, bytes) + await inboundRepo.clearBounceFailures(key) + return { outcome: "failed", reason: "bounce-bookkeeping" } +} + async function moveToFailed(storage: Storage, key: string, bytes: Uint8Array): Promise { const failedKey = key.startsWith(INBOUND_PENDING_PREFIX) ? INBOUND_FAILED_PREFIX + key.slice(INBOUND_PENDING_PREFIX.length) diff --git a/services/api/src/services/admin/inbound-repository.drizzle.ts b/services/api/src/services/admin/inbound-repository.drizzle.ts index 244af965..3d8e29fa 100644 --- a/services/api/src/services/admin/inbound-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-repository.drizzle.ts @@ -1,5 +1,11 @@ import type { Sql } from "../../db/client.js" -import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" +import { + clampLimit, + decodeCursor, + keysetInstant, + keysetPredicate, + paginateKeyset, +} from "./pagination.js" import { likeContains } from "./like.js" import { writeAudit } from "./audit.js" import { HTML_PREVIEW_SOURCE_CHARS, PREVIEW_SOURCE_CHARS, toPreview } from "./mail-preview.js" @@ -30,6 +36,9 @@ export interface InboundRepository { list(query: InboxListQuery): Promise get(id: string): Promise setStatus(id: string, status: InboundEmailStatus, actorId: string | null): Promise + /** Counts one more failed bounce-bookkeeping run for a pending object; returns the new total. */ + recordBounceFailure(objectKey: string): Promise + clearBounceFailures(objectKey: string): Promise } export { toPreview } @@ -124,7 +133,7 @@ export function makeDrizzleInboundRepository(sql: Sql): InboundRepository { const anchor = decodeCursor(query.cursor, true) const cursorFilter = anchor !== null - ? sql`AND (received_at, id) < (${anchor.createdAt}, ${anchor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`received_at`, sql`id`, anchor)}` : sql`` const statusFilter = query.status === "unread" @@ -143,11 +152,12 @@ export function makeDrizzleInboundRepository(sql: Sql): InboundRepository { return sql`AND (from_addr ILIKE ${like} ESCAPE '\\' OR subject ILIKE ${like} ESCAPE '\\' OR recipient ILIKE ${like} ESCAPE '\\')` })() : sql`` - const rows = await sql` + const rows = await sql<(InboundListRowSelect & { cursor_at: string })[]>` SELECT id, from_addr, recipient, subject, left(body_text, ${PREVIEW_SOURCE_CHARS}) AS preview_text, left(body_html, ${HTML_PREVIEW_SOURCE_CHARS}) AS preview_html, - has_attachments, status, received_at + has_attachments, status, received_at, + ${keysetInstant(sql, sql`received_at`)} AS cursor_at FROM inbound_emails WHERE true ${statusFilter} @@ -157,13 +167,11 @@ export function makeDrizzleInboundRepository(sql: Sql): InboundRepository { ORDER BY received_at DESC, id DESC LIMIT ${limit + 1} ` - const hasMore = rows.length > limit - const page = hasMore ? rows.slice(0, limit) : rows - const items = page.map(toListItem) - const last = page[page.length - 1] - const nextCursor = - hasMore && last ? encodeCursor({ createdAt: last.received_at, id: last.id }) : null - return { items, nextCursor } + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) + return { items: items.map(toListItem), nextCursor } }, async get(id: string): Promise { @@ -203,5 +211,22 @@ export function makeDrizzleInboundRepository(sql: Sql): InboundRepository { return true }) }, + + async recordBounceFailure(objectKey: string): Promise { + const rows = await sql<{ attempts: number }[]>` + INSERT INTO inbound_bounce_attempts (object_key, attempts, last_attempt_at) + VALUES (${objectKey}, 1, now()) + ON CONFLICT (object_key) DO UPDATE + SET attempts = inbound_bounce_attempts.attempts + 1, last_attempt_at = now() + RETURNING attempts + ` + const attempts = rows[0]?.attempts + if (attempts === undefined) throw new Error("recordBounceFailure: upsert returned no row") + return attempts + }, + + async clearBounceFailures(objectKey: string): Promise { + await sql`DELETE FROM inbound_bounce_attempts WHERE object_key = ${objectKey}` + }, } } diff --git a/services/api/src/services/admin/inbound-repository.memory.ts b/services/api/src/services/admin/inbound-repository.memory.ts index 2fc3509a..ee37432a 100644 --- a/services/api/src/services/admin/inbound-repository.memory.ts +++ b/services/api/src/services/admin/inbound-repository.memory.ts @@ -31,6 +31,7 @@ export interface RecordedInboxAudit { export class InMemoryInboundRepository implements InboundRepository { readonly rows: StoredInbound[] = [] readonly audits: RecordedInboxAudit[] = [] + readonly bounceAttempts = new Map() private tick = 0 private nextDate(base?: Date): Date { @@ -108,6 +109,17 @@ export class InMemoryInboundRepository implements InboundRepository { }) return Promise.resolve(true) } + + recordBounceFailure(objectKey: string): Promise { + const attempts = (this.bounceAttempts.get(objectKey) ?? 0) + 1 + this.bounceAttempts.set(objectKey, attempts) + return Promise.resolve(attempts) + } + + clearBounceFailures(objectKey: string): Promise { + this.bounceAttempts.delete(objectKey) + return Promise.resolve() + } } function toListItem(r: StoredInbound): InboundEmailListItemDTO { diff --git a/services/api/src/services/admin/inbound-thread-correlation.ts b/services/api/src/services/admin/inbound-thread-correlation.ts index b6d32007..f6db60ee 100644 --- a/services/api/src/services/admin/inbound-thread-correlation.ts +++ b/services/api/src/services/admin/inbound-thread-correlation.ts @@ -40,6 +40,8 @@ export interface InboundEffectDeps { export const EFFECTS_LEASE_MS = 10 * 60 * 1000 +const EVENT_REPLY_FALLBACK_NOTE = "Jurisdiction replied" + export const EFFECTS_STAGE_TIMELINE = 1 export const EFFECTS_STAGE_CHAT = 2 export const EFFECTS_STAGE_NOTIFIED = 3 @@ -343,12 +345,11 @@ export async function onEventReply( if (stage < EFFECTS_STAGE_TIMELINE) { const cleanupRepo = injectedCleanupRepo ?? makeDrizzleCleanupRepository(container.getDb().sql) - const fullBody = (message.body ?? "").trim() - const preview = replyPreview(fullBody) - const note = preview.length > 0 ? `Jurisdiction replied — ${preview}` : "Jurisdiction replied" await cleanupRepo.appendCleanupTimeline(cleanupId, { kind: "city_reply", - note: fullBody.length > 0 ? fullBody : note, + note: + cityReplyChatBody(message.body, container.env.MAIL_REPLY_DOMAIN) ?? + EVENT_REPLY_FALLBACK_NOTE, actorId: null, }) await mailRepo.setMessageEffectsStage(message.id, EFFECTS_STAGE_TIMELINE) @@ -356,11 +357,6 @@ export async function onEventReply( await mailRepo.setThreadStatus(thread.id, "replied") } -export function replyPreview(body: string): string { - const collapsed = body.replace(/\s+/g, " ").trim() - return collapsed.length > 140 ? `${collapsed.slice(0, 140)}…` : collapsed -} - const DERIVED_ID_BODY_PREFIX_CHARS = 4096 export function resolveMessageId(mail: ParsedMail): string { diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts index 497022a3..fdc0c3c3 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts @@ -1,7 +1,12 @@ import type { Sql } from "../../db/client.js" import { decodeOffsetCursor, encodeOffsetCursor, clampLimit } from "./pagination.js" import { writeAudit } from "./audit.js" -import { upsertJurisdictionContacts } from "./discovery-repository.drizzle.js" +import { + invalidateDirectoryFacetCache, + readDirectoryFacetCache, + upsertJurisdictionContacts, + writeDirectoryFacetCache, +} from "./discovery-repository.drizzle.js" import { buildUnmappedRecord, shouldIncludeUnmapped } from "./jurisdiction-directory-projection.js" import { ADMIN_CATEGORIES, @@ -24,8 +29,6 @@ import { AppError } from "@civfix/shared" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" import { ilikeAnyOf } from "./sql-fragments.js" -const DIRECTORY_FACET_TTL_MS = 30_000 - const PG_UNIQUE_VIOLATION = "23505" const JURISDICTION_HANDLE_CONSTRAINT = "jurisdictions_handle_lower_key" @@ -35,30 +38,6 @@ function isJurisdictionHandleConflict(err: unknown): boolean { return e.code === PG_UNIQUE_VIOLATION && e.constraint_name === JURISDICTION_HANDLE_CONSTRAINT } -interface DirectoryFacetAggregate { - total: number - facets: { routed: number; unrouted: number } -} - -let defaultFacetCache: { at: number; value: DirectoryFacetAggregate } | null = null - -function readDefaultFacetCache(): DirectoryFacetAggregate | null { - if (defaultFacetCache === null) return null - if (Date.now() - defaultFacetCache.at > DIRECTORY_FACET_TTL_MS) { - defaultFacetCache = null - return null - } - return defaultFacetCache.value -} - -function writeDefaultFacetCache(value: DirectoryFacetAggregate): void { - defaultFacetCache = { at: Date.now(), value } -} - -function invalidateDefaultFacetCache(): void { - defaultFacetCache = null -} - interface DirectoryRow extends CategoryCountRow { geoid: string name: string @@ -188,7 +167,7 @@ export function makeDrizzleJurisdictionContactsRepository( return { taskResolved } }) - invalidateDefaultFacetCache() + invalidateDirectoryFacetCache() return { taskResolved: committed.taskResolved } }, @@ -277,7 +256,7 @@ export function makeDrizzleJurisdictionContactsRepository( }) return true }) - invalidateDefaultFacetCache() + invalidateDirectoryFacetCache() return result }, @@ -430,7 +409,7 @@ export function makeDrizzleJurisdictionContactsRepository( let facets: { routed: number; unrouted: number } | null = null if (offset === 0) { const isDefaultView = args.q === null && args.layer === null - const cached = args.filter === "all" && isDefaultView ? readDefaultFacetCache() : null + const cached = args.filter === "all" && isDefaultView ? readDirectoryFacetCache() : null if (cached !== null) { total = cached.total facets = cached.facets @@ -447,7 +426,7 @@ export function makeDrizzleJurisdictionContactsRepository( total = Number(rows[0]?.filtered_total ?? "0") facets = { routed: Number(a?.routed ?? "0"), unrouted: Number(a?.unrouted ?? "0") } if (args.filter === "all" && isDefaultView) { - writeDefaultFacetCache({ total: Number(a?.total ?? "0"), facets }) + writeDirectoryFacetCache({ total: Number(a?.total ?? "0"), facets }) } } } diff --git a/services/api/src/services/admin/like.ts b/services/api/src/services/admin/like.ts index 52498403..6da497e9 100644 --- a/services/api/src/services/admin/like.ts +++ b/services/api/src/services/admin/like.ts @@ -21,3 +21,8 @@ export function escapeLike(term: string): string { export function likeContains(term: string): string { return "%" + escapeLike(term) + "%" } + +/** Build a literal "starts with" pattern: %. Use with `ESCAPE '\\'` in the query. */ +export function likePrefix(term: string): string { + return escapeLike(term) + "%" +} diff --git a/services/api/src/services/admin/mail-format.ts b/services/api/src/services/admin/mail-format.ts index 7f889483..bb851f31 100644 --- a/services/api/src/services/admin/mail-format.ts +++ b/services/api/src/services/admin/mail-format.ts @@ -96,8 +96,17 @@ function mapLinkFor(lat: number, lng: number): string { return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=18/${lat}/${lng}` } +// The API container runs in UTC, which turns a US evening report into the next day. No jurisdiction +// carries a time zone yet, so the packet uses the platform default the certificates and events use. +const SUBMITTED_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "America/Los_Angeles", + year: "numeric", + month: "long", + day: "numeric", +}) + function formatSubmittedDate(d: Date): string { - return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) + return SUBMITTED_DATE_FORMAT.format(d) } function buildTemplateValues( diff --git a/services/api/src/services/admin/mail-preview.ts b/services/api/src/services/admin/mail-preview.ts index 3e3c1f0c..7199b3de 100644 --- a/services/api/src/services/admin/mail-preview.ts +++ b/services/api/src/services/admin/mail-preview.ts @@ -108,17 +108,48 @@ export function htmlToText(html: string): string { .trim() } +const TEXT_NAMED_ENTITIES: ReadonlyMap = new Map( + Object.entries({ + nbsp: " ", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + amp: "&", + lsquo: "\u2018", + rsquo: "\u2019", + sbquo: "\u201a", + ldquo: "\u201c", + rdquo: "\u201d", + bdquo: "\u201e", + ndash: "\u2013", + mdash: "\u2014", + hellip: "\u2026", + bull: "\u2022", + middot: "\u00b7", + laquo: "\u00ab", + raquo: "\u00bb", + copy: "\u00a9", + reg: "\u00ae", + trade: "\u2122", + deg: "\u00b0", + euro: "\u20ac", + }), +) + +const TEXT_ENTITY_RE = /&(?:#x([0-9a-f]{1,6})|#([0-9]{1,7})|([a-z]{2,8}));/gi + +// One pass, so "&lt;" decodes to the text "<" and never to "<". function decodeTextEntities(text: string): string { - return text - .replace(/ /gi, " ") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/"/gi, '"') - .replace(/&#(?:x([0-9a-f]{1,6})|([0-9]{1,7}));/gi, (_m, hex?: string, dec?: string) => - codePointText(hex !== undefined ? Number.parseInt(hex, 16) : Number(dec)), - ) - .replace(/'/gi, "'") - .replace(/&/gi, "&") + return text.replace( + TEXT_ENTITY_RE, + (whole, hex: string | undefined, dec: string | undefined, named: string | undefined) => { + if (hex !== undefined) return codePointText(Number.parseInt(hex, 16)) + if (dec !== undefined) return codePointText(Number(dec)) + const key = named ?? "" + return TEXT_NAMED_ENTITIES.get(key) ?? TEXT_NAMED_ENTITIES.get(key.toLowerCase()) ?? whole + }, + ) } function codePointText(code: number): string { diff --git a/services/api/src/services/admin/mail-repository.drizzle.ts b/services/api/src/services/admin/mail-repository.drizzle.ts index bf58947a..0f706c44 100644 --- a/services/api/src/services/admin/mail-repository.drizzle.ts +++ b/services/api/src/services/admin/mail-repository.drizzle.ts @@ -1,10 +1,15 @@ import type { Queryable, Sql } from "../../db/client.js" -import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" +import { + clampLimit, + decodeCursor, + keysetInstant, + keysetPredicate, + paginateKeyset, +} from "./pagination.js" import { PREVIEW_SOURCE_CHARS } from "./mail-preview.js" import { writeAudit } from "./audit.js" import { ilikeAnyOf, type SqlFragment } from "./sql-fragments.js" import { - anchorOf, mintThreadToken, toMessageRecord, toOutreachRecord, @@ -31,6 +36,9 @@ import { type OutboundMessageSnapshot, type OutreachStatePatch, type OutreachStateRecord, + BOUNCE_DISCOVERY_PENDING_META_KEY, + type BounceEventKey, + type BounceEventState, type ClaimEffectsInput, type PendingEffects, type PendingEffectsQuery, @@ -146,6 +154,15 @@ async function insertOrSelectThread( return toThreadRecord(row) } +function bounceEventMatch(sql: Queryable, input: BounceEventKey): SqlFragment { + return sql` + thread_id = ${input.threadId}::uuid + AND type = 'bounced' + AND meta->>'originalMessageId' = ${input.originalMessageId} + AND lower(meta->>'failedRecipient') = lower(${input.failedRecipient}) + ` +} + export function makeDrizzleMailRepository(sql: Sql): MailRepository { return { async upsertThreadByToken(token: string, init: ThreadInit = {}): Promise { @@ -334,10 +351,9 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { async listThreads(input: ListThreadsInput): Promise { const limit = clampLimit(input.limit) const anchor = decodeCursor(input.cursor, true) + const activityAt = sql`COALESCE(t.last_message_at, t.created_at)` const cursorFilter = - anchor !== null - ? sql`AND (COALESCE(t.last_message_at, t.created_at), t.id) < (${anchor.createdAt}, ${anchor.id}::uuid)` - : sql`` + anchor !== null ? sql`AND ${keysetPredicate(sql, activityAt, sql`t.id`, anchor)}` : sql`` const geoidFilter = input.jurisdictionGeoid !== undefined ? sql`AND t.jurisdiction_geoid = ${input.jurisdictionGeoid}` @@ -357,11 +373,12 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { lm_from_addr: string | null lm_to_addr: string | null lm_body: string | null + cursor_at: string })[] >` SELECT ${threadColumns(sql, "t")}, lm.direction AS lm_direction, lm.from_addr AS lm_from_addr, lm.to_addr AS lm_to_addr, - lm.body AS lm_body + lm.body AS lm_body, ${keysetInstant(sql, activityAt)} AS cursor_at FROM mail_threads t LEFT JOIN LATERAL ( -- Only a preview's worth of the latest body: a municipal reply can be tens of KB and this is a @@ -381,8 +398,10 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { ORDER BY COALESCE(t.last_message_at, t.created_at) DESC, t.id DESC LIMIT ${limit + 1} ` - const hasMore = rows.length > limit - const page = hasMore ? rows.slice(0, limit) : rows + const { items: page, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) const items = page.map((r) => { const thread = toThreadRecord(r) const latest: MailMessageRecord | null = @@ -409,8 +428,6 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { : null return toThreadListItem(thread, latest) }) - const last = page[page.length - 1] - const nextCursor = hasMore && last ? encodeCursor(anchorOf(toThreadRecord(last))) : null return { items, nextCursor } }, @@ -506,6 +523,27 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { return rows[0]?.ok ?? false }, + async bounceEventState(input: BounceEventKey): Promise { + const rows = await sql<{ recorded: boolean; complete: boolean }[]>` + SELECT + COUNT(*) > 0 AS recorded, + COALESCE(bool_or((meta->>${BOUNCE_DISCOVERY_PENDING_META_KEY}::text) IS NULL), false) AS complete + FROM mail_events + WHERE ${bounceEventMatch(sql, input)} + ` + const row = rows[0] + if (row?.recorded !== true) return "none" + return row.complete ? "complete" : "discovery_pending" + }, + + async markBounceDiscoveryEnqueued(input: BounceEventKey): Promise { + await sql` + UPDATE mail_events SET meta = meta - ${BOUNCE_DISCOVERY_PENDING_META_KEY}::text + WHERE ${bounceEventMatch(sql, input)} + AND (meta->>${BOUNCE_DISCOVERY_PENDING_META_KEY}::text) IS NOT NULL + ` + }, + async claimMessageEffects(id: string, input: ClaimEffectsInput): Promise { const rows = await sql<{ effects_stage: number }[]>` UPDATE mail_messages diff --git a/services/api/src/services/admin/mail-repository.memory.ts b/services/api/src/services/admin/mail-repository.memory.ts index c7d68701..ef6e23d1 100644 --- a/services/api/src/services/admin/mail-repository.memory.ts +++ b/services/api/src/services/admin/mail-repository.memory.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto" import { MAIL_STATS_WINDOW_DAYS, + BOUNCE_DISCOVERY_PENDING_META_KEY, + type BounceEventKey, + type BounceEventState, type CreateThreadInput, type InsertMessageInput, type ListThreadsInput, @@ -22,7 +25,10 @@ import { } from "./mail-repository.js" import { deriveWho, mintThreadToken, toMessageDTO, toThreadListItem } from "./mail-mappers.js" import { buildMailStats } from "./mail-stats.js" -import { ROUTE_DEADLINE_INFLIGHT_SECONDS } from "./outbound-send-policy.js" +import { + ROUTE_CLAIM_STALE_SECONDS, + ROUTE_DEADLINE_INFLIGHT_SECONDS, +} from "./outbound-send-policy.js" import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" import type { MailDelivery, MailStatsResponse, MailStatus, MailThreadDTO } from "@civfix/shared" @@ -392,7 +398,8 @@ export class InMemoryMailRepository implements MailRepository { } hasSendInFlight(threadId: string): Promise { - const inflightBefore = new Date(Date.now() - ROUTE_DEADLINE_INFLIGHT_SECONDS * 1000) + const inflightBefore = this.now.getTime() - ROUTE_DEADLINE_INFLIGHT_SECONDS * 1000 + const staleBefore = this.now.getTime() - ROUTE_CLAIM_STALE_SECONDS * 1000 let latest: MailMessageRecord | null = null for (const m of this.messages) { if (m.threadId !== threadId || m.direction !== "out") continue @@ -401,16 +408,46 @@ export class InMemoryMailRepository implements MailRepository { if (latest === null) return Promise.resolve(false) const own = this.events.filter((e) => e.messageId === latest.id) if (own.some((e) => e.type === "sent")) return Promise.resolve(false) + const failed = own.filter((e) => e.type === "failed") + if (failed.length === 0) return Promise.resolve(latest.createdAt.getTime() > staleBefore) return Promise.resolve( - own.some( + failed.some( (e) => - e.type === "failed" && (e.meta as { reason?: unknown } | null)?.reason === "deadline" && - e.createdAt.getTime() > inflightBefore.getTime(), + e.createdAt.getTime() > inflightBefore, ), ) } + private bounceEvents(input: BounceEventKey): StoredMailEvent[] { + const recipient = input.failedRecipient.toLowerCase() + return this.events.filter((e) => { + if (e.threadId !== input.threadId || e.type !== "bounced") return false + const meta = e.meta as { originalMessageId?: unknown; failedRecipient?: unknown } | null + return ( + meta?.originalMessageId === input.originalMessageId && + typeof meta.failedRecipient === "string" && + meta.failedRecipient.toLowerCase() === recipient + ) + }) + } + + bounceEventState(input: BounceEventKey): Promise { + const events = this.bounceEvents(input) + if (events.length === 0) return Promise.resolve("none") + const complete = events.some((e) => e.meta?.[BOUNCE_DISCOVERY_PENDING_META_KEY] === undefined) + return Promise.resolve(complete ? "complete" : "discovery_pending") + } + + markBounceDiscoveryEnqueued(input: BounceEventKey): Promise { + for (const event of this.bounceEvents(input)) { + if (event.meta === null) continue + const { [BOUNCE_DISCOVERY_PENDING_META_KEY]: _pending, ...rest } = event.meta + event.meta = rest + } + return Promise.resolve() + } + claimMessageEffects(id: string, input: ClaimEffectsInput): Promise { const message = this.messages.find((m) => m.id === id) if (!message || message.effectsAppliedAt !== null) return Promise.resolve(null) diff --git a/services/api/src/services/admin/mail-repository.ts b/services/api/src/services/admin/mail-repository.ts index 39a2265f..fc29ac97 100644 --- a/services/api/src/services/admin/mail-repository.ts +++ b/services/api/src/services/admin/mail-repository.ts @@ -181,6 +181,8 @@ export interface MailRepository { outboundRecipients(threadId: string): Promise findMessageByMessageId(messageId: string): Promise hasSendInFlight(threadId: string): Promise + bounceEventState(input: BounceEventKey): Promise + markBounceDiscoveryEnqueued(input: BounceEventKey): Promise claimMessageEffects(id: string, input: ClaimEffectsInput): Promise setMessageEffectsStage(id: string, stage: number): Promise markMessageEffectsApplied(id: string): Promise @@ -188,6 +190,21 @@ export interface MailRepository { findMessagesPendingEffects(input: PendingEffectsQuery): Promise } +export interface BounceEventKey { + threadId: string + failedRecipient: string + originalMessageId: string +} + +/** + * "discovery_pending": the 'bounced' event is recorded but the discovery job it calls for may not be + * enqueued yet, so a re-drive must still enqueue it. Events written before the flag existed read as + * "complete", which is what they were. + */ +export type BounceEventState = "none" | "discovery_pending" | "complete" + +export const BOUNCE_DISCOVERY_PENDING_META_KEY = "discoveryPending" + export interface ClaimEffectsInput { leaseBefore: Date } diff --git a/services/api/src/services/admin/moderation-repository.drizzle.ts b/services/api/src/services/admin/moderation-repository.drizzle.ts index d4f483a6..a36c20a9 100644 --- a/services/api/src/services/admin/moderation-repository.drizzle.ts +++ b/services/api/src/services/admin/moderation-repository.drizzle.ts @@ -1,7 +1,13 @@ import type { ReportCategory } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" -import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" +import { + clampLimit, + decodeCursor, + encodeKeysetCursor, + keysetInstant, + keysetPredicate, +} from "./pagination.js" import { ADMIN_CATEGORIES } from "./category-counts.js" import { ilikeAnyOf, type SqlFragment } from "./sql-fragments.js" import { tombstonePostInTx } from "../post-repository.drizzle.js" @@ -295,11 +301,11 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository )}` : sql`` const keyset = anchor - ? sql`AND (created_at, id) < (${anchor.createdAt}, ${anchor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, anchor)}` : sql`` const rows = (await sql` - SELECT ${itemColumns(sql)} + SELECT ${itemColumns(sql)}, ${keysetInstant(sql, sql`created_at`)} AS cursor_at FROM moderation_items WHERE status = 'open' ${facet} @@ -307,15 +313,14 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository ${keyset} ORDER BY created_at DESC, id DESC LIMIT ${limit + 1} - `) as unknown as ModerationItemRow[] + `) as unknown as (ModerationItemRow & { cursor_at: string })[] const hasMore = rows.length > limit const page = hasMore ? rows.slice(0, limit) : rows await attachDestinationRefs(sql, page) const records = page.map((r) => toRecord(r, [])) const last = page[page.length - 1] - const nextCursor = - hasMore && last ? encodeCursor({ createdAt: last.created_at, id: last.id }) : null + const nextCursor = hasMore && last ? encodeKeysetCursor(last.cursor_at, last.id) : null return { records, nextCursor } }, @@ -379,7 +384,12 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository return sql.begin(async (tx) => { const resolved = await resolveItem(tx, id, "removed", input.actorId) if (!resolved) return null - const removed = await tombstoneSubject(tx, resolved.subject_type, resolved.subject_id) + const userRemoval = isUserSubject(resolved.subject_type) + ? await removeUserSubject(tx, resolved.subject_id) + : null + const removed = + userRemoval?.removed ?? + (await tombstoneSubject(tx, resolved.subject_type, resolved.subject_id)) const removedReport = removed && resolved.subject_type === "report" if (removedReport) { await tx` @@ -387,7 +397,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository VALUES (${resolved.subject_id}, 'rejected', ${input.reason ?? "Removed in moderation"}, ${input.actorId}) ` } - if (removed) { + if (removed && !isOwnerTakedown(resolved.meta)) { const authorId = await resolveSubjectAuthor( tx, resolved.subject_type, @@ -408,9 +418,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository const media = await loadMedia(tx, resolved.subject_type, resolved.subject_id) const record = toRecord(resolved, media) if (removedReport) record.reportTimelineStatus = "rejected" - if (removed && isUserSubject(resolved.subject_type)) { - record.suspendedUserId = resolved.subject_id - } + if (userRemoval?.suspended === true) record.suspendedUserId = resolved.subject_id return record }) }, @@ -493,7 +501,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository LIMIT 1 ` if (existing[0]) { - await escalateOpenItem(tx, existing[0].id, input) + await escalateOpenItem(tx, existing[0].id, input, await isOwnerRequest(tx, input)) return null } return insertModerationItem(tx, input, { dedupeOpen: true }) @@ -731,14 +739,14 @@ async function restoreSubject( return restoreMessage(tx, subjectId) case "profile": case "user": { + // Only the 'suspended' a moderation removal imposes is lifted; an operator's separate ban survives. const rows = await tx<{ user_id: string }[]>` - INSERT INTO user_moderation (user_id, account_status, flagged, updated_at) - SELECT u.id, 'active', false, now() + UPDATE user_moderation um + SET account_status = 'active', flagged = false, updated_at = now() FROM users u - WHERE u.id = ${subjectId} AND u.deleted_at IS NULL - ON CONFLICT (user_id) - DO UPDATE SET account_status = 'active', flagged = false, updated_at = now() - RETURNING user_id` + WHERE um.user_id = ${subjectId} AND u.id = um.user_id AND u.deleted_at IS NULL + AND um.account_status = 'suspended' + RETURNING um.user_id` return rows.length > 0 } default: @@ -812,33 +820,65 @@ async function tombstoneSubject( case "post": { return tombstonePostInTx(tx, subjectId) } - case "profile": - case "user": { - assertTargetIsNotOfficialAccount(subjectId, "remove") - const target = await tx<{ role: string }[]>` - SELECT role FROM users WHERE id = ${subjectId}` - assertTargetIsNotOperatorRole(target[0]?.role, "remove") - const rows = await tx<{ user_id: string }[]>` - INSERT INTO user_moderation (user_id, account_status, flagged, updated_at) - VALUES (${subjectId}, 'suspended', true, now()) - ON CONFLICT (user_id) DO UPDATE SET account_status = 'suspended', flagged = true, updated_at = now() - RETURNING user_id` - return rows.length > 0 - } default: return false } } +async function removeUserSubject( + tx: Queryable, + subjectId: string, +): Promise<{ removed: boolean; suspended: boolean }> { + assertTargetIsNotOfficialAccount(subjectId, "remove") + const target = await tx<{ role: string }[]>` + SELECT role FROM users WHERE id = ${subjectId} AND deleted_at IS NULL FOR NO KEY UPDATE` + const row = target[0] + if (row === undefined) return { removed: false, suspended: false } + assertTargetIsNotOperatorRole(row.role, "remove") + // A ban outranks the suspension a removal imposes. Overwriting it would also let an appeal overturn, + // which lifts only suspensions, reactivate a banned account. + const rows = await tx<{ user_id: string }[]>` + INSERT INTO user_moderation (user_id, account_status, flagged, updated_at) + VALUES (${subjectId}, 'suspended', true, now()) + ON CONFLICT (user_id) DO UPDATE SET account_status = 'suspended', flagged = true, updated_at = now() + WHERE user_moderation.account_status <> 'banned' + RETURNING user_id` + return { removed: true, suspended: rows.length > 0 } +} + +/** + * Marks an item that exists only because the report's author asked to take it down. Removing it honors + * that request, so it must not count as a strike. The marker is set only when the owner's request opens + * the item and is cleared as soon as any other origin folds in, or an author could pre-file a takedown + * on every post and wipe the strike an operator would record for a third party's or the pipeline's flag. + */ +const OWNER_TAKEDOWN_META = { ownerTakedown: true } as const + +function isOwnerTakedown(meta: unknown): boolean { + return ( + typeof meta === "object" && + meta !== null && + (meta as Record).ownerTakedown === true + ) +} + +async function isOwnerRequest(tx: Queryable, input: CreateModerationItemInput): Promise { + if (input.subjectType !== "report" || input.reporterUserId == null) return false + const authorId = await resolveSubjectAuthor(tx, "report", input.subjectId) + return authorId !== null && authorId === input.reporterUserId +} + async function escalateOpenItem( tx: Queryable, itemId: string, input: CreateModerationItemInput, + ownerRequest: boolean, ): Promise { + const otherOrigin: SqlFragment = ownerRequest ? tx`` : tx`- 'ownerTakedown'` await tx` UPDATE moderation_items SET priority = 'high', - meta = CASE + meta = (CASE WHEN ${input.reporterUserId ?? null}::text IS NULL THEN meta WHEN meta->'reporters' @> to_jsonb(ARRAY[${input.reporterUserId ?? ""}::text]) THEN meta ELSE jsonb_set( @@ -846,7 +886,7 @@ async function escalateOpenItem( '{reporters}', COALESCE(meta->'reporters', '[]'::jsonb) || to_jsonb(${input.reporterUserId ?? ""}::text) ) - END + END) ${otherOrigin} WHERE id = ${itemId} AND status = 'open' ` } @@ -885,6 +925,7 @@ export async function insertModerationItem( if (authorId != null) userSnapshot = await buildUserSnapshot(tx, authorId) } if (userSnapshot != null) meta.user = userSnapshot + if (await isOwnerRequest(tx, input)) Object.assign(meta, OWNER_TAKEDOWN_META) const onConflict: SqlFragment = opts.dedupeOpen ? tx`ON CONFLICT DO NOTHING` : tx`` const rows = await tx<{ id: string }[]>` diff --git a/services/api/src/services/admin/moderation-repository.memory.ts b/services/api/src/services/admin/moderation-repository.memory.ts index 59b178fb..bff01c79 100644 --- a/services/api/src/services/admin/moderation-repository.memory.ts +++ b/services/api/src/services/admin/moderation-repository.memory.ts @@ -30,7 +30,7 @@ export class InMemoryModerationRepository implements ModerationRepository { readonly reportStatus = new Map() readonly suspensions = new Map() readonly tombstoned = new Set() - readonly accountStatus = new Map() + readonly accountStatus = new Map() readonly userRoles = new Map() readonly deletedUserIds = new Set() @@ -194,7 +194,10 @@ export class InMemoryModerationRepository implements ModerationRepository { if (item.subjectType === "chat" || item.subjectType === "message") { this.tombstoned.add(item.subjectId) } - if (isUserSubjectType(item.subjectType)) { + if ( + isUserSubjectType(item.subjectType) && + this.accountStatus.get(item.subjectId) !== "banned" + ) { this.accountStatus.set(item.subjectId, "suspended") item.suspendedUserId = item.subjectId } @@ -234,6 +237,7 @@ export class InMemoryModerationRepository implements ModerationRepository { } if (subjectType === "user" || subjectType === "profile") { if (this.deletedUserIds.has(subjectId)) return false + if (this.accountStatus.get(subjectId) !== "suspended") return false this.accountStatus.set(subjectId, "active") return true } diff --git a/services/api/src/services/admin/moderation-service.ts b/services/api/src/services/admin/moderation-service.ts index a0bdf58b..59cfb53a 100644 --- a/services/api/src/services/admin/moderation-service.ts +++ b/services/api/src/services/admin/moderation-service.ts @@ -18,6 +18,7 @@ import { clampLimit } from "./pagination.js" import { timelineKindForStatus } from "./admin-report-status.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "../media-presign.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" +import type { MessageUpdateAnnouncer } from "./admin-report-chat-service.js" export type ModerationFilter = "all" | ModerationKind | "high" @@ -152,6 +153,11 @@ export interface ModerationServiceDeps { now?: () => Date reportChatEmitter?: ReportChatSystemEmitter sessions?: ModerationSessionControl + announceMessageUpdate?: MessageUpdateAnnouncer +} + +function isMessageSubject(subjectType: ModerationItemRecord["subjectType"]): boolean { + return subjectType === "chat" || subjectType === "message" } export interface ModerationService { @@ -266,6 +272,9 @@ export function makeModerationService(deps: ModerationServiceDeps): ModerationSe ): Promise { const result = await deps.repo.remove(id, input) if (!result) throw AppError.notFound("Moderation item not found") + if (isMessageSubject(result.subjectType)) { + await deps.announceMessageUpdate?.(result.subjectId) + } if (result.suspendedUserId && deps.sessions) { await deps.sessions.applyStatus(result.suspendedUserId, "suspended") } @@ -290,6 +299,9 @@ export function makeModerationService(deps: ModerationServiceDeps): ModerationSe ): Promise { const result = await deps.repo.decideAppeal(id, input) if (!result) throw AppError.notFound("Moderation item not found") + if (input.decision === "overturn" && isMessageSubject(result.subjectType)) { + await deps.announceMessageUpdate?.(result.subjectId) + } if (result.restoredUserId && deps.sessions) { await deps.sessions.applyStatus(result.restoredUserId, "active") } diff --git a/services/api/src/services/admin/outbound-mail-service.ts b/services/api/src/services/admin/outbound-mail-service.ts index db8be33d..204814c5 100644 --- a/services/api/src/services/admin/outbound-mail-service.ts +++ b/services/api/src/services/admin/outbound-mail-service.ts @@ -207,7 +207,16 @@ export function makeOutboundMailService(deps: OutboundMailServiceDeps): Outbound onLateSuccess?: (() => Promise) | undefined }): Promise { const rfcMessageId = `` - const priorIds = await repo.priorOutboundMessageIds(args.threadId).catch(() => [] as string[]) + // Threading headers are a courtesy to the city's mail client; losing them must not block the send. + const priorIds = await repo + .priorOutboundMessageIds(args.threadId) + .catch((err: unknown): string[] => { + logger.warn( + { err, threadId: args.threadId }, + "outbound mail: prior Message-IDs unreadable; sending without In-Reply-To/References", + ) + return [] + }) const inReplyTo = priorIds.length > 0 ? priorIds[priorIds.length - 1] : undefined const references = priorIds.length > 10 ? [priorIds[0] as string, ...priorIds.slice(-9)] : priorIds @@ -320,6 +329,8 @@ export function makeOutboundMailService(deps: OutboundMailServiceDeps): Outbound void send.then( async (late: SentMail) => { try { + // The caller already awaits failureWrite and sees its error; here it only orders the + // late 'sent' after the 'failed' row. await failureWrite.catch(() => {}) await recordSent(late, { late: true }) if (args.onLateSuccess !== undefined) await args.onLateSuccess() diff --git a/services/api/src/services/admin/outbound-send-sql.ts b/services/api/src/services/admin/outbound-send-sql.ts index 5479368b..5fcfe967 100644 --- a/services/api/src/services/admin/outbound-send-sql.ts +++ b/services/api/src/services/admin/outbound-send-sql.ts @@ -32,19 +32,28 @@ export function attemptEventExists( ` } +// The outbound row is inserted before transmission starts, so a young attempt with no outcome yet is +// still on the wire; it is in flight until the same stale window after which sendFailedExpr calls it +// crashed. export function sendInFlightExpr(sql: Queryable, threadRef: SqlFragment): SqlFragment { return sql` COALESCE( ( SELECT - ${attemptEventExists( - sql, - threadRef, - "failed", - sql`AND e.meta->>'reason' = 'deadline' - AND e.created_at > now() - make_interval(secs => ${ROUTE_DEADLINE_INFLIGHT_SECONDS})`, - )} - AND NOT ${attemptEventExists(sql, threadRef, "sent", sql``)} + NOT ${attemptEventExists(sql, threadRef, "sent", sql``)} + AND ( + ${attemptEventExists( + sql, + threadRef, + "failed", + sql`AND e.meta->>'reason' = 'deadline' + AND e.created_at > now() - make_interval(secs => ${ROUTE_DEADLINE_INFLIGHT_SECONDS})`, + )} + OR ( + NOT ${attemptEventExists(sql, threadRef, "failed", sql``)} + AND latest.created_at > now() - make_interval(secs => ${ROUTE_CLAIM_STALE_SECONDS}) + ) + ) FROM (${latestOutboundAttempt(sql, threadRef)}) latest ), false diff --git a/services/api/src/services/admin/outreach-jobs.ts b/services/api/src/services/admin/outreach-jobs.ts index 3b0c93c8..3c4036c2 100644 --- a/services/api/src/services/admin/outreach-jobs.ts +++ b/services/api/src/services/admin/outreach-jobs.ts @@ -2,7 +2,10 @@ import type { Container } from "../../di.js" import { writeAudit } from "./audit.js" import { OUTREACH_DIGEST_JOB } from "./jurisdiction-contacts-types.js" import { makeDrizzleMailRepository } from "./mail-repository.drizzle.js" -import { makeContainerOutboundMailService } from "./outbound-mail-service.js" +import { + makeContainerOutboundMailService, + type OutboundMailLogger, +} from "./outbound-mail-service.js" import { makeDrizzleOutreachRepository } from "./outreach-repository.drizzle.js" import { makeOutreachService, @@ -12,8 +15,11 @@ import { export { OUTREACH_DIGEST_JOB } -export async function registerOutreachJobs(container: Container): Promise { - const service = makeOutreachServiceFromContainer(container) +export async function registerOutreachJobs( + container: Container, + logger?: OutboundMailLogger, +): Promise { + const service = makeOutreachServiceFromContainer(container, logger) if (container.env.OUTREACH_DIGEST_ENABLED) { await container.jobs.schedule(OUTREACH_DIGEST_JOB, container.env.OUTREACH_DIGEST_CRON) @@ -33,15 +39,22 @@ export async function registerOutreachJobs(container: Container): Promise }) } -function makeOutreachServiceFromContainer(container: Container): OutreachService { +function makeOutreachServiceFromContainer( + container: Container, + logger: OutboundMailLogger | undefined, +): OutreachService { const sql = container.getDb().sql const mailRepo = makeDrizzleMailRepository(sql) - const outboundMail = makeContainerOutboundMailService(container, { repo: mailRepo }) + const outboundMail = makeContainerOutboundMailService(container, { + repo: mailRepo, + ...(logger !== undefined ? { logger } : {}), + }) return makeOutreachService({ outreachRepo: makeDrizzleOutreachRepository(sql), mailRepo, outboundMail, throttleDays: container.env.OUTREACH_THROTTLE_DAYS, + ...(logger !== undefined ? { logger } : {}), }) } diff --git a/services/api/src/services/admin/outreach-repository.drizzle.ts b/services/api/src/services/admin/outreach-repository.drizzle.ts index 2a581ed6..c0cab461 100644 --- a/services/api/src/services/admin/outreach-repository.drizzle.ts +++ b/services/api/src/services/admin/outreach-repository.drizzle.ts @@ -11,6 +11,7 @@ import { parseCount, type CategoryCountRow, } from "./category-counts.js" +import { legacyContactEmailUsable } from "./sql-fragments.js" interface DigestRow extends CategoryCountRow { org: string | null @@ -47,6 +48,11 @@ export function makeDrizzleOutreachRepository(sql: Sql): OutreachRepository { ( SELECT e FROM unnest(COALESCE(j.contact_emails, ARRAY[]::text[])) AS e WHERE e <> '' + AND ${legacyContactEmailUsable(sql, { + email: sql`e`, + geoid: sql`j.geoid`, + contactUpdatedAt: sql`j.contact_updated_at`, + })} LIMIT 1 ) ) AS to_addr, @@ -108,7 +114,13 @@ export function makeDrizzleOutreachRepository(sql: Sql): OutreachRepository { WHERE j.geoid = c.geoid AND j.contact_emails IS NOT NULL AND EXISTS ( - SELECT 1 FROM unnest(j.contact_emails) AS e WHERE e <> '' + SELECT 1 FROM unnest(COALESCE(j.contact_emails, ARRAY[]::text[])) AS e + WHERE e <> '' + AND ${legacyContactEmailUsable(sql, { + email: sql`e`, + geoid: sql`j.geoid`, + contactUpdatedAt: sql`j.contact_updated_at`, + })} ) ) ) diff --git a/services/api/src/services/admin/outreach-service.ts b/services/api/src/services/admin/outreach-service.ts index 72235cd3..667e8101 100644 --- a/services/api/src/services/admin/outreach-service.ts +++ b/services/api/src/services/admin/outreach-service.ts @@ -2,7 +2,11 @@ import { REPORT_CATEGORY_LABELS } from "@civfix/shared" import type { ReportCategory } from "@civfix/shared" import { ADMIN_CATEGORIES } from "./category-counts.js" import type { MailRepository } from "./mail-repository.drizzle.js" -import type { OutboundMailService } from "./outbound-mail-service.js" +import { + isOutboundSendDeadlineError, + type OutboundMailLogger, + type OutboundMailService, +} from "./outbound-mail-service.js" export interface OutreachDigest { geoid: string @@ -70,6 +74,7 @@ export interface OutreachServiceDeps { throttleDays: number now?: () => Date sweepBatchSize?: number + logger?: OutboundMailLogger } export interface OutreachService { @@ -128,10 +133,17 @@ export function makeOutreachService(deps: OutreachServiceDeps): OutreachService const threadId = await sendDigest(digest) return { geoid, sent: true, reportCount: digest.total, threadId } } catch (err) { - if (!isDeliveredError(err)) { + // A deadline is an unknown outcome, not a non-delivery: releasing the window would let the next + // sweep put a second digest in front of the same city contact. + if (!isDeliveredError(err) && !isOutboundSendDeadlineError(err)) { await deps.mailRepo .setOutreachState(geoid, { lastOutreachAt: state?.lastOutreachAt ?? null }) - .catch(() => {}) + .catch((releaseErr: unknown) => { + deps.logger?.warn( + { err: releaseErr, geoid }, + "outreach: releasing the digest window after a failed send failed", + ) + }) } throw err } diff --git a/services/api/src/services/admin/pagination.ts b/services/api/src/services/admin/pagination.ts index ce7f992c..df866434 100644 --- a/services/api/src/services/admin/pagination.ts +++ b/services/api/src/services/admin/pagination.ts @@ -7,9 +7,15 @@ * contract; these are the server-side encode/decode + limit clamp around them. */ -import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" +import { encodeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" -export { paginate } from "../../db/cursor-helpers.js" +export { + encodeKeysetCursor, + keysetInstant, + keysetPredicate, + paginate, + paginateKeyset, +} from "../../db/cursor-helpers.js" /** Default page size when the request omits `limit`. */ export const ADMIN_DEFAULT_LIMIT = 25 @@ -22,6 +28,11 @@ export interface CursorAnchor { id: string } +/** A decoded anchor that also keeps the cursor's instant as text, which is what keysetPredicate binds. */ +export interface KeysetAnchor extends CursorAnchor { + atText: string +} + /** Encode a keyset anchor into the opaque "|" cursor string. */ export function encodeCursor(anchor: CursorAnchor): string { return encodeTimeCursor({ at: anchor.createdAt, id: anchor.id }) @@ -38,10 +49,10 @@ export function encodeCursor(anchor: CursorAnchor): string { export function decodeCursor( cursor: string | null | undefined, requireUuidId = false, -): CursorAnchor | null { - const parsed = parseTimeCursor(cursor, { requireUuid: requireUuidId }) +): KeysetAnchor | null { + const parsed = parseKeysetCursor(cursor, { requireUuid: requireUuidId }) if (parsed === null) return null - return { createdAt: parsed.at, id: parsed.id } + return { createdAt: parsed.at, id: parsed.id, atText: parsed.atText } } /** @@ -83,7 +94,7 @@ export function decodeOffsetCursor(cursor: string | null | undefined): number { /** * Page a PRE-SORTED in-memory list by the shared "|" cursor: find the anchor row by id, then take * a one-extra-row probe. `anchorOf` returns the {createdAt,id} the cursor encodes — encoding the row's REAL - * sort value (not a placeholder) so the opaque cursor string matches the Drizzle impl for the same page. + * sort value (not a placeholder) so the opaque cursor has the same shape as the Drizzle impl for the same page. * The id alone drives the slice position; the cursor's createdAt is informational here. * * THE one implementation for every in-memory admin repo (six hand-rolled copies of diff --git a/services/api/src/services/admin/sql-fragments.ts b/services/api/src/services/admin/sql-fragments.ts index fa8ddd09..8d4bd613 100644 --- a/services/api/src/services/admin/sql-fragments.ts +++ b/services/api/src/services/admin/sql-fragments.ts @@ -41,22 +41,65 @@ export function jurisdictionHasAnyContactExpr(sql: Queryable, jAlias: string): S )` } +// A legacy contact_emails address has no bounce column: the bounce handler leaves only a 'bounced' +// mail_events row on the jurisdiction's thread (or a bounced_at on a matching per-category row). Events +// older than the last contact save are ignored, the same scoping the directory's bounced flag uses, so +// re-entering a fixed address makes it usable again. +export function legacyContactEmailUsable( + sql: Queryable, + refs: { email: SqlFragment; geoid: SqlFragment; contactUpdatedAt: SqlFragment }, +): SqlFragment { + return sql` + NOT EXISTS ( + SELECT 1 FROM jurisdiction_contacts bc + WHERE bc.geoid = ${refs.geoid} AND bc.bounced_at IS NOT NULL + AND lower(bc.email) = lower(${refs.email}) + ) + AND NOT EXISTS ( + SELECT 1 FROM mail_events me + JOIN mail_threads mt ON mt.id = me.thread_id + WHERE mt.jurisdiction_geoid = ${refs.geoid} AND me.type = 'bounced' + AND lower(me.meta->>'failedRecipient') = lower(${refs.email}) + AND me.created_at > COALESCE(${refs.contactUpdatedAt}, '-infinity'::timestamptz) + ) + ` +} + +// One definition of a routable contact for every reader that sends a packet or reports a report as +// routable, so a hard-bounced address stops receiving packets everywhere at once. +export function usableContactRowExpr(sql: Queryable, jcAlias: string): SqlFragment { + const jc = sql(jcAlias) + return sql`${jc}.email IS NOT NULL AND btrim(${jc}.email) <> '' AND ${jc}.bounced_at IS NULL` +} + +export function firstUsableLegacyContactExpr(sql: Queryable, jAlias: string): SqlFragment { + const j = sql(jAlias) + return sql`( + SELECT ulc.v FROM unnest(COALESCE(${j}.contact_emails, '{}'::text[])) WITH ORDINALITY AS ulc(v, ord) + WHERE btrim(ulc.v) <> '' + AND ${legacyContactEmailUsable(sql, { + email: sql`ulc.v`, + geoid: sql`${j}.geoid`, + contactUpdatedAt: sql`${j}.contact_updated_at`, + })} + ORDER BY ulc.ord + LIMIT 1 + )` +} + export function reportRoutableExpr(sql: Queryable, rAlias: string): SqlFragment { const r = sql(rAlias) return sql`( EXISTS ( - SELECT 1 FROM jurisdictions rrj - WHERE rrj.geoid = ${r}.jurisdiction_geoid - AND EXISTS ( - SELECT 1 FROM unnest(COALESCE(rrj.contact_emails, '{}'::text[])) AS rrje(v) - WHERE btrim(rrje.v) <> '' - ) - ) - OR EXISTS ( SELECT 1 FROM jurisdiction_contacts rrjc WHERE rrjc.geoid = ${r}.jurisdiction_geoid - AND rrjc.email IS NOT NULL AND btrim(rrjc.email) <> '' + AND ${usableContactRowExpr(sql, "rrjc")} AND (rrjc.category IS NULL OR rrjc.category = ${r}.category) ) + OR EXISTS ( + SELECT 1 FROM jurisdictions rrj + WHERE rrj.geoid = ${r}.jurisdiction_geoid + AND ${firstUsableLegacyContactExpr(sql, "rrj")} IS NOT NULL + ) )` } diff --git a/services/api/src/services/blocks-repository.drizzle.ts b/services/api/src/services/blocks-repository.drizzle.ts index 939cb035..af0114a2 100644 --- a/services/api/src/services/blocks-repository.drizzle.ts +++ b/services/api/src/services/blocks-repository.drizzle.ts @@ -1,7 +1,12 @@ import type { Sql } from "../db/client.js" import { avatarGradient } from "@civfix/shared" import type { PersonDTO } from "@civfix/shared" -import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../db/cursor-helpers.js" export const LIST_BLOCKS_DEFAULT_LIMIT = 50 @@ -80,10 +85,10 @@ export function makeDrizzleBlocksRepository(sql: Sql): BlocksRepository { async listBlocked(blockerId: string, args?: ListBlockedArgs): Promise { const limit = args?.limit ?? LIST_BLOCKS_DEFAULT_LIMIT - const cursor = parseTimeCursor(args?.cursor ?? null) + const cursor = parseKeysetCursor(args?.cursor ?? null) const cursorFilter = cursor !== null - ? sql`AND (b.created_at, b.blocked_id) < (${cursor.at}, ${cursor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`b.created_at`, sql`b.blocked_id`, cursor)}` : sql`` const rows = await sql< { @@ -92,10 +97,11 @@ export function makeDrizzleBlocksRepository(sql: Sql): BlocksRepository { handle: string | null bio: string | null avatar_url: string | null - created_at: Date + cursor_at: string }[] >` - SELECT u.id, u.display_name, u.handle, u.bio, u.avatar_url, b.created_at + SELECT u.id, u.display_name, u.handle, u.bio, u.avatar_url, + ${keysetInstant(sql, sql`b.created_at`)} AS cursor_at FROM user_blocks b JOIN users u ON u.id = b.blocked_id WHERE b.blocker_id = ${blockerId} AND u.deleted_at IS NULL @@ -103,7 +109,10 @@ export function makeDrizzleBlocksRepository(sql: Sql): BlocksRepository { ORDER BY b.created_at DESC, b.blocked_id DESC LIMIT ${limit + 1} ` - const { items, nextCursor } = paginate(rows, limit, (r) => ({ at: r.created_at, id: r.id })) + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) return { blocked: items.map((r) => ({ id: r.id, diff --git a/services/api/src/services/certificate-pdf.ts b/services/api/src/services/certificate-pdf.ts index 0c6c7cd1..fdf16afc 100644 --- a/services/api/src/services/certificate-pdf.ts +++ b/services/api/src/services/certificate-pdf.ts @@ -14,7 +14,9 @@ import { type TranscriptModel, } from "./certificate-model.js" -export const CERTIFICATE_VERIFY_BASE_URL = "https://civfix.org/service-record" +export const CERTIFICATE_VERIFY_PATH = "/service-record" + +export const CERTIFICATE_VERIFY_BASE_URL = `https://civfix.org${CERTIFICATE_VERIFY_PATH}` export interface ServiceHoursPdfInput { model: TranscriptModel @@ -84,7 +86,9 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise const locale = model.locale const displayCode = formatCertificateCode(input.code) const issuedAt = toDate(input.issuedAt) - const verifyUrl = `${input.verifyBaseUrl ?? CERTIFICATE_VERIFY_BASE_URL}/${displayCode}` + const verifyBaseUrl = input.verifyBaseUrl ?? CERTIFICATE_VERIFY_BASE_URL + const verifyUrl = `${verifyBaseUrl}/${displayCode}` + const verifyLabel = verifyBaseUrl.replace(/^https?:\/\//, "") const holderName = model.holder.displayName const issuedLabel = formatDate(issuedAt, locale) @@ -437,7 +441,7 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise align: "center", characterSpacing: 0.2, }) - line(FONT.mono, 8, COLOR.ink3, String(issuedAt.getUTCFullYear()), sealX, cy + 8, { + line(FONT.mono, 8, COLOR.ink3, formatYear(issuedAt), sealX, cy + 8, { width: 68, align: "center", }) @@ -461,7 +465,7 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise drawQr(verifyUrl, 474, blockTop, 84) const textX = 306 const textW = 156 - const prompt = t("certificate.verify.prompt") + const prompt = t("certificate.verify.prompt", { url: verifyLabel }) font(fontFor(prompt, "regular"), 8).fillColor(COLOR.ink2) doc.text(prompt, textX, blockTop + 4, { width: textW, @@ -539,7 +543,7 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise width: 200, }, ) - line(FONT.body, 7.5, COLOR.ink3, "civfix.org/service-record", 206, PAGE.footerText, { + line(FONT.body, 7.5, COLOR.ink3, verifyLabel, 206, PAGE.footerText, { width: 200, align: "center", }) @@ -572,6 +576,14 @@ function formatDate(value: Date, locale: string): string { }).format(value) } +// The seal year must agree with the Issued date printed beside it, which is in the certificate zone. +function formatYear(value: Date): string { + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + timeZone: CERTIFICATE_TIME_ZONE, + }).format(value) +} + function formatNumber(value: number, locale: string): string { return new Intl.NumberFormat(locale, { minimumFractionDigits: Number.isInteger(value) ? 0 : 1, diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index dcd69406..480d18e5 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -199,6 +199,8 @@ export interface CertificateServiceDeps { /** The ledger read lives on the volunteer-hours repo; this service owns only the certificate rows. */ hours: Pick storage: CertificateStorage + /** Printed and QR-encoded on every document; the deployment's own web origin, so staging never points at production. */ + verifyBaseUrl?: string now?: () => Date newId?: () => string /** Injected in tests to force the code-collision retry. */ @@ -365,6 +367,7 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat code: existing.code, issuedAt: existing.issuedAt, fingerprint, + ...(deps.verifyBaseUrl !== undefined ? { verifyBaseUrl: deps.verifyBaseUrl } : {}), }) const documentSha256 = sha256Hex(bytes) await storage.put(existing.r2Key, bytes, { @@ -398,7 +401,13 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat // The code is PRINTED on the document, so a re-mint must re-render. The key is derived from the // row id, which does not change across attempts, so the re-put overwrites rather than orphaning. const code = mintCode() - const bytes = await buildServiceHoursPdf({ model, code, issuedAt: at, fingerprint }) + const bytes = await buildServiceHoursPdf({ + model, + code, + issuedAt: at, + fingerprint, + ...(deps.verifyBaseUrl !== undefined ? { verifyBaseUrl: deps.verifyBaseUrl } : {}), + }) const documentSha256 = sha256Hex(bytes) await storage.put(r2Key, bytes, { contentType: "application/pdf", diff --git a/services/api/src/services/chat-edit-service.ts b/services/api/src/services/chat-edit-service.ts index 0b89c31e..f6a0c9f7 100644 --- a/services/api/src/services/chat-edit-service.ts +++ b/services/api/src/services/chat-edit-service.ts @@ -18,8 +18,8 @@ * 5. kind === "text" only -> 422. * 6. Within EDIT_WINDOW_HOURS of created_at -> 403 (code "edit_window_expired"). * Then: slur filter (App Store 1.2a, same helper as WS send / the old DM edit), the sender-gated UPDATE - * (body + edited_at = now()), mention re-resolution under the existing scope rules (report rooms stay - * mention-free; the recorded set is REPLACED so dropped @mentions clear), a {type:"message_update"} + * (body + edited_at = now()), mention re-resolution under the same scope rules the send path records + * with (the recorded set is REPLACED so dropped @mentions clear), a {type:"message_update"} * broadcast to the room key, and the refreshed fully-hydrated ChatMessageDTO back to the caller. * * The machine subcodes ride AppError's `fields` ({ code: "..." }) because ErrorCode is a closed enum; @@ -62,7 +62,7 @@ export interface ChatEditServiceDeps { * Optional mention seam (resolve + record). Absent -> the edit leaves the recorded mentions as-is. * notifyChatMention is deliberately excluded: editing a message never re-fires mention bells. */ - chatMentions?: Pick + chatMentions?: Pick /** Room fan-out seam (chatService.broadcastEvent). Best-effort: a failure never fails the edit. */ broadcastEvent?: (roomKey: string, frame: WsServerMessage) => Promise | void } @@ -106,9 +106,9 @@ function assertEditable( export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService { /** - * Re-resolve + REPLACE the message's recorded mention set from the edited body (existing scope rules: - * the resolver filters to the dm peer / cleanup members; report rooms stay mention-free so they skip - * entirely). Best-effort like the WS send path — a mention failure never fails the edit. + * Re-resolve + REPLACE the message's recorded mention set from the edited body (the resolver scopes it + * to the dm peer or the room's members). Best-effort like the WS send path: a mention failure never + * fails the edit. */ async function rerecordMentions( kind: RoomKind, @@ -119,7 +119,7 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService mentionedUserIds: string[] | undefined, ): Promise { const mentions = deps.chatMentions - if (!mentions || kind === "report") return + if (!mentions) return try { const resolved = await mentions.resolveChatMentions({ handles: parseUserMentions(body), @@ -133,8 +133,11 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService messageId, resolved.map((m) => m.id), ) - } catch { - // Best-effort, like the WS send path: a mention failure never fails the edit. + } catch (err) { + mentions.logger?.warn( + { err, messageId, kind, roomId }, + "chat mentions could not be re-recorded on edit; keeping the edit", + ) } } diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index bb4de4d0..6242dcc4 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -70,9 +70,9 @@ export interface ChatGroupRepository { roleOf(groupId: string, userId: string): Promise accessOf(groupId: string, userId: string): Promise addMembers(groupId: string, userIds: string[]): Promise + joinUnlessBanned(groupId: string, userId: string): Promise removeMember(groupId: string, userId: string): Promise banMember(groupId: string, userId: string, bannedBy: string): Promise - isBanned(groupId: string, userId: string): Promise setRole(groupId: string, userId: string, role: "admin" | "member"): Promise findMember( groupId: string, @@ -331,22 +331,35 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG return rows.length > 0 }, - async banMember(groupId: string, userId: string, bannedBy: string): Promise { - await sql` - INSERT INTO chat_group_bans (group_id, user_id, banned_by) - VALUES (${groupId}, ${userId}, ${bannedBy}) - ON CONFLICT (group_id, user_id) - DO UPDATE SET banned_by = ${bannedBy}, banned_at = now() + async joinUnlessBanned(groupId: string, userId: string): Promise { + // The ban check and the insert are one statement, so a kick that commits after the caller's own + // checks still keeps the user out, and a self-join never clears a ban the way an invite does. + const rows = await sql<{ user_id: string }[]>` + INSERT INTO chat_group_members (group_id, user_id, role) + SELECT ${groupId}::uuid, ${userId}::uuid, 'member' + WHERE NOT EXISTS ( + SELECT 1 FROM chat_group_bans + WHERE group_id = ${groupId} AND user_id = ${userId} + ) + ON CONFLICT (group_id, user_id) DO NOTHING + RETURNING user_id ` + return rows.length > 0 }, - async isBanned(groupId: string, userId: string): Promise { - const rows = await sql<{ one: number }[]>` - SELECT 1 AS one FROM chat_group_bans - WHERE group_id = ${groupId} AND user_id = ${userId} - LIMIT 1 - ` - return rows.length > 0 + async banMember(groupId: string, userId: string, bannedBy: string): Promise { + await sql.begin(async (tx) => { + await tx` + DELETE FROM chat_group_members + WHERE group_id = ${groupId} AND user_id = ${userId} + ` + await tx` + INSERT INTO chat_group_bans (group_id, user_id, banned_by) + VALUES (${groupId}, ${userId}, ${bannedBy}) + ON CONFLICT (group_id, user_id) + DO UPDATE SET banned_by = ${bannedBy}, banned_at = now() + ` + }) }, async setRole(groupId: string, userId: string, role: "admin" | "member"): Promise { diff --git a/services/api/src/services/chat-group-service.ts b/services/api/src/services/chat-group-service.ts index a12694be..e7c21e0e 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -255,7 +255,6 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi if (targetRole === "owner" || (targetRole === "admin" && actorRole !== "owner")) { throw forbidden("You can't remove this member.", "remove_forbidden") } - await groups.removeMember(groupId, targetId) await groups.banMember(groupId, targetId, actorId) }, @@ -305,14 +304,17 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi if (view === null || view.visibility !== "public") { throw forbidden("This group isn't open to join.", "not_public") } - if (await groups.isBanned(groupId, userId)) { - throw forbidden("This group isn't open to join.", "not_public") - } const role = await groups.roleOf(groupId, userId) if (role !== null) { return toGroupDTO(view, userId, role) } - await groups.addMembers(groupId, [userId]) + if (!(await groups.joinUnlessBanned(groupId, userId))) { + const concurrentRole = await groups.roleOf(groupId, userId) + if (concurrentRole === null) { + throw forbidden("This group isn't open to join.", "not_public") + } + return toGroupDTO(view, userId, concurrentRole) + } const refreshed = await requireGroup(groupId) return toGroupDTO(refreshed, userId, "member") }, diff --git a/services/api/src/services/chat-mention-resolver.ts b/services/api/src/services/chat-mention-resolver.ts index 1d5a92f8..2b47be96 100644 --- a/services/api/src/services/chat-mention-resolver.ts +++ b/services/api/src/services/chat-mention-resolver.ts @@ -5,21 +5,22 @@ * - report rooms resolve only current report_chat_members (D11, P2 2.5): a @mention of a non-member * silently resolves to nothing — no row, no bell; * - dm resolves only the thread PEER (a @mention of anyone else silently drops); - * - cleanup resolves only current MEMBERS (capped at THREAD_SIGNAL_MEMBER_CAP); - * - group rooms (P4 4.4) resolve only current chat_group_members (uncapped, like report). + * - cleanup resolves only current cleanup_members; + * - group rooms (P4 4.4) resolve only current chat_group_members. * - * The user lookup, dm-peer lookup, and member listings are injected so each call site wires its own repo - * instances; only the scope rules live here. Room kinds added later extend THIS file. + * Membership is checked for the resolved users only, never against a capped roster listing, so a member + * of any seniority stays mentionable. The user lookup, dm-peer lookup, and member filters are injected so + * each call site wires its own repo instances; only the scope rules live here. Room kinds added later + * extend THIS file. */ import type { RoomKind, UserMentionDTO } from "@civfix/shared" import { parseUserMentions } from "./discussion-mentions.js" -import { THREAD_SIGNAL_MEMBER_CAP } from "./cleanup-service.js" import type { GatewayChatMentions } from "../ws/types.js" export type ChatMentionRecordSeam = Pick< GatewayChatMentions, - "resolveChatMentions" | "recordChatMentions" + "resolveChatMentions" | "recordChatMentions" | "logger" > export interface RecordChatMentionsInput { @@ -53,7 +54,11 @@ export async function resolveAndRecordChatMentions( ) } return mentions - } catch { + } catch (err) { + seam.logger?.warn( + { err, messageId: input.messageId, kind: input.kind, roomId: input.roomId }, + "chat mentions could not be resolved or recorded; sending without them", + ) return [] } } @@ -67,12 +72,12 @@ export interface ChatMentionResolverDeps { }): Promise /** The OTHER dm participant, or null when the author is not in the thread. */ dmPeerOf(threadId: string, userId: string): Promise - /** Member ids of a cleanup, capped (the resolver passes THREAD_SIGNAL_MEMBER_CAP). */ - listCleanupMemberIds(cleanupId: string, cap: number): Promise - /** Member ids of a report chat (report_chat_members; uncapped in the repo, like the D-E2 fan-out). */ - listReportChatMemberIds(reportId: string): Promise - /** Member ids of a group room (chat_group_members; P4 4.4). */ - listGroupMemberIds(groupId: string): Promise + /** The cleanup members among `candidateIds`. */ + listCleanupMemberIds(cleanupId: string, candidateIds: string[]): Promise + /** The report chat members (report_chat_members) among `candidateIds`. */ + listReportChatMemberIds(reportId: string, candidateIds: string[]): Promise + /** The group room members (chat_group_members; P4 4.4) among `candidateIds`. */ + listGroupMemberIds(groupId: string, candidateIds: string[]): Promise } /** Build the seam's `resolveChatMentions` half over the injected lookups. */ @@ -90,17 +95,14 @@ export function makeChatMentionResolver( const peer = await deps.dmPeerOf(input.roomId, input.authorUserId) return peer !== null ? resolved.filter((m) => m.id === peer) : [] } - if (input.kind === "report") { - const memberIds = new Set(await deps.listReportChatMemberIds(input.roomId)) - return resolved.filter((m) => memberIds.has(m.id)) - } - if (input.kind === "group") { - const memberIds = new Set(await deps.listGroupMemberIds(input.roomId)) - return resolved.filter((m) => memberIds.has(m.id)) - } - const memberIds = new Set( - await deps.listCleanupMemberIds(input.roomId, THREAD_SIGNAL_MEMBER_CAP), - ) + const candidateIds = resolved.map((m) => m.id) + const members = + input.kind === "report" + ? await deps.listReportChatMemberIds(input.roomId, candidateIds) + : input.kind === "group" + ? await deps.listGroupMemberIds(input.roomId, candidateIds) + : await deps.listCleanupMemberIds(input.roomId, candidateIds) + const memberIds = new Set(members) return resolved.filter((m) => memberIds.has(m.id)) } } diff --git a/services/api/src/services/chat-mentions.drizzle.ts b/services/api/src/services/chat-mentions.drizzle.ts index 16a86f36..8b23ac80 100644 --- a/services/api/src/services/chat-mentions.drizzle.ts +++ b/services/api/src/services/chat-mentions.drizzle.ts @@ -43,3 +43,36 @@ export async function recordChatMentions( await repo.recordFor(tx, messageId, mentionedUserIds) }) } + +export type MentionScopeKind = "cleanup" | "report" | "group" + +const MEMBER_TABLES: Record< + MentionScopeKind, + { + table: "cleanup_members" | "report_chat_members" | "chat_group_members" + scope: "cleanup_id" | "report_id" | "group_id" + } +> = { + cleanup: { table: "cleanup_members", scope: "cleanup_id" }, + report: { table: "report_chat_members", scope: "report_id" }, + group: { table: "chat_group_members", scope: "group_id" }, +} + +/** + * Which of `candidateIds` are current members of the room. A primary-key probe per candidate, bounded by + * the mentions a message can carry, so a member who joined after any roster cap is still mentionable. + */ +export async function roomMemberIdsAmong( + sql: Queryable, + kind: MentionScopeKind, + roomId: string, + candidateIds: string[], +): Promise { + if (candidateIds.length === 0) return [] + const { table, scope } = MEMBER_TABLES[kind] + const rows = await sql<{ user_id: string }[]>` + SELECT user_id FROM ${sql(table)} + WHERE ${sql(scope)} = ${roomId} AND user_id = ANY(${candidateIds}::uuid[]) + ` + return rows.map((r) => r.user_id) +} diff --git a/services/api/src/services/chat-room-fanout-notifier.ts b/services/api/src/services/chat-room-fanout-notifier.ts index 908d2302..ad891afd 100644 --- a/services/api/src/services/chat-room-fanout-notifier.ts +++ b/services/api/src/services/chat-room-fanout-notifier.ts @@ -12,6 +12,15 @@ const FANOUT_CONCURRENCY = 8 export const ROOM_FANOUT_MEMBER_CAP = 2000 +export const REPORT_CHAT_FANOUT_MEMBER_CAP = 500 + +// The fan-out owns the cap: a wiring that forwards a one-argument listMemberIds still type-checks, so a +// cap passed only through the adapter is silently dropped. +const MEMBER_CAP_BY_KIND: Record = { + report: REPORT_CHAT_FANOUT_MEMBER_CAP, + group: ROOM_FANOUT_MEMBER_CAP, +} + export const ROOM_ACTIVITY_COALESCE_WINDOW_MS = 10 * 60 * 1000 export const ROOM_FANOUT_THROTTLE_MS = 15 * 1000 @@ -19,8 +28,10 @@ export const ROOM_FANOUT_THROTTLE_MS = 15 * 1000 const FANOUT_MARKER_SWEEP_THRESHOLD = 5000 export interface RoomFanoutNotifierDeps { - notificationService: Pick - listMemberIds: (roomId: string) => Promise + notificationService: Pick + listMemberIds: (roomId: string, limit: number) => Promise + // May reject: the fan-out fails open per recipient and logs one line per fan-out, so a wiring + // that wraps this in its own fail-open check brings back one warning per member. isMuted: (userId: string, roomId: string) => Promise mutedUserIdsFor?: (roomId: string, userIds: string[]) => Promise> presence?: { online(roomKey: string): Promise } | undefined @@ -45,8 +56,35 @@ export const ROOM_FANOUT_SPEC: Record = { group: { kind: "group", titleFallbackKey: "notification.group_chat.title_fallback" }, } +interface LookupFailures { + record(err: unknown): void + report(msg: string): void +} + +// A store outage fails every per-recipient lookup of a fan-out, so one line per fan-out carries the +// count instead of one warning per member. +function tallyLookupFailures( + deps: RoomFanoutNotifierDeps, + kind: RoomFanoutKind, + candidates: number, +): LookupFailures { + let failed = 0 + let first: unknown + return { + record(err) { + if (failed === 0) first = err + failed += 1 + }, + report(msg) { + if (failed === 0) return + deps.logger?.warn({ err: first, kind, failed, candidates }, msg) + }, + } +} + async function blockedIds( deps: RoomFanoutNotifierDeps, + kind: RoomFanoutKind, actorId: string | null, candidates: string[], ): Promise> { @@ -54,22 +92,27 @@ async function blockedIds( if (deps.blockedIdsFor) { try { return await deps.blockedIdsFor(actorId, candidates) - } catch { + } catch (err) { + deps.logger?.warn({ err, kind }, "room fan-out block lookup failed; skipping the room") return new Set(candidates) } } + const failures = tallyLookupFailures(deps, kind, candidates.length) const verdicts = await mapWithLimit(candidates, FANOUT_CONCURRENCY, async (recipientId) => { try { return await deps.isBlockedEitherWay(actorId, recipientId) - } catch { + } catch (err) { + failures.record(err) return true } }) + failures.report("room fan-out block lookup failed; skipping those recipients") return new Set(candidates.filter((_, i) => verdicts[i] === true)) } async function mutedIds( deps: RoomFanoutNotifierDeps, + kind: RoomFanoutKind, roomId: string, candidates: string[], ): Promise> { @@ -77,17 +120,21 @@ async function mutedIds( if (deps.mutedUserIdsFor) { try { return await deps.mutedUserIdsFor(roomId, candidates) - } catch { + } catch (err) { + deps.logger?.warn({ err, kind }, "room fan-out mute lookup failed; notifying anyway") return new Set() } } + const failures = tallyLookupFailures(deps, kind, candidates.length) const verdicts = await mapWithLimit(candidates, FANOUT_CONCURRENCY, async (recipientId) => { try { return await deps.isMuted(recipientId, roomId) - } catch { + } catch (err) { + failures.record(err) return false } }) + failures.report("room fan-out mute lookup failed; notifying those recipients anyway") return new Set(candidates.filter((_, i) => verdicts[i] === true)) } @@ -131,7 +178,11 @@ export function makeRoomFanoutNotifier( ) } } - await runRoomFanout(spec, deps, roomId, message) + try { + await runRoomFanout(spec, deps, roomId, message) + } catch (err) { + deps.logger?.error({ err, kind: spec.kind }, "chat.room.fanout inline fan-out failed") + } } } @@ -144,13 +195,15 @@ export async function runRoomFanout( const bell = CONVERSATION_BELL[spec.kind] const coalesceWindowMs = deps.coalesceWindowMs ?? ROOM_ACTIVITY_COALESCE_WINDOW_MS const actorId = message.from?.id ?? null - const memberIds = (await deps.listMemberIds(roomId)).slice(0, ROOM_FANOUT_MEMBER_CAP) + const cap = MEMBER_CAP_BY_KIND[spec.kind] + const memberIds = (await deps.listMemberIds(roomId, cap)).slice(0, cap) let present: string[] = [] if (deps.presence) { try { present = await deps.presence.online(deps.roomKey(roomId)) - } catch { + } catch (err) { + deps.logger?.warn({ err, kind: spec.kind }, "room fan-out presence lookup failed") present = [] } } @@ -164,23 +217,36 @@ export async function runRoomFanout( ) if (candidates.length === 0) return - const blocked = await blockedIds(deps, actorId, candidates) + const blocked = await blockedIds(deps, spec.kind, actorId, candidates) const unblocked = candidates.filter((id) => !blocked.has(id)) if (unblocked.length === 0) return - const muted = await mutedIds(deps, roomId, unblocked) + const muted = await mutedIds(deps, spec.kind, roomId, unblocked) const recipients = unblocked.filter((id) => !muted.has(id)) if (recipients.length === 0) return const name = message.from?.name?.trim() ? message.from.name : null const preview = textPreview(message) - await deps.notificationService - .createNotifications(recipients, { + const { failed } = await deps.notificationService.createNotificationsReportingFailures( + recipients, + { type: bell.type, ...(name !== null ? { title: name } : { titleKey: spec.titleFallbackKey }), ...(preview !== null ? { body: preview } : { bodyKey: "notification.message.no_preview" }), link: bell.link(roomId), coalesceWindowMs, - }) - .catch(() => {}) + }, + ) + if (failed.length === 0) return + // A total failure fails the chat.room.fanout job so pg-boss retries it. Re-running the whole fan-out + // is safe because room bells coalesce into the recipient's unread row inside the coalesce window, + // which is far longer than pg-boss's immediate retries. A partial failure completes: the room's next + // message bells the missed members again. + if (failed.length === recipients.length) { + throw new Error(`room fan-out wrote no bell for ${failed.length} recipients`) + } + deps.logger?.warn( + { kind: spec.kind, recipients: recipients.length, failed: failed.length }, + "room fan-out: some bells were not written", + ) } diff --git a/services/api/src/services/chat-room-notifier-wiring.ts b/services/api/src/services/chat-room-notifier-wiring.ts index 2f8ab00e..1a99841f 100644 --- a/services/api/src/services/chat-room-notifier-wiring.ts +++ b/services/api/src/services/chat-room-notifier-wiring.ts @@ -7,7 +7,6 @@ import { makeDrizzleNotificationRepository } from "./notification-repository.dri import { makeConversationMutesRepository } from "./conversation-mutes-repository.drizzle.js" import { RedisChatPresence } from "../adapters/chat-presence.js" import { roomKeyFor } from "../ws/gateway.js" -import { REPORT_CHAT_FANOUT_MEMBER_CAP } from "./report-chat-notifier.js" import { ROOM_FANOUT_THROTTLE_MS, type RoomFanoutKind, @@ -36,18 +35,6 @@ export function makeContainerRoomFanoutDeps( }) const conversationMutes = makeConversationMutesRepository(sql) - const isMuted = async ( - userId: string, - kind: RoomFanoutKind, - roomId: string, - ): Promise => { - try { - return await conversationMutes.isMuted(userId, kind, roomId) - } catch (err) { - logger?.warn({ err, kind }, "room fan-out mute lookup failed; notifying anyway") - return false - } - } const mutedUserIdsForRoom = ( kind: RoomFanoutKind, ): ((roomId: string, userIds: string[]) => Promise>) | undefined => { @@ -73,7 +60,7 @@ export function makeContainerRoomFanoutDeps( const common = (kind: RoomFanoutKind): Omit => ({ notificationService, - isMuted: (userId, roomId) => isMuted(userId, kind, roomId), + isMuted: (userId, roomId) => conversationMutes.isMuted(userId, kind, roomId), ...(mutedUserIdsForRoom(kind) ? { mutedUserIdsFor: mutedUserIdsForRoom(kind) } : {}), ...(presence !== undefined ? { presence } : {}), roomKey: (roomId) => roomKeyFor(kind, roomId), @@ -88,12 +75,11 @@ export function makeContainerRoomFanoutDeps( return { report: { ...common("report"), - listMemberIds: (reportId) => - reportChatRepo.listMemberIds(reportId, REPORT_CHAT_FANOUT_MEMBER_CAP), + listMemberIds: (reportId, limit) => reportChatRepo.listMemberIds(reportId, limit), }, group: { ...common("group"), - listMemberIds: (groupId) => groupRepo.listMemberIds(groupId), + listMemberIds: (groupId, limit) => groupRepo.listMemberIds(groupId, limit), }, } } diff --git a/services/api/src/services/claim-service.ts b/services/api/src/services/claim-service.ts index 3978d6d4..b0134c6c 100644 --- a/services/api/src/services/claim-service.ts +++ b/services/api/src/services/claim-service.ts @@ -20,6 +20,7 @@ export interface ClaimServiceDeps { enqueueHoldRelease?: (reportId: string) => Promise newClaimCode?: () => string now?: () => Date + logger?: { warn(obj: unknown, msg?: string): void } } export interface ClaimService { @@ -55,7 +56,13 @@ export function makeClaimService(deps: ClaimServiceDeps): ClaimService { throw AppError.notFound("Claim code not found") } if (deps.enqueueHoldRelease !== undefined) { - await deps.enqueueHoldRelease(claimed.reportId).catch(() => {}) + // The claim already committed; the media-worker hold-release sweep is the backstop. + await deps.enqueueHoldRelease(claimed.reportId).catch((err: unknown) => { + deps.logger?.warn( + { err, reportId: claimed.reportId }, + "claim: hold-release enqueue failed (suppressed; the sweep releases it)", + ) + }) } const report = await deps.getReportForOwner(claimed.reportId, { userId }) return { report } diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index 737372a4..61852d50 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -24,11 +24,14 @@ import { userUploader } from "./media-uploader.js" import { isUniqueViolationOn } from "./host/registration-sql.js" import { applyBanIn } from "./host/registration-repository.drizzle.js" import { deterministicUuid } from "./deterministic-uuid.js" +import { firstUsableLegacyContactExpr, usableContactRowExpr } from "./admin/sql-fragments.js" import type { AttendeeView, CancelCleanupOutcome, ClaimSlotOutcome, CleanupOrganizationView, + CleanupEditOutcome, + CleanupEdits, CleanupIdempotency, CleanupRecord, CleanupRepository, @@ -282,7 +285,7 @@ async function privateEventBlocksJoin( return standing.length === 0 } -function hostSetFragments(sql: Sql, patch: EventHostWrite): postgres.Fragment[] { +function hostSetFragments(sql: Queryable, patch: EventHostWrite): postgres.Fragment[] { const sets: postgres.Fragment[] = [] if (patch.endsAt !== undefined) sets.push(sql`ends_at = ${patch.endsAt}`) if (patch.timezone !== undefined) sets.push(sql`timezone = ${patch.timezone}`) @@ -311,6 +314,80 @@ function hostSetFragments(sql: Sql, patch: EventHostWrite): postgres.Fragment[] return sets } +function cleanupSetList(sql: Queryable, patch: UpdateCleanupPatch): postgres.Fragment | null { + const sets: postgres.Fragment[] = hostSetFragments(sql, patch) + if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`) + if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) + if (patch.eventKind !== undefined) sets.push(sql`event_kind = ${patch.eventKind}`) + if (patch.type !== undefined) sets.push(sql`type = ${patch.type}`) + if (patch.scheduledAt !== undefined) sets.push(sql`scheduled_at = ${patch.scheduledAt}`) + if (patch.lat !== undefined && patch.lng !== undefined) { + sets.push(sql`geom = ST_SetSRID(ST_MakePoint(${patch.lng}, ${patch.lat}), 4326)`) + } + if (patch.address !== undefined) sets.push(sql`address = ${patch.address}`) + if (patch.addressSource !== undefined) { + sets.push(sql`address_source = ${patch.addressSource}`) + } + if (patch.bring !== undefined) { + sets.push(sql`bring = ${patch.bring as unknown as string[] | null}`) + } + if (patch.jurisdictionGeoid !== undefined) { + sets.push(sql`jurisdiction_geoid = ${patch.jurisdictionGeoid}`) + } + if (sets.length === 0) return null + return sets.reduce((acc, frag) => sql`${acc}, ${frag}`) +} + +async function updateCleanupInTx( + tx: Queryable, + id: string, + setList: postgres.Fragment, + patch: UpdateCleanupPatch, + actorUserId: string, +): Promise { + const updated = await tx<{ id: string }[]>` + UPDATE cleanups SET ${setList} WHERE id = ${id} RETURNING id + ` + if (updated.length === 0) return false + await claimEventMediaInTx(tx, id, patch, actorUserId) + return true +} + +async function reconcileLinkedReportsInTx( + tx: Queryable, + cleanupId: string, + desiredIds: string[], + actorId: string | null, +): Promise<{ added: string[]; removed: string[] }> { + const existing = await tx<{ report_id: string }[]>` + SELECT report_id FROM cleanup_reports WHERE cleanup_id = ${cleanupId} + ` + const have = new Set(existing.map((r) => r.report_id)) + const want = new Set(desiredIds) + const toAdd = desiredIds.filter((id) => !have.has(id)) + const droppable = [...have].filter((id) => !want.has(id)) + const visible = await selectVisibleReportIds(tx, droppable) + const toRemove = droppable.filter((id) => visible.has(id)) + + const added = await linkReportsInTx(tx, cleanupId, toAdd, actorId) + if (toRemove.length > 0) { + await tx` + DELETE FROM cleanup_reports + WHERE cleanup_id = ${cleanupId} AND report_id = ANY(${toRemove}::uuid[]) + ` + await tx` + INSERT INTO cleanup_timeline (cleanup_id, kind, note, actor_id) + SELECT ${cleanupId}, 'report_unlinked', 'Unlinked report ' || rid, ${actorId} + FROM unnest(${toRemove}::uuid[]) AS rid + ` + } + return { added, removed: toRemove } +} + +function slotConflictAsValidation(err: unknown): unknown { + return isSlotTitleConflict(err) ? AppError.validation({ slots: "duplicate slot title" }) : err +} + export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { async function readById( tag: Queryable, @@ -444,39 +521,57 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { patch: UpdateCleanupPatch, actorUserId: string, ): Promise { - const sets: postgres.Fragment[] = hostSetFragments(sql, patch) - if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`) - if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) - if (patch.eventKind !== undefined) sets.push(sql`event_kind = ${patch.eventKind}`) - if (patch.type !== undefined) sets.push(sql`type = ${patch.type}`) - if (patch.scheduledAt !== undefined) sets.push(sql`scheduled_at = ${patch.scheduledAt}`) - if (patch.lat !== undefined && patch.lng !== undefined) { - sets.push(sql`geom = ST_SetSRID(ST_MakePoint(${patch.lng}, ${patch.lat}), 4326)`) - } - if (patch.address !== undefined) sets.push(sql`address = ${patch.address}`) - if (patch.addressSource !== undefined) { - sets.push(sql`address_source = ${patch.addressSource}`) - } - if (patch.bring !== undefined) { - sets.push(sql`bring = ${patch.bring as unknown as string[] | null}`) - } - if (patch.jurisdictionGeoid !== undefined) { - sets.push(sql`jurisdiction_geoid = ${patch.jurisdictionGeoid}`) - } - - if (sets.length === 0) { + const setList = cleanupSetList(sql, patch) + if (setList === null) { const rows = await sql<{ id: string }[]>`SELECT id FROM cleanups WHERE id = ${id} LIMIT 1` return rows.length > 0 } - const setList = sets.reduce((acc, frag, i) => (i === 0 ? frag : sql`${acc}, ${frag}`)) - return sql.begin(async (tx) => { - const updated = await tx<{ id: string }[]>` - UPDATE cleanups SET ${setList} WHERE id = ${id} RETURNING id - ` - if (updated.length === 0) return false - await claimEventMediaInTx(tx, id, patch, actorUserId) - return true - }) + return sql.begin((tx) => updateCleanupInTx(tx, id, setList, patch, actorUserId)) + }, + + async updateCleanupWithEdits( + id: string, + patch: UpdateCleanupPatch, + edits: CleanupEdits, + ): Promise { + try { + return await sql.begin(async (tx): Promise => { + // NO KEY UPDATE is the lock the UPDATE below takes anyway; taking it first makes the + // cancelled and ended checks hold until commit, and it still waits for the FOR SHARE a + // concurrent join or slot claim holds. It is taken before any slot row, the same order + // a slot claim uses. + const locked = await tx< + { status: CleanupStatus; scheduled_at: Date; ends_at: Date | null; now: Date }[] + >` + SELECT status, scheduled_at, ends_at, now() AS now + FROM cleanups + WHERE id = ${id} LIMIT 1 FOR NO KEY UPDATE + ` + const cleanup = locked[0] + if (cleanup === undefined) return { kind: "not_found" } + if (cleanup.status === "cancelled") return { kind: "cancelled" } + if ( + edits.refusalOnceEnded !== null && + hasEventEnded(eventWindowOfRow(cleanup), cleanup.now.getTime()) + ) { + throw edits.refusalOnceEnded + } + const setList = cleanupSetList(tx, patch) + if (setList !== null) { + await updateCleanupInTx(tx, id, setList, patch, edits.actorUserId) + } + if (edits.links !== null) { + await reconcileLinkedReportsInTx(tx, id, edits.links, edits.actorUserId) + } + const slotDiff = + edits.slots === null + ? null + : await reconcileSlotsInTx(tx, id, edits.slots, edits.actorUserId) + return { kind: "updated", slotDiff } + }) + } catch (err) { + throw slotConflictAsValidation(err) + } }, async linkReports( @@ -513,31 +608,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { desiredIds: string[], actorId: string | null, ): Promise<{ added: string[]; removed: string[] }> { - return sql.begin(async (tx) => { - const existing = await tx<{ report_id: string }[]>` - SELECT report_id FROM cleanup_reports WHERE cleanup_id = ${cleanupId} - ` - const have = new Set(existing.map((r) => r.report_id)) - const want = new Set(desiredIds) - const toAdd = desiredIds.filter((id) => !have.has(id)) - const droppable = [...have].filter((id) => !want.has(id)) - const visible = await selectVisibleReportIds(tx, droppable) - const toRemove = droppable.filter((id) => visible.has(id)) - - const added = await linkReportsInTx(tx, cleanupId, toAdd, actorId) - if (toRemove.length > 0) { - await tx` - DELETE FROM cleanup_reports - WHERE cleanup_id = ${cleanupId} AND report_id = ANY(${toRemove}::uuid[]) - ` - await tx` - INSERT INTO cleanup_timeline (cleanup_id, kind, note, actor_id) - SELECT ${cleanupId}, 'report_unlinked', 'Unlinked report ' || rid, ${actorId} - FROM unnest(${toRemove}::uuid[]) AS rid - ` - } - return { added, removed: toRemove } - }) + return sql.begin((tx) => reconcileLinkedReportsInTx(tx, cleanupId, desiredIds, actorId)) }, async loadLinkedReportsForCleanups( @@ -1275,125 +1346,9 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { actorId: string | null, ): Promise { try { - return await sql.begin(async (tx) => { - const existing = await tx< - { id: string; title: string; starts_at: Date | null; ends_at: Date | null }[] - >` - SELECT id, title, starts_at, ends_at FROM cleanup_slots WHERE cleanup_id = ${cleanupId} - ` - const have = new Map( - existing.map((r) => [ - r.id, - { title: r.title, startsAt: r.starts_at, endsAt: r.ends_at }, - ]), - ) - - for (const slot of desired) { - if (slot.id !== undefined && !have.has(slot.id)) { - throw AppError.validation({ slots: `unknown slot: ${slot.id}` }) - } - } - - const keep = new Set( - desired.map((s) => s.id).filter((id): id is string => id !== undefined), - ) - const toRemove = [...have.keys()].filter((id) => !keep.has(id)) - const removed: SlotReconcileResult["removed"] = [] - if (toRemove.length > 0) { - const claimants = await tx<{ slot_id: string; user_id: string }[]>` - SELECT slot_id, user_id FROM cleanup_slot_claims - WHERE cleanup_id = ${cleanupId} AND slot_id = ANY(${toRemove}::uuid[]) - ` - const bySlot = new Map() - for (const c of claimants) { - const list = bySlot.get(c.slot_id) - if (list) list.push(c.user_id) - else bySlot.set(c.slot_id, [c.user_id]) - } - await tx` - DELETE FROM cleanup_slots - WHERE cleanup_id = ${cleanupId} AND id = ANY(${toRemove}::uuid[]) - ` - for (const slotId of toRemove) { - removed.push({ - slotId, - title: have.get(slotId)?.title ?? "", - claimantUserIds: (bySlot.get(slotId) ?? []).filter((u) => u !== actorId), - }) - } - } - - const kept = desired.filter((s): s is DesiredSlot & { id: string } => s.id !== undefined) - const changed = ( - slot: DesiredSlot & { id: string }, - keyOf: (s: SlotIdentity) => string, - ): boolean => { - const before = have.get(slot.id) - return before === undefined || keyOf(before) !== keyOf(slot) - } - const rekeying = kept.filter((s) => changed(s, slotIdentityKey)).map((s) => s.id) - if (rekeying.length > 0) { - await tx` - UPDATE cleanup_slots SET title = id::text - WHERE cleanup_id = ${cleanupId} AND id = ANY(${rekeying}::uuid[]) - ` - } - - const movedIds = kept.filter((s) => changed(s, slotWindowKey)).map((s) => s.id) - const rescheduled: SlotReconcileResult["rescheduled"] = [] - if (movedIds.length > 0) { - const claimants = await tx<{ slot_id: string; user_id: string }[]>` - SELECT slot_id, user_id FROM cleanup_slot_claims - WHERE cleanup_id = ${cleanupId} AND slot_id = ANY(${movedIds}::uuid[]) - ` - const bySlot = new Map() - for (const c of claimants) { - if (c.user_id === actorId) continue - const list = bySlot.get(c.slot_id) - if (list) list.push(c.user_id) - else bySlot.set(c.slot_id, [c.user_id]) - } - for (const slot of kept) { - const userIds = bySlot.get(slot.id) - if (userIds === undefined || userIds.length === 0) continue - rescheduled.push({ slotId: slot.id, title: slot.title, claimantUserIds: userIds }) - } - } - - const added: string[] = [] - const updated: string[] = [] - for (const slot of desired) { - if (slot.id !== undefined) { - await tx` - UPDATE cleanup_slots SET - title = ${slot.title}, - description = ${slot.description}, - capacity = ${slot.capacity}, - starts_at = ${slot.startsAt}, - ends_at = ${slot.endsAt}, - sort_order = ${slot.sortOrder} - WHERE id = ${slot.id} AND cleanup_id = ${cleanupId} - ` - updated.push(slot.id) - } else { - const [row] = await tx<{ id: string }[]>` - INSERT INTO cleanup_slots (cleanup_id, title, description, capacity, starts_at, ends_at, sort_order) - VALUES ( - ${cleanupId}, ${slot.title}, ${slot.description}, ${slot.capacity}, - ${slot.startsAt}, ${slot.endsAt}, ${slot.sortOrder} - ) - RETURNING id - ` - if (row) added.push(row.id) - } - } - return { added, updated, removed, rescheduled } - }) + return await sql.begin((tx) => reconcileSlotsInTx(tx, cleanupId, desired, actorId)) } catch (err) { - if (isSlotTitleConflict(err)) { - throw AppError.validation({ slots: "duplicate slot title" }) - } - throw err + throw slotConflictAsValidation(err) } }, @@ -1524,8 +1479,8 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { j.name, (SELECT jc.email FROM jurisdiction_contacts jc WHERE jc.geoid = j.geoid AND jc.category IS NULL - AND jc.email IS NOT NULL AND jc.email <> '' LIMIT 1) AS default_email, - j.contact_emails[1] AS legacy_email + AND ${usableContactRowExpr(sql, "jc")} LIMIT 1) AS default_email, + ${firstUsableLegacyContactExpr(sql, "j")} AS legacy_email FROM jurisdictions j WHERE j.geoid = ${geoid} LIMIT 1 @@ -1664,6 +1619,121 @@ async function linkReportsInTx( return newlyLinked } +async function reconcileSlotsInTx( + tx: Queryable, + cleanupId: string, + desired: DesiredSlot[], + actorId: string | null, +): Promise { + const existing = await tx< + { id: string; title: string; starts_at: Date | null; ends_at: Date | null }[] + >` + SELECT id, title, starts_at, ends_at FROM cleanup_slots WHERE cleanup_id = ${cleanupId} + ` + const have = new Map( + existing.map((r) => [r.id, { title: r.title, startsAt: r.starts_at, endsAt: r.ends_at }]), + ) + + for (const slot of desired) { + if (slot.id !== undefined && !have.has(slot.id)) { + throw AppError.validation({ slots: `unknown slot: ${slot.id}` }) + } + } + + const keep = new Set(desired.map((s) => s.id).filter((id): id is string => id !== undefined)) + const toRemove = [...have.keys()].filter((id) => !keep.has(id)) + const removed: SlotReconcileResult["removed"] = [] + if (toRemove.length > 0) { + const claimants = await tx<{ slot_id: string; user_id: string }[]>` + SELECT slot_id, user_id FROM cleanup_slot_claims + WHERE cleanup_id = ${cleanupId} AND slot_id = ANY(${toRemove}::uuid[]) + ` + const bySlot = new Map() + for (const c of claimants) { + const list = bySlot.get(c.slot_id) + if (list) list.push(c.user_id) + else bySlot.set(c.slot_id, [c.user_id]) + } + await tx` + DELETE FROM cleanup_slots + WHERE cleanup_id = ${cleanupId} AND id = ANY(${toRemove}::uuid[]) + ` + for (const slotId of toRemove) { + removed.push({ + slotId, + title: have.get(slotId)?.title ?? "", + claimantUserIds: (bySlot.get(slotId) ?? []).filter((u) => u !== actorId), + }) + } + } + + const kept = desired.filter((s): s is DesiredSlot & { id: string } => s.id !== undefined) + const changed = ( + slot: DesiredSlot & { id: string }, + keyOf: (s: SlotIdentity) => string, + ): boolean => { + const before = have.get(slot.id) + return before === undefined || keyOf(before) !== keyOf(slot) + } + const rekeying = kept.filter((s) => changed(s, slotIdentityKey)).map((s) => s.id) + if (rekeying.length > 0) { + await tx` + UPDATE cleanup_slots SET title = id::text + WHERE cleanup_id = ${cleanupId} AND id = ANY(${rekeying}::uuid[]) + ` + } + + const movedIds = kept.filter((s) => changed(s, slotWindowKey)).map((s) => s.id) + const rescheduled: SlotReconcileResult["rescheduled"] = [] + if (movedIds.length > 0) { + const claimants = await tx<{ slot_id: string; user_id: string }[]>` + SELECT slot_id, user_id FROM cleanup_slot_claims + WHERE cleanup_id = ${cleanupId} AND slot_id = ANY(${movedIds}::uuid[]) + ` + const bySlot = new Map() + for (const c of claimants) { + if (c.user_id === actorId) continue + const list = bySlot.get(c.slot_id) + if (list) list.push(c.user_id) + else bySlot.set(c.slot_id, [c.user_id]) + } + for (const slot of kept) { + const userIds = bySlot.get(slot.id) + if (userIds === undefined || userIds.length === 0) continue + rescheduled.push({ slotId: slot.id, title: slot.title, claimantUserIds: userIds }) + } + } + + const added: string[] = [] + const updated: string[] = [] + for (const slot of desired) { + if (slot.id !== undefined) { + await tx` + UPDATE cleanup_slots SET + title = ${slot.title}, + description = ${slot.description}, + capacity = ${slot.capacity}, + starts_at = ${slot.startsAt}, + ends_at = ${slot.endsAt}, + sort_order = ${slot.sortOrder} + WHERE id = ${slot.id} AND cleanup_id = ${cleanupId} + ` + updated.push(slot.id) + } else { + const [row] = await tx<{ id: string }[]>` + INSERT INTO cleanup_slots (cleanup_id, title, description, capacity, starts_at, ends_at, sort_order) + VALUES ( + ${cleanupId}, ${slot.title}, ${slot.description}, ${slot.capacity}, + ${slot.startsAt}, ${slot.endsAt}, ${slot.sortOrder} + ) + RETURNING id + ` + if (row) added.push(row.id) + } + } + return { added, updated, removed, rescheduled } +} + async function insertSlotsInTx( tx: Queryable, cleanupId: string, diff --git a/services/api/src/services/cleanup-repository.types.ts b/services/api/src/services/cleanup-repository.types.ts index a7405ba5..7c531c05 100644 --- a/services/api/src/services/cleanup-repository.types.ts +++ b/services/api/src/services/cleanup-repository.types.ts @@ -1,4 +1,5 @@ import type { + AppError, CleanupMemberRole, CleanupStatus, CleanupType, @@ -231,6 +232,25 @@ export interface UpdateCleanupPatch extends EventHostWrite { jurisdictionGeoid?: string | null } +export interface CleanupEdits { + actorUserId: string + /** The full desired link set, or null to leave the links as they are. */ + links: string[] | null + /** The full desired slot board, or null to leave the slots as they are. */ + slots: DesiredSlot[] | null + /** + * Thrown when the locked row shows the event has ended, because the service's own ended check ran + * on a read that a concurrent reschedule or the clock may have overtaken. Null when this edit is + * still allowed after the end. + */ + refusalOnceEnded: AppError | null +} + +export type CleanupEditOutcome = + | { kind: "updated"; slotDiff: SlotReconcileResult | null } + | { kind: "not_found" } + | { kind: "cancelled" } + export type JoinCleanupOutcome = "joined" | "not_found" | "banned" | "closed" | "ended" export type LeaveCleanupOutcome = "left" | "not_found" | "closed" @@ -284,6 +304,12 @@ export interface SignupSeat { export interface CleanupRepository { createCleanupTx(args: CreateCleanupTxArgs): Promise updateCleanup(id: string, patch: UpdateCleanupPatch, actorUserId: string): Promise + /** The scalar patch, the link reconcile and the slot reconcile, committed together or not at all. */ + updateCleanupWithEdits( + id: string, + patch: UpdateCleanupPatch, + edits: CleanupEdits, + ): Promise linkReports(cleanupId: string, reportIds: string[], actorId: string | null): Promise unlinkReport(cleanupId: string, reportId: string, actorId: string | null): Promise reconcileLinkedReports( diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index cad9c19d..9716ff9c 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -130,6 +130,12 @@ export type HostEventPatch = Pick< | "hostReplyTo" > & { scheduledAt?: string } +// `joined` means an RSVP (a cleanup_members row, which the organizer always has). Org standing +// grants host powers and visibility, not attendance: clients key Join/Leave off this flag. +function isAttending(standing: HostStanding): boolean { + return standing.eventRole !== null +} + function toDateOrNull(value: string | null | undefined): Date | null { if (value === null || value === undefined) return null return new Date(value) @@ -163,6 +169,37 @@ const EVENT_CLOSED_MESSAGE = "This event is closed." */ const MIN_EVENT_ADDRESS_LENGTH = 3 +const CANCELLED_EVENT_EDIT_MESSAGE = "This event has been cancelled and can no longer be edited." + +function refusalOnceEnded( + patch: UpdateCleanupPatchRequest, + current: CleanupRecord, +): AppError | null { + const frozen = + patch.title !== undefined || + patch.scheduledAt !== undefined || + patch.lat !== undefined || + patch.lng !== undefined || + patch.type !== undefined || + patch.eventKind !== undefined + if (frozen) { + return AppError.conflict( + "An event that has ended can't change its date, title, location or type.", + ) + } + if ( + patch.endsAt !== undefined && + patch.endsAt !== null && + new Date(patch.endsAt).getTime() !== current.endsAt.getTime() + ) { + return AppError.conflict("An event that has ended can't change its end time.") + } + if (patch.slots !== undefined) { + return AppError.validation({ slots: "Slots can't be changed after an event has ended." }) + } + return null +} + function assertScheduledAtNotBackdated(next: string | undefined, stored: Date): void { if (next === undefined) return const nextMs = Date.parse(next) @@ -1053,7 +1090,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(record, { gallery: true }), ]) return enrichOne( - toCleanupDTO(record, standing.eventRole !== null, linkedReports, standing.eventRole, { + toCleanupDTO(record, isAttending(standing), linkedReports, standing.eventRole, { slots: slotBoard, myCapabilities: capabilityList(standing), ...media, @@ -1154,23 +1191,11 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { } const requesterRole = standing.eventRole if (current.status === "cancelled") { - throw AppError.conflict("This event has been cancelled and can no longer be edited.") + throw AppError.conflict(CANCELLED_EVENT_EDIT_MESSAGE) } const hasEnded = deriveCleanupStatus(eventWindowOf(current), Date.now()) === "done" - if (hasEnded) { - const frozen = - patch.title !== undefined || - patch.scheduledAt !== undefined || - patch.lat !== undefined || - patch.lng !== undefined || - patch.type !== undefined || - patch.eventKind !== undefined - if (frozen) { - throw AppError.conflict( - "An event that has ended can't change its date, title, location or type.", - ) - } - } + const endedRefusal = refusalOnceEnded(patch, current) + if (hasEnded && endedRefusal !== null) throw endedRefusal assertScheduledAtNotBackdated(patch.scheduledAt, current.scheduledAt) const effectiveKind = patch.eventKind ?? current.eventKind @@ -1191,13 +1216,6 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { if (patch.endsAt === null) { throw AppError.validation({ endsAt: "an event must have an end time" }) } - if ( - hasEnded && - patch.endsAt !== undefined && - new Date(patch.endsAt).getTime() !== current.endsAt.getTime() - ) { - throw AppError.conflict("An event that has ended can't change its end time.") - } const effectiveWindow: EventWindow = { status: current.status, scheduledAt: @@ -1208,9 +1226,6 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { effectiveWindow.scheduledAt.getTime() !== current.scheduledAt.getTime() || (effectiveWindow.endsAt?.getTime() ?? null) !== (current.endsAt?.getTime() ?? null) - if (patch.slots !== undefined && hasEnded) { - throw AppError.validation({ slots: "Slots can't be changed after an event has ended." }) - } if (patch.slots !== undefined && patch.slots.length === 0) { throw AppError.validation({ slots: EVENT_NEEDS_A_SLOT_MESSAGE }) } @@ -1272,22 +1287,20 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { ...(patch.eventKind !== undefined ? { eventKind: patch.eventKind } : {}), ...(patch.type !== undefined ? { type: patch.type } : {}), ...(patch.scheduledAt !== undefined ? { scheduledAt: new Date(patch.scheduledAt) } : {}), - ...(patch.lat !== undefined ? { lat: patch.lat } : {}), - ...(patch.lng !== undefined ? { lng: patch.lng } : {}), + ...(movedTo ?? {}), ...addressPatch, ...(patch.bring !== undefined ? { bring: patch.bring } : {}), ...(reresolvedGeoid !== undefined ? { jurisdictionGeoid: reresolvedGeoid } : {}), } - const updated = await deps.repo.updateCleanup(id, scalarPatch, requesterUserId) - if (!updated) notFoundCleanup() - - if (desiredLinks !== null) { - await deps.repo.reconcileLinkedReports(id, desiredLinks, requesterUserId) - } - const slotDiff = - desiredSlots !== null - ? await deps.repo.reconcileSlots(id, desiredSlots, requesterUserId) - : null + const outcome = await deps.repo.updateCleanupWithEdits(id, scalarPatch, { + actorUserId: requesterUserId, + links: desiredLinks, + slots: desiredSlots, + refusalOnceEnded: endedRefusal, + }) + if (outcome.kind === "not_found") notFoundCleanup() + if (outcome.kind === "cancelled") throw AppError.conflict(CANCELLED_EVENT_EDIT_MESSAGE) + const { slotDiff } = outcome const record = await deps.repo.findCleanupById(id, null) if (!record) notFoundCleanup() @@ -1306,7 +1319,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(record, { gallery: true }), ]) return enrichOne( - toCleanupDTO(record, standing.eventRole !== null, linkedReports, requesterRole, { + toCleanupDTO(record, isAttending(standing), linkedReports, requesterRole, { slots: slotBoard, myCapabilities: capabilityList(standing), ...media, @@ -1350,11 +1363,17 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(record, { gallery: true }), ]) return enrichOne( - toCleanupDTO(record, true, linkedReports, standing.eventRole ?? "organizer", { - slots: slotBoard, - myCapabilities: capabilityList(standing), - ...media, - }), + toCleanupDTO( + record, + isAttending(standing), + linkedReports, + standing.eventRole ?? "organizer", + { + slots: slotBoard, + myCapabilities: capabilityList(standing), + ...media, + }, + ), requesterUserId, ) }, @@ -1379,7 +1398,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(record, { gallery: true }), ]) return enrichOne( - toCleanupDTO(record, standing.eventRole !== null, linkedReports, requesterRole, { + toCleanupDTO(record, isAttending(standing), linkedReports, requesterRole, { slots: slotBoard, myCapabilities: capabilityList(standing), ...media, @@ -1416,7 +1435,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { const standing = standingsById.get(record.id) ?? NO_HOST_STANDING return toCleanupDTO( record, - hasHostStanding(standing), + isAttending(standing), linkedByCleanup.get(record.id) ?? [], standing.eventRole, { @@ -1460,7 +1479,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { const standing = standingsById.get(record.id) ?? NO_HOST_STANDING return toCleanupDTO( record, - hasHostStanding(standing), + isAttending(standing), linkedByCleanup.get(record.id) ?? [], standing.eventRole, { @@ -1488,7 +1507,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(record, { gallery: true }), ]) return enrichOne( - toCleanupDTO(record, hasHostStanding(standing), linkedReports, standing.eventRole, { + toCleanupDTO(record, isAttending(standing), linkedReports, standing.eventRole, { slots: slotBoard, myCapabilities: capabilityList(standing), ...media, @@ -1697,17 +1716,11 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(updated, { gallery: true }), ]) return enrichOne( - toCleanupDTO( - updated, - hasHostStanding(nextStanding), - linkedReports, - nextStanding.eventRole, - { - slots: slotBoard, - myCapabilities: capabilityList(nextStanding), - ...media, - }, - ), + toCleanupDTO(updated, isAttending(nextStanding), linkedReports, nextStanding.eventRole, { + slots: slotBoard, + myCapabilities: capabilityList(nextStanding), + ...media, + }), userId, ) }, @@ -1809,8 +1822,10 @@ function guestVisibleChange( if (!Number.isNaN(next) && next !== current.scheduledAt.getTime()) return true } if (patch.address !== undefined && (patch.address ?? null) !== current.address) return true - if (patch.lat !== undefined && patch.lat !== current.lat) return true - if (patch.lng !== undefined && patch.lng !== current.lng) return true + // The contract lets lat and lng arrive alone, but the location only moves when both do. + if (patch.lat !== undefined && patch.lng !== undefined) { + if (patch.lat !== current.lat || patch.lng !== current.lng) return true + } return false } diff --git a/services/api/src/services/conversation-mutes-repository.drizzle.ts b/services/api/src/services/conversation-mutes-repository.drizzle.ts index d7b0cc21..9f755bfb 100644 --- a/services/api/src/services/conversation-mutes-repository.drizzle.ts +++ b/services/api/src/services/conversation-mutes-repository.drizzle.ts @@ -10,6 +10,7 @@ * report-chat-repository.drizzle.ts). */ +import type { FastifyBaseLogger } from "fastify" import type { Sql } from "../db/client.js" import type { ConversationMuteRoomKind } from "../db/schema/conversation_mutes.js" @@ -51,6 +52,28 @@ export interface ConversationMutesRepository { ): Promise> } +export type FailOpenMuteCheck = ( + userId: string, + roomKind: ConversationMuteRoomKind, + roomId: string, +) => Promise + +export function makeFailOpenMuteCheck( + repo: Pick | undefined, + logger?: Pick, +): FailOpenMuteCheck { + return async (userId, roomKind, roomId) => { + if (!repo) return false + try { + return await repo.isMuted(userId, roomKind, roomId) + } catch (err) { + // A mute is a comfort setting: a lookup outage must not silence the room for everyone. + logger?.warn({ err, kind: roomKind }, "conversation mute lookup failed; notifying anyway") + return false + } + } +} + export function makeConversationMutesRepository(sql: Sql): ConversationMutesRepository { return { async isMuted( diff --git a/services/api/src/services/data-export-jobs.ts b/services/api/src/services/data-export-jobs.ts index 2cd2eeed..fffb9df5 100644 --- a/services/api/src/services/data-export-jobs.ts +++ b/services/api/src/services/data-export-jobs.ts @@ -1,7 +1,9 @@ import { AppError, ErrorCode } from "@civfix/shared" +import { mailFailureKind } from "../adapters/mail-failure.js" import type { Env } from "../env.js" import type { Container } from "../di.js" import { makeDataExportService, type DataExportService } from "./data-export-service.js" +import { isFinalJobAttempt } from "./job-attempt.js" export const DATA_EXPORT_JOB = "data.export" @@ -31,7 +33,9 @@ export async function registerDataExportJobs( await container.jobs.work(DATA_EXPORT_JOB, async (job) => { const userId = extractUserId(job.data) if (userId === null) return - await runDataExport(make(container), userId, opts?.logger) + await runDataExport(make(container), userId, opts?.logger, { + finalAttempt: isFinalJobAttempt(job), + }) }) } @@ -39,18 +43,32 @@ export async function runDataExport( service: DataExportService, userId: string, logger?: DataExportJobLogger, + attempt: { finalAttempt: boolean } = { finalAttempt: false }, ): Promise { try { const result = await service.exportData(userId) if (result.email === null) { logger?.info({ userId }, "data.export skip: no delivery channel") } + if (result.undeliverable !== undefined) { + logger?.warn( + { userId, reason: result.undeliverable }, + "data.export undeliverable (recorded for an operator)", + ) + } } catch (err) { - if (isTransientInfraError(err)) { + if (!isTransientInfraError(err)) { + logger?.warn({ err, userId }, "data.export failed (completing job)") + return + } + if (!attempt.finalAttempt) { logger?.warn({ err, userId }, "data.export transient failure (retrying)") throw err } - logger?.warn({ err, userId }, "data.export failed (completing job)") + // An access request must never end with no trail: once the retries are spent, leave it on record for + // an operator to fulfil by hand. If even that write fails, the job fails loudly instead. + await service.recordUndeliverable(userId, "rejected") + logger?.warn({ err, userId }, "data.export retries exhausted (recorded for an operator)") } } @@ -64,6 +82,9 @@ function makeContainerDataExportService(container: Container): DataExportService } function isTransientInfraError(err: unknown): boolean { + // A sender or credential rejection is a platform config fault, not something wrong with this export; + // completing on it would silently discard every export requested while the fault lasts. + if (mailFailureKind(err) === "auth") return true if (err instanceof AppError) { return err.code === ErrorCode.INTERNAL || err.code === ErrorCode.RATE_LIMITED } diff --git a/services/api/src/services/data-export-service.ts b/services/api/src/services/data-export-service.ts index a6354d72..4c43a627 100644 --- a/services/api/src/services/data-export-service.ts +++ b/services/api/src/services/data-export-service.ts @@ -1,8 +1,11 @@ +import { AppError, ErrorCode } from "@civfix/shared" import type { Sql } from "../db/client.js" import type { Mailer } from "@civfix/shared/interfaces" import type { UserStore } from "../auth/stores.js" import { heading, paragraph } from "../adapters/email-blocks.js" import { renderEmailBody } from "../adapters/email-layout.js" +import { mailFailure } from "../adapters/mail-failure.js" +import { writeAudit } from "./admin/audit.js" export interface DataExportServiceDeps { sql: Sql @@ -12,8 +15,17 @@ export interface DataExportServiceDeps { supportEmail: string } +/** + * Why a built export never reached the user: the provider refused its size, refused the address, or + * refused the message itself (or kept refusing it until the retries ran out). + */ +export type DataExportUndeliverable = "oversize" | "permanent" | "rejected" + export interface DataExportService { - exportData(userId: string): Promise<{ ok: true; email: string | null }> + exportData( + userId: string, + ): Promise<{ ok: true; email: string | null; undeliverable?: DataExportUndeliverable }> + recordUndeliverable(userId: string, kind: DataExportUndeliverable): Promise } export const DATA_EXPORT_MAX_ROWS = 50_000 @@ -54,11 +66,83 @@ export function buildDataExportEmail( return { subject, text, html } } +/** + * The JSON key order of the export's sections. Truncation is reported in this order even though the byte + * budget is spent on the small structured sections first. + */ +const DATA_EXPORT_SECTION_ORDER = [ + "reports", + "posts", + "comments", + "chatMessages", + "dmMessages", + "volunteerHours", + "cleanupsOrganized", + "cleanupsJoined", + "following", + "followers", + "blocks", + "pushTokens", + "certificates", + "organizations", + "eventTeamMemberships", + "eventConsents", + "eventRegistrations", + "eventAnswers", + "eventCheckins", +] as const + +export function buildDataExportUndeliverableEmail(supportEmail: string): { + subject: string + text: string + html: string +} { + const subject = "Your civfix data export" + const blocks = [ + heading("Your civfix data export"), + paragraph( + "Your data export was too large to send by email. Your request is on record, and our team " + + `will send you a complete copy. You can also email ${supportEmail} about it.`, + ), + paragraph("If you did not request this, you can ignore this email.", { muted: true }), + ] + const { text, html } = renderEmailBody({ preheader: subject, blocks }) + return { subject, text, html } +} + export function makeDataExportService(deps: DataExportServiceDeps): DataExportService { const { sql, mailer, users, fromNoReply, supportEmail } = deps + /** + * The operator-visible trail for an export that has to be fulfilled by hand. Written before any notice + * so the request is on record even if the notice fails. Carries no address or export content. + */ + async function recordUndeliverable(userId: string, kind: DataExportUndeliverable): Promise { + await writeAudit(sql, { + actorId: null, + action: "data_export.undeliverable", + target: `user:${userId}`, + meta: { reason: kind }, + }) + } + + async function sendUndeliverableNotice(to: string): Promise { + const notice = buildDataExportUndeliverableEmail(supportEmail) + try { + await mailer.sendOutbound({ from: fromNoReply, to, ...notice }) + } catch (err) { + // The request is already on record for an operator, so a failed notice completes the job rather + // than retrying, which would rebuild the export and hit the same size refusal again. + throw new AppError(ErrorCode.CONFLICT, "The data export notice could not be sent", { + cause: err, + }) + } + } + return { - async exportData(userId: string): Promise<{ ok: true; email: string | null }> { + async exportData( + userId: string, + ): Promise<{ ok: true; email: string | null; undeliverable?: DataExportUndeliverable }> { const profileRows = await sql< { id: string @@ -385,10 +469,14 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe const email = profile?.email ?? (users ? ((await users.findById(userId))?.email ?? null) : null) - const truncatedSections: string[] = [] + const truncatedCaps = new Map() let usedBytes = 0 - const fit = (name: string, rows: T[], rowCap: number): T[] => { + const fit = ( + name: (typeof DATA_EXPORT_SECTION_ORDER)[number], + rows: T[], + rowCap: number, + ): T[] => { let truncated = false let source = rows if (source.length > rowCap) { @@ -405,54 +493,90 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe usedBytes += size kept.push(row) } - if (truncated) truncatedSections.push(name) + if (truncated) truncatedCaps.set(name, rowCap) return kept } + // The budget is spent on the small structured sections first, so one heavy free-text history + // (thousands of chat messages) cannot crowd out a user's certificates, consents or registrations. + const pushTokensFit = fit( + "pushTokens", + pushTokenRows.map((t) => ({ + id: t.id, + platform: t.platform, + token: "[REDACTED]", + createdAt: t.created_at, + revokedAt: t.revoked_at, + })), + DATA_EXPORT_MAX_ROWS, + ) + const certificatesFit = fit("certificates", certificateRows, DATA_EXPORT_MAX_ROWS) + const organizationsFit = fit("organizations", organizationRows, DATA_EXPORT_MAX_ROWS) + const eventConsentsFit = fit("eventConsents", eventConsentRows, DATA_EXPORT_MAX_ROWS) + const eventRegistrationsFit = fit( + "eventRegistrations", + eventRegistrationRows, + DATA_EXPORT_MAX_ROWS, + ) + const eventCheckinsFit = fit("eventCheckins", eventCheckinRows, DATA_EXPORT_MAX_ROWS) + const eventTeamMembershipsFit = fit( + "eventTeamMemberships", + eventTeamMembershipRows, + DATA_EXPORT_MAX_ROWS, + ) + const volunteerHoursFit = fit("volunteerHours", volunteerHourRows, DATA_EXPORT_MAX_ROWS) + const cleanupsOrganizedFit = fit( + "cleanupsOrganized", + cleanupsOrganizedRows, + DATA_EXPORT_MAX_ROWS, + ) + const cleanupsJoinedFit = fit("cleanupsJoined", cleanupsJoinedRows, DATA_EXPORT_MAX_ROWS) + const followingFit = fit("following", followingRows, DATA_EXPORT_MAX_ROWS) + const followersFit = fit("followers", followerRows, DATA_EXPORT_MAX_ROWS) + const blocksFit = fit("blocks", blockRows, DATA_EXPORT_MAX_ROWS) + const reportsFit = fit("reports", reportRows, DATA_EXPORT_MAX_ROWS) + const commentsFit = fit("comments", commentRows, DATA_EXPORT_MAX_ROWS) + const eventAnswersFit = fit("eventAnswers", eventAnswerRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS) + const postsFit = fit("posts", postRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS) + const chatMessagesFit = fit("chatMessages", chatRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS) + const dmMessagesFit = fit("dmMessages", dmRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS) + + const truncatedSections = DATA_EXPORT_SECTION_ORDER.filter((name) => truncatedCaps.has(name)) + const sectionCaps = Object.fromEntries( + truncatedSections.map((name) => [name, truncatedCaps.get(name)]), + ) + const exportObject = { exportedAt: new Date().toISOString(), format: "civfix-data-export@1", userId, profile, - reports: fit("reports", reportRows, DATA_EXPORT_MAX_ROWS), - posts: fit("posts", postRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS), - comments: fit("comments", commentRows, DATA_EXPORT_MAX_ROWS), - chatMessages: fit("chatMessages", chatRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS), - dmMessages: fit("dmMessages", dmRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS), - volunteerHours: fit("volunteerHours", volunteerHourRows, DATA_EXPORT_MAX_ROWS), - cleanupsOrganized: fit("cleanupsOrganized", cleanupsOrganizedRows, DATA_EXPORT_MAX_ROWS), - cleanupsJoined: fit("cleanupsJoined", cleanupsJoinedRows, DATA_EXPORT_MAX_ROWS), - following: fit("following", followingRows, DATA_EXPORT_MAX_ROWS).map((f) => f.followee_id), - followers: fit("followers", followerRows, DATA_EXPORT_MAX_ROWS).map((f) => f.follower_id), - blocks: fit("blocks", blockRows, DATA_EXPORT_MAX_ROWS).map((b) => b.blocked_id), + reports: reportsFit, + posts: postsFit, + comments: commentsFit, + chatMessages: chatMessagesFit, + dmMessages: dmMessagesFit, + volunteerHours: volunteerHoursFit, + cleanupsOrganized: cleanupsOrganizedFit, + cleanupsJoined: cleanupsJoinedFit, + following: followingFit.map((f) => f.followee_id), + followers: followersFit.map((f) => f.follower_id), + blocks: blocksFit.map((b) => b.blocked_id), notificationPrefs: notificationPrefRows[0] ?? null, - pushTokens: fit( - "pushTokens", - pushTokenRows.map((t) => ({ - id: t.id, - platform: t.platform, - token: "[REDACTED]", - createdAt: t.created_at, - revokedAt: t.revoked_at, - })), - DATA_EXPORT_MAX_ROWS, - ), - certificates: fit("certificates", certificateRows, DATA_EXPORT_MAX_ROWS), - organizations: fit("organizations", organizationRows, DATA_EXPORT_MAX_ROWS), - eventTeamMemberships: fit( - "eventTeamMemberships", - eventTeamMembershipRows, - DATA_EXPORT_MAX_ROWS, - ), - eventConsents: fit("eventConsents", eventConsentRows, DATA_EXPORT_MAX_ROWS), - eventRegistrations: fit("eventRegistrations", eventRegistrationRows, DATA_EXPORT_MAX_ROWS), - eventAnswers: fit("eventAnswers", eventAnswerRows, DATA_EXPORT_FREE_TEXT_MAX_ROWS), - eventCheckins: fit("eventCheckins", eventCheckinRows, DATA_EXPORT_MAX_ROWS), + pushTokens: pushTokensFit, + certificates: certificatesFit, + organizations: organizationsFit, + eventTeamMemberships: eventTeamMembershipsFit, + eventConsents: eventConsentsFit, + eventRegistrations: eventRegistrationsFit, + eventAnswers: eventAnswersFit, + eventCheckins: eventCheckinsFit, truncated: truncatedSections.length > 0 ? { sections: truncatedSections, capPerSection: DATA_EXPORT_MAX_ROWS, + sectionCaps, byteBudget: DATA_EXPORT_BYTE_BUDGET, note: `These sections were clipped because this export reached its per-section or overall size limit. Email ${supportEmail} to request a complete copy of the truncated sections.`, } @@ -465,22 +589,53 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe const rendered = buildDataExportEmail(supportEmail, truncatedSections) - await mailer.sendOutbound({ - from: fromNoReply, - to: email, - subject: rendered.subject, - text: rendered.text, - html: rendered.html, - attachments: [ - { - filename: "civfix-export.json", - contentType: "application/json", - content: bytes, - }, - ], - }) + try { + await mailer.sendOutbound({ + from: fromNoReply, + to: email, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + attachments: [ + { + filename: "civfix-export.json", + contentType: "application/json", + content: bytes, + }, + ], + }) + } catch (err) { + const kind = undeliverableKind(err) + if (kind === null) throw err + await recordUndeliverable(userId, kind) + // A rejected recipient would bounce a notice too; only a size refusal can still reach the user. + if (kind === "oversize") await sendUndeliverableNotice(email) + return { ok: true, email, undeliverable: kind } + } return { ok: true, email } }, + + recordUndeliverable, } } + +const SMTP_PERMANENT_MIN = 500 + +// A 5xx the provider sends in answer to the message body refuses this message, not our credentials or +// sender, so rebuilding and resending the same export can only be refused again. +function isMessageRejection(failure: ReturnType): boolean { + return ( + failure.code === "EMESSAGE" && + failure.command === "DATA" && + failure.responseCode !== undefined && + failure.responseCode >= SMTP_PERMANENT_MIN + ) +} + +function undeliverableKind(err: unknown): DataExportUndeliverable | null { + const failure = mailFailure(err) + if (failure.kind === "oversize" || failure.kind === "permanent") return failure.kind + if (isMessageRejection(failure)) return "rejected" + return null +} diff --git a/services/api/src/services/discussion-repository.drizzle.ts b/services/api/src/services/discussion-repository.drizzle.ts index 0a3ed469..84c15668 100644 --- a/services/api/src/services/discussion-repository.drizzle.ts +++ b/services/api/src/services/discussion-repository.drizzle.ts @@ -7,6 +7,7 @@ */ import type { Sql } from "../db/client.js" +import { firstUsableLegacyContactExpr, usableContactRowExpr } from "./admin/sql-fragments.js" import type { DiscussionReportView, DiscussionRepository, @@ -17,8 +18,8 @@ import type { ReportCategory } from "@civfix/shared" export function makeDrizzleDiscussionRepository(sql: Sql): DiscussionRepository { return { async findReportForDiscussion(reportId: string): Promise { - // Report visibility handle + its resolved jurisdiction + first usable contact email, using the SAME - // contact precedence as admin getRouting: category-specific -> default -> legacy[1]. + // Same contact precedence and bounce rule as admin getRouting, so a city forward never goes to an + // address the admin screen already reports as unusable. const rows = await sql< { id: string @@ -49,11 +50,11 @@ export function makeDrizzleDiscussionRepository(sql: Sql): DiscussionRepository j.handle AS j_handle, (SELECT jc.email FROM jurisdiction_contacts jc WHERE jc.geoid = j.geoid AND jc.category = r.category - AND jc.email IS NOT NULL AND jc.email <> '' LIMIT 1) AS cat_email, + AND ${usableContactRowExpr(sql, "jc")} LIMIT 1) AS cat_email, (SELECT jc.email FROM jurisdiction_contacts jc WHERE jc.geoid = j.geoid AND jc.category IS NULL - AND jc.email IS NOT NULL AND jc.email <> '' LIMIT 1) AS default_email, - j.contact_emails[1] AS legacy_email + AND ${usableContactRowExpr(sql, "jc")} LIMIT 1) AS default_email, + ${firstUsableLegacyContactExpr(sql, "j")} AS legacy_email FROM reports r LEFT JOIN jurisdictions j ON j.geoid = r.jurisdiction_geoid WHERE r.id = ${reportId} diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index 3b3edff7..23572daa 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -30,6 +30,10 @@ import { } from "./chat-room-scope.drizzle.js" import { liveMessageIds, toTombstoneDTO } from "./chat-tombstone.js" +// dm_messages is range-partitioned on created_at and an ack carries only the message id, so the bound +// lets the planner prune the lookup to recent partitions instead of probing every month ever created. +export const DM_ACK_LOOKUP_WINDOW_DAYS = 90 + export interface DmThread { id: string userLo: string @@ -546,7 +550,8 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep async resolveMessageCreatedAt(threadId: string, messageId: string): Promise { const rows = await sql<{ created_at: Date }[]>` SELECT created_at FROM dm_messages - WHERE id = ${messageId} AND thread_id = ${threadId} AND created_at >= now() - interval '90 days' + WHERE id = ${messageId} AND thread_id = ${threadId} + AND created_at >= now() - make_interval(days => ${DM_ACK_LOOKUP_WINDOW_DAYS}) LIMIT 1 ` return rows[0]?.created_at ?? null diff --git a/services/api/src/services/dm-service.ts b/services/api/src/services/dm-service.ts index 92bfd90d..6d1a118f 100644 --- a/services/api/src/services/dm-service.ts +++ b/services/api/src/services/dm-service.ts @@ -71,6 +71,7 @@ export function makeDmService(deps: DmServiceDeps): DmService { const thread = existing ?? (await deps.dm.openOrCreateThread(viewerId, targetUserId)) + // The mute flag and unread badge are display hints: their lookup failing must not block opening a DM. const [page, muted, unread] = await Promise.all([ deps.dm.history(thread.id, undefined, 1), deps.isMutedFor diff --git a/services/api/src/services/group-chat-notifier.ts b/services/api/src/services/group-chat-notifier.ts index c845dc6f..ec358c1f 100644 --- a/services/api/src/services/group-chat-notifier.ts +++ b/services/api/src/services/group-chat-notifier.ts @@ -1,10 +1,11 @@ import type { ChatMessageDTO } from "@civfix/shared" +import type { FastifyBaseLogger } from "fastify" import type { NotificationService } from "./notification-service.js" -import { makeRoomFanoutNotifier } from "./chat-room-fanout-notifier.js" +import { makeRoomFanoutNotifier, ROOM_FANOUT_SPEC } from "./chat-room-fanout-notifier.js" export interface GroupChatNotifierDeps { - notificationService: Pick - groupRepo: { listMemberIds(groupId: string): Promise } + notificationService: Pick + groupRepo: { listMemberIds(groupId: string, limit: number): Promise } isMuted: (userId: string, roomId: string) => Promise mutedUserIdsFor?: (roomId: string, userIds: string[]) => Promise> presence?: { online(roomKey: string): Promise } @@ -15,26 +16,25 @@ export interface GroupChatNotifierDeps { now?: () => number claimWindow?: (roomId: string, windowMs: number) => Promise dispatchToJob?: (roomId: string, messageId: string) => Promise + logger?: Pick | undefined } export function makeGroupChatNotifier( deps: GroupChatNotifierDeps, ): (groupId: string, message: ChatMessageDTO) => Promise { - return makeRoomFanoutNotifier( - { kind: "group", titleFallbackKey: "notification.group_chat.title_fallback" }, - { - notificationService: deps.notificationService, - listMemberIds: (groupId) => deps.groupRepo.listMemberIds(groupId), - isMuted: deps.isMuted, - ...(deps.mutedUserIdsFor ? { mutedUserIdsFor: deps.mutedUserIdsFor } : {}), - presence: deps.presence, - roomKey: (groupId) => deps.roomKeyFor("group", groupId), - isBlockedEitherWay: deps.isBlockedEitherWay, - ...(deps.blockedIdsFor ? { blockedIdsFor: deps.blockedIdsFor } : {}), - ...(deps.coalesceWindowMs !== undefined ? { coalesceWindowMs: deps.coalesceWindowMs } : {}), - ...(deps.now !== undefined ? { now: deps.now } : {}), - ...(deps.claimWindow !== undefined ? { claimWindow: deps.claimWindow } : {}), - ...(deps.dispatchToJob !== undefined ? { dispatchToJob: deps.dispatchToJob } : {}), - }, - ) + return makeRoomFanoutNotifier(ROOM_FANOUT_SPEC.group, { + notificationService: deps.notificationService, + listMemberIds: (groupId, limit) => deps.groupRepo.listMemberIds(groupId, limit), + isMuted: deps.isMuted, + ...(deps.mutedUserIdsFor ? { mutedUserIdsFor: deps.mutedUserIdsFor } : {}), + presence: deps.presence, + roomKey: (groupId) => deps.roomKeyFor("group", groupId), + isBlockedEitherWay: deps.isBlockedEitherWay, + ...(deps.blockedIdsFor ? { blockedIdsFor: deps.blockedIdsFor } : {}), + ...(deps.coalesceWindowMs !== undefined ? { coalesceWindowMs: deps.coalesceWindowMs } : {}), + ...(deps.now !== undefined ? { now: deps.now } : {}), + ...(deps.claimWindow !== undefined ? { claimWindow: deps.claimWindow } : {}), + ...(deps.dispatchToJob !== undefined ? { dispatchToJob: deps.dispatchToJob } : {}), + ...(deps.logger !== undefined ? { logger: deps.logger } : {}), + }) } diff --git a/services/api/src/services/guest-rsvp-repository.drizzle.ts b/services/api/src/services/guest-rsvp-repository.drizzle.ts index d4b2c181..fdfd76b2 100644 --- a/services/api/src/services/guest-rsvp-repository.drizzle.ts +++ b/services/api/src/services/guest-rsvp-repository.drizzle.ts @@ -1,6 +1,11 @@ import type { CleanupStatus, EventVisibility, GuestContactChannel } from "@civfix/shared" import type { Sql } from "../db/client.js" -import { encodeTimeCursor, pageWith, type TimeCursor } from "../db/cursor-helpers.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + type KeysetCursor, +} from "../db/cursor-helpers.js" import type { GuestEventView, GuestNoticeTarget, @@ -187,8 +192,8 @@ export function makeDrizzleGuestRsvpRepository(sql: Sql): GuestRsvpRepository { return rows.length > 0 }, - async upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string }> { - const rows = await sql<{ id: string }[]>` + async upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string; created: boolean }> { + const rows = await sql<{ id: string; created: boolean }[]>` INSERT INTO cleanup_guests ( cleanup_id, name, channel, email, phone, contact_key, manage_token_hash, verified_at ) VALUES ( @@ -204,11 +209,11 @@ export function makeDrizzleGuestRsvpRepository(sql: Sql): GuestRsvpRepository { manage_token_hash = EXCLUDED.manage_token_hash, verified_at = EXCLUDED.verified_at, contact_scrubbed_at = NULL - RETURNING id + RETURNING id, (xmax = 0) AS created ` const row = rows[0] if (row === undefined) throw new Error("guest rsvp: upsert returned no row") - return { id: row.id } + return { id: row.id, created: row.created } }, async findGuestByManageTokenHash( @@ -316,24 +321,26 @@ export function makeDrizzleGuestRsvpRepository(sql: Sql): GuestRsvpRepository { async listGuests(args: { cleanupId: string - cursor: TimeCursor | null + cursor: KeysetCursor | null limit: number }): Promise<{ rows: GuestRosterRow[]; nextCursor: string | null }> { const cursorFilter = args.cursor !== null - ? sql`AND (created_at, id) < (${args.cursor.at}, ${args.cursor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, args.cursor)}` : sql`` - const rows = await sql` - SELECT id, name, channel, email, phone, verified_at, cancelled_at, created_at + const rows = await sql<(GuestRowSelect & { cursor_at: string })[]>` + SELECT id, name, channel, email, phone, verified_at, cancelled_at, created_at, + ${keysetInstant(sql, sql`created_at`)} AS cursor_at FROM cleanup_guests WHERE cleanup_id = ${args.cleanupId} ${cursorFilter} ORDER BY created_at DESC, id DESC LIMIT ${args.limit + 1} ` - const { items, nextCursor } = pageWith(rows, args.limit, (last) => - encodeTimeCursor({ at: last.created_at, id: last.id }), - ) + const { items, nextCursor } = paginateKeyset(rows, args.limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) return { rows: items.map(toRosterRow), nextCursor } }, diff --git a/services/api/src/services/guest-rsvp-service.ts b/services/api/src/services/guest-rsvp-service.ts index 92b62f1f..89ef3ea4 100644 --- a/services/api/src/services/guest-rsvp-service.ts +++ b/services/api/src/services/guest-rsvp-service.ts @@ -17,6 +17,7 @@ import { type GuestRsvpVerifyResponse, type RegisterForEventRequest, type RegisterForEventResponse, + type TicketTypeVisibility, } from "@civfix/shared" import { GUEST_RSVP_TURNSTILE_ACTION } from "@civfix/shared/host" import type { AbuseChecks, Jobs, Mailer, SmsSender } from "@civfix/shared/interfaces" @@ -40,13 +41,14 @@ import { } from "../auth/crypto.js" import type { CacheClient } from "../auth/cache.js" import type { CounterStore } from "../abuse/counter-store.js" +import type { AdminAuditAction } from "./admin/audit.js" import { honeypotTripped } from "../abuse/honeypot.js" import { normalizeIp } from "../abuse/ip-rate-limit.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { smsFailureKind } from "../errors/sms-failure.js" import { mapWithLimit } from "./media-presign.js" import { renderMessage } from "../i18n/renderMessage.js" -import { parseTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" +import { parseKeysetCursor, type KeysetCursor } from "../db/cursor-helpers.js" import { eventEndedError, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" import { isEventPubliclyVisible } from "./host/authz.js" import { enqueueWaitlistPromotion } from "./host/waitlist-promotion.js" @@ -99,12 +101,26 @@ export interface GuestRegistrationFields { consent?: RegisterForEventRequest["consent"] } +export interface GuestTicketTypeGate { + id: string + visibility: TicketTypeVisibility + salesOpensAt: Date | null + salesClosesAt: Date | null +} + +export interface GuestRegistrationGate { + registrationOpensAt: Date | null + registrationClosesAt: Date | null + ticketTypes: GuestTicketTypeGate[] +} + export interface GuestRegistrationBridge { register( input: RegisterForEventRequest, subject: { kind: "guest"; guestId: string }, ): Promise assertInputValid?(cleanupId: string, fields: GuestRegistrationFields): Promise + registrationGate?(cleanupId: string): Promise } export interface GuestOtpRecord { @@ -192,7 +208,8 @@ export interface GuestRsvpRepository { findLatestActiveOtp(cleanupId: string, contact: string, now: Date): Promise incrementOtpAttempts(otpId: string): Promise markOtpConsumed(otpId: string, now: Date): Promise - upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string }> + /** `created` is true when this call inserted the row rather than re-verifying an active one. */ + upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string; created: boolean }> findGuestByManageTokenHash( hash: string, ): Promise<{ id: string; cleanupId: string; cancelledAt: Date | null } | null> @@ -200,7 +217,7 @@ export interface GuestRsvpRepository { cancelGuest(guestId: string, now: Date): Promise listGuests(args: { cleanupId: string - cursor: TimeCursor | null + cursor: KeysetCursor | null limit: number }): Promise<{ rows: GuestRosterRow[]; nextCursor: string | null }> listContactableGuests(cleanupId: string, limit: number): Promise @@ -226,7 +243,7 @@ export interface GuestRsvpServiceDeps { jobs?: Jobs audit?: (input: { actorId: string | null - action: string + action: AdminAuditAction target: string meta?: Record }) => Promise @@ -290,6 +307,152 @@ function eventClosedError(): AppError { return AppError.conflict("This event is closed.") } +const RETRY_WITH_NEW_CODE = "Check your details, then request a new code to try again." + +const RETRY_REQUEST = "Check your details, then try again." + +export const GUEST_REGISTRATION_ERROR_FIELD = "registration" + +export const GuestRegistrationRefusalReason = { + soldOut: "sold_out", + registrationClosed: "registration_closed", + salesClosed: "sales_closed", + eventClosed: "event_closed", + partyTooLarge: "party_too_large", + ticketTypeUnavailable: "ticket_type_unavailable", + accessCodeRequired: "access_code_required", + accessCodeInvalid: "access_code_invalid", + answersInvalid: "answers_invalid", +} as const + +export type GuestRegistrationRefusalReason = + (typeof GuestRegistrationRefusalReason)[keyof typeof GuestRegistrationRefusalReason] + +type GuestSeat = Pick< + GuestRsvpVerifyResponse, + "registration" | "registrationOutcome" | "ticketTokens" +> & { refusal: AppError | null } + +type RegisterOutcome = RegisterForEventResponse["outcome"] + +function refusedConflict(message: string, reason: GuestRegistrationRefusalReason): AppError { + return new AppError(ErrorCode.CONFLICT, message, { + fields: { [GUEST_REGISTRATION_ERROR_FIELD]: reason }, + }) +} + +function refusedInput( + fields: Record, + reason: GuestRegistrationRefusalReason, + message: string, +): AppError { + return AppError.validation({ ...fields, [GUEST_REGISTRATION_ERROR_FIELD]: reason }, message) +} + +// The clients map an error code to one generic message, so every refusal also names its reason in +// `fields`, the way the OTP and SMS refusals do, for the guest to be told what to change. +function refusalForOutcome( + outcome: RegisterOutcome, + answerFields: Record | undefined, + retryMessage: string, +): AppError | null { + switch (outcome) { + case "registered": + case "replayed": + case "already_registered": + return null + case "full": + case "waitlisted": + return refusedConflict( + "This event has no seats left.", + GuestRegistrationRefusalReason.soldOut, + ) + case "registration_closed": + return refusedConflict( + "Registration for this event is closed.", + GuestRegistrationRefusalReason.registrationClosed, + ) + case "sales_closed": + return refusedConflict( + "Ticket sales for this event are closed.", + GuestRegistrationRefusalReason.salesClosed, + ) + case "closed": + return refusedConflict("This event is closed.", GuestRegistrationRefusalReason.eventClosed) + case "party_too_large": + return refusedInput( + { partySize: "more people than seats left" }, + GuestRegistrationRefusalReason.partyTooLarge, + retryMessage, + ) + case "ticket_type_not_found": + return refusedInput( + { ticketTypeId: "that ticket type is not available" }, + GuestRegistrationRefusalReason.ticketTypeUnavailable, + retryMessage, + ) + case "access_code_required": + return refusedInput( + { accessCode: "required for this ticket type" }, + GuestRegistrationRefusalReason.accessCodeRequired, + retryMessage, + ) + case "access_code_invalid": + return refusedInput( + { accessCode: "that code is not valid for this ticket type" }, + GuestRegistrationRefusalReason.accessCodeInvalid, + retryMessage, + ) + case "answers_invalid": + return refusedInput( + answerFields ?? { answers: "invalid" }, + GuestRegistrationRefusalReason.answersInvalid, + retryMessage, + ) + // A host ban reads exactly like an unknown event, as it does for every other guest refusal. + case "banned": + case "not_found": + return AppError.notFound("Event not found") + } +} + +function registrationRefusalError(response: RegisterForEventResponse): AppError | null { + return refusalForOutcome(response.outcome, response.fields, RETRY_WITH_NEW_CODE) +} + +function withinWindow(at: Date, opensAt: Date | null, closesAt: Date | null): boolean { + if (opensAt !== null && at < opensAt) return false + if (closesAt !== null && at >= closesAt) return false + return true +} + +/** + * The refusal registration is certain to give a new guest, judged from the gates a code request can + * see. It mirrors the order of the registration transaction and answers null whenever that + * transaction could still accept, so verify stays the authority on everything else. + */ +export function foreseeableGuestRefusal( + gate: GuestRegistrationGate, + fields: GuestRegistrationFields, + at: Date, +): RegisterOutcome | null { + if (!withinWindow(at, gate.registrationOpensAt, gate.registrationClosesAt)) { + return "registration_closed" + } + const ticketType = + fields.ticketTypeId !== undefined + ? gate.ticketTypes.find((t) => t.id === fields.ticketTypeId) + : gate.ticketTypes.length === 1 + ? gate.ticketTypes[0] + : undefined + if (ticketType === undefined || ticketType.visibility === "hidden") return null + if (ticketType.visibility === "access_code" && fields.accessCode === undefined) { + return "access_code_required" + } + if (!withinWindow(at, ticketType.salesOpensAt, ticketType.salesClosesAt)) return "sales_closed" + return null +} + function invalidCodeError(): AppError { return new AppError(ErrorCode.UNAUTHORIZED, "Invalid or expired code.", { fields: { [GUEST_OTP_ERROR_FIELD]: GuestOtpErrorReason.invalidCode }, @@ -507,6 +670,32 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi await deps.registrations?.assertInputValid?.(cleanupId, fields) } + // Judged from public event state and the form alone, never from whether this contact already + // holds an RSVP: the answer must not tell a caller who is on the guest list. + async function refuseForeseeableRegistration( + cleanupId: string, + fields: GuestRegistrationFields, + ): Promise { + const bridge = deps.registrations + const readGate = bridge?.registrationGate + if (bridge === undefined || readGate === undefined) return + let outcome: RegisterOutcome | null + try { + const gate = await readGate.call(bridge, cleanupId) + if (gate === null) return + outcome = foreseeableGuestRefusal(gate, fields, new Date(now())) + if (outcome === null) return + } catch (err) { + deps.logger?.warn( + { err, cleanupId }, + "guest rsvp: registration gate lookup failed; the verify step decides", + ) + return + } + const refusal = refusalForOutcome(outcome, undefined, RETRY_REQUEST) + if (refusal !== null) throw refusal + } + async function notifyGuestsBySms( cleanupId: string, kind: "cancelled" | "updated", @@ -558,7 +747,12 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi sent += 1 } catch (err) { if (smsFailureKind(err) === "opted_out" && recipient.phone !== null) { - await deps.repo.recordPhoneOptOut(recipient.phone).catch(() => {}) + await deps.repo.recordPhoneOptOut(recipient.phone).catch((recordErr: unknown) => { + deps.logger?.warn( + { err: recordErr, cleanupId, guestId: recipient.id }, + "guest sms: failed to record SMS opt-out", + ) + }) } deps.logger?.warn({ err, cleanupId, guestId: recipient.id, kind }, "guest sms: send failed") } @@ -570,12 +764,10 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi cleanupId: string, guestId: string, registration: GuestRegistrationFields, - ): Promise< - Pick - > { + ): Promise { const bridge = deps.registrations if (bridge === undefined) { - return { registration: null, registrationOutcome: null, ticketTokens: [] } + return { registration: null, registrationOutcome: null, ticketTokens: [], refusal: null } } try { const response = await bridge.register( @@ -597,6 +789,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi registration: response.registration, registrationOutcome: response.outcome, ticketTokens: response.ticketTokens, + refusal: registrationRefusalError(response), } } catch (err) { if (err instanceof AppError && err.code === ErrorCode.VALIDATION) throw err @@ -604,7 +797,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi { err, cleanupId }, "guest rsvp: registration failed (suppressed; the RSVP stands)", ) - return { registration: null, registrationOutcome: null, ticketTokens: [] } + return { registration: null, registrationOutcome: null, ticketTokens: [], refusal: null } } } @@ -628,7 +821,30 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi manageTokenHash, now: new Date(now()), }) - const seat = await registerVerifiedGuest(args.event.id, guest.id, args.registration ?? {}) + // Only a row this verify inserted is rolled back: a re-verifying guest already held the RSVP + // (possibly with a live registration that cancelGuest would also cancel). + const rollBackThenThrow = async (refusal: unknown): Promise => { + if (guest.created) { + try { + const released = await deps.repo.cancelGuest(guest.id, new Date(now())) + await enqueueWaitlistPromotion(deps.jobs, released, deps.logger) + } catch (err) { + // The guest must still hear why they were refused; a 500 would hide it. + deps.logger?.error( + { err, cleanupId: args.event.id, guestId: guest.id }, + "guest rsvp: rolling back a refused guest failed; the RSVP row may linger", + ) + } + } + throw refusal + } + let seat: GuestSeat + try { + seat = await registerVerifiedGuest(args.event.id, guest.id, args.registration ?? {}) + } catch (err) { + return rollBackThenThrow(err) + } + if (seat.refusal !== null && guest.created) return rollBackThenThrow(seat.refusal) const going = await deps.repo.goingCount(args.event.id) if (args.confirm) { await sendConfirmation({ ...args, rawToken }).catch((err: unknown) => { @@ -765,6 +981,9 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi return fakeSuccess } + // Refused before any budget is spent or code sent: the guest can fix the form and ask again. + await refuseForeseeableRegistration(event.id, registrationFieldsOf(input)) + const active = await deps.repo.countActiveGuests(event.id) if (active >= MAX_GUESTS_PER_EVENT) { throw AppError.conflict("This event has reached its guest limit.") @@ -891,7 +1110,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi return joinAsGuest({ event, name: record.name, - channel: input.channel, + channel: record.channel, contact, confirm: true, registration: registrationFieldsOf(input), @@ -924,7 +1143,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi const limit = query.limit ?? GUESTS_DEFAULT_LIMIT const { rows, nextCursor } = await deps.repo.listGuests({ cleanupId: event.id, - cursor: parseTimeCursor(query.cursor, { direction: "desc" }), + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit, }) const count = await deps.repo.countActiveGuests(event.id) diff --git a/services/api/src/services/guest-rsvp-wiring.ts b/services/api/src/services/guest-rsvp-wiring.ts index c7a8ec0f..45c9502f 100644 --- a/services/api/src/services/guest-rsvp-wiring.ts +++ b/services/api/src/services/guest-rsvp-wiring.ts @@ -62,6 +62,28 @@ export function makeContainerGuestRsvpService( ...(fields.answers !== undefined ? { answers: fields.answers } : {}), ...(fields.consent !== undefined ? { consent: fields.consent } : {}), }), + registrationGate: async (cleanupId) => { + const { repo: registrationRepo } = makeContainerRegistrationServices( + container, + undefined, + logger, + ) + const [event, ticketTypes] = await Promise.all([ + registrationRepo.eventContext(cleanupId), + registrationRepo.listTicketTypes(cleanupId), + ]) + if (event === null) return null + return { + registrationOpensAt: event.registrationOpensAt, + registrationClosesAt: event.registrationClosesAt, + ticketTypes: ticketTypes.map((t) => ({ + id: t.id, + visibility: t.visibility, + salesOpensAt: t.salesOpensAt, + salesClosesAt: t.salesClosesAt, + })), + } + }, } const audit: GuestRsvpServiceDeps["audit"] = overrides?.audit ?? diff --git a/services/api/src/services/host/admin-pages-repository.drizzle.ts b/services/api/src/services/host/admin-pages-repository.drizzle.ts index 5f59f23d..ce25c90c 100644 --- a/services/api/src/services/host/admin-pages-repository.drizzle.ts +++ b/services/api/src/services/host/admin-pages-repository.drizzle.ts @@ -1,5 +1,6 @@ import type { EventPageStatus, EventVisibility } from "@civfix/shared" import type { Queryable } from "../../db/client.js" +import { keysetInstant, keysetPredicate, type KeysetCursor } from "../../db/cursor-helpers.js" import { likeContains } from "../admin/like.js" export interface AdminEventPageRow { @@ -21,7 +22,8 @@ export interface AdminEventPageRow { flaggedByName: string | null flaggedByHandle: string | null flaggedByJoined: Date | null - sortAt: Date + /** COALESCE(published_at, updated_at) at microsecond precision, the list's keyset instant. */ + cursorAt: string pageId: string } @@ -29,7 +31,7 @@ export interface AdminEventPageListParams { q?: string status?: EventPageStatus flagged?: boolean - cursor: { at: Date; id: string } | null + cursor: KeysetCursor | null limit: number } @@ -63,7 +65,7 @@ interface PageRowSelect { flagged_by_name: string | null flagged_by_handle: string | null flagged_by_joined: Date | null - sort_at: Date + cursor_at: string } function toRow(row: PageRowSelect): AdminEventPageRow { @@ -87,11 +89,12 @@ function toRow(row: PageRowSelect): AdminEventPageRow { flaggedByName: row.flagged_by_name, flaggedByHandle: row.flagged_by_handle, flaggedByJoined: row.flagged_by_joined, - sortAt: row.sort_at, + cursorAt: row.cursor_at, } } export function makeDrizzleAdminEventPageRepository(sql: Queryable): AdminEventPageRepository { + const sortAt = sql`COALESCE(p.published_at, p.updated_at)` const selection = sql` p.id AS page_id, p.cleanup_id, c.page_slug AS slug, c.title, p.status, c.visibility, u.id AS organizer_id, u.display_name AS organizer_name, u.handle AS organizer_handle, @@ -99,7 +102,7 @@ export function makeDrizzleAdminEventPageRepository(sql: Queryable): AdminEventP o.name AS org_name, p.view_count, p.published_at, p.flagged_at, p.flag_reason, f.id AS flagged_by_id, f.display_name AS flagged_by_name, f.handle AS flagged_by_handle, f.created_at AS flagged_by_joined, - COALESCE(p.published_at, p.updated_at) AS sort_at + ${keysetInstant(sql, sortAt)} AS cursor_at ` const joins = sql` FROM cleanup_pages p @@ -133,7 +136,7 @@ export function makeDrizzleAdminEventPageRepository(sql: Queryable): AdminEventP : sql`` const cursorFilter = params.cursor !== null - ? sql`AND (COALESCE(p.published_at, p.updated_at), p.id) < (${params.cursor.at}, ${params.cursor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sortAt, sql`p.id`, params.cursor)}` : sql`` const rows = await sql` SELECT ${selection} ${joins} @@ -142,7 +145,7 @@ export function makeDrizzleAdminEventPageRepository(sql: Queryable): AdminEventP ${flaggedFilter} ${search} ${cursorFilter} - ORDER BY COALESCE(p.published_at, p.updated_at) DESC, p.id DESC + ORDER BY ${sortAt} DESC, p.id DESC LIMIT ${params.limit}` return rows.map(toRow) }, diff --git a/services/api/src/services/host/announcement-service.ts b/services/api/src/services/host/announcement-service.ts index 911fe8e9..2525dff6 100644 --- a/services/api/src/services/host/announcement-service.ts +++ b/services/api/src/services/host/announcement-service.ts @@ -14,7 +14,8 @@ import type { ListEventAnnouncementsResponse, PersonDTO, } from "@civfix/shared" -import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" +import type { FastifyBaseLogger } from "fastify" +import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import type { AnnouncementCap, BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastRecord } from "./broadcast-types.js" import { broadcastLinkWarnings } from "./broadcast-render.js" @@ -32,6 +33,7 @@ export interface AnnouncementServiceDeps { identities: AnnouncementIdentityRepository broadcasts: BroadcastService config: Pick + logger?: Pick now?: () => Date } @@ -193,7 +195,12 @@ export function makeAnnouncementService(deps: AnnouncementServiceDeps): Announce } await deps.broadcasts.sendAnnouncement(cleanupId, actorId, draft.id) } catch (err) { - await repo.deleteDraft(cleanupId, draft.id).catch(() => false) + await repo.deleteDraft(cleanupId, draft.id).catch((cleanupErr: unknown) => { + deps.logger?.warn( + { err: cleanupErr, cleanupId, announcementId: draft.id }, + "announcement: failed send left its draft behind; it counts toward today's cap", + ) + }) throw err } @@ -204,19 +211,16 @@ export function makeAnnouncementService(deps: AnnouncementServiceDeps): Announce async list(cleanupId, query, projection) { const limit = Math.min(query.limit ?? ANNOUNCEMENT_DEFAULT_LIMIT, ANNOUNCEMENT_MAX_LIMIT) - const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) const rows = await repo.listAnnouncements({ cleanupId, - cursor: cursor === null ? null : { createdAt: cursor.at, id: cursor.id }, + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) - const page = rows.slice(0, limit) - const last = page.at(-1) - const nextCursor = - rows.length > limit && last !== undefined - ? encodeTimeCursor({ at: last.createdAt, id: last.id }) - : null - return { items: await hydrate(cleanupId, page, projection), nextCursor } + const { items, nextCursor } = paginateKeyset(rows, limit, (row) => ({ + atText: row.cursorAt, + id: row.id, + })) + return { items: await hydrate(cleanupId, items, projection), nextCursor } }, async get(cleanupId, announcementId, projection) { diff --git a/services/api/src/services/host/broadcast-capability-token.ts b/services/api/src/services/host/broadcast-capability-token.ts index db4c5be6..74fed900 100644 --- a/services/api/src/services/host/broadcast-capability-token.ts +++ b/services/api/src/services/host/broadcast-capability-token.ts @@ -55,6 +55,7 @@ export function verifyUnsubscribeToken( try { parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) } catch { + // Unreachable without the signing key; refused like any other bad token rather than a 500. return null } if (typeof parsed !== "object" || parsed === null) return null diff --git a/services/api/src/services/host/broadcast-lanes.ts b/services/api/src/services/host/broadcast-lanes.ts index 08e4e738..a10721b7 100644 --- a/services/api/src/services/host/broadcast-lanes.ts +++ b/services/api/src/services/host/broadcast-lanes.ts @@ -47,9 +47,9 @@ export function makeBroadcastLanes(deps: BroadcastLaneDeps) { segment: { kind: "all_registered" }, channels: AUTOMATED_CHANNELS, status: "sending", + startedAt: now(), replyTo: event.replyToVerified ? event.replyTo : null, }) - await deps.repo.transition(record.id, ["sending"], "sending", { startedAt: now() }) await deps.enqueuePlan(record.id) return record.id } @@ -81,6 +81,27 @@ export function makeBroadcastLanes(deps: BroadcastLaneDeps) { } } + /** + * A retry lands here when an earlier attempt inserted the row but failed to enqueue its plan. + * Re-enqueueing cannot double-send: plan() only acts on a 'sending' row and deliveries are unique + * per recipient and channel. Without it the notice would wait for the stale-sending sweep. + */ + async function replanUnplannedCancellation(cleanupId: string): Promise { + const existing = await deps.repo.findEventCancellation(cleanupId) + if (existing !== null && existing.status === "sending" && existing.plannedAt === null) { + deps.logger?.warn( + { evt: "broadcast.event_cancelled.replanned", cleanupId, broadcastId: existing.id }, + "event_cancelled lane found its broadcast unplanned; enqueueing the plan again", + ) + await deps.enqueuePlan(existing.id) + return + } + deps.logger?.info( + { evt: "broadcast.event_cancelled.deduped", cleanupId }, + "event_cancelled lane skipped: this event already has a cancellation broadcast", + ) + } + return { async eventUpdated(cleanupId: string): Promise { const event = await deps.repo.eventContext(cleanupId) @@ -117,16 +138,13 @@ export function makeBroadcastLanes(deps: BroadcastLaneDeps) { segment: { kind: "all_registered" }, channels: AUTOMATED_CHANNELS, status: "sending", + startedAt: now(), replyTo: event.replyToVerified ? event.replyTo : null, }) if (record === null) { - deps.logger?.info( - { evt: "broadcast.event_cancelled.deduped", cleanupId }, - "event_cancelled lane skipped: this event already has a cancellation broadcast", - ) + await replanUnplannedCancellation(cleanupId) return null } - await deps.repo.transition(record.id, ["sending"], "sending", { startedAt: now() }) await deps.enqueuePlan(record.id) return record.id }, @@ -153,6 +171,7 @@ export function makeBroadcastLanes(deps: BroadcastLaneDeps) { segment: { kind: "all_registered" }, channels: AUTOMATED_CHANNELS, status: "sending", + startedAt: at, replyTo: event.replyToVerified ? event.replyTo : null, }) if (record === null) continue diff --git a/services/api/src/services/host/broadcast-pipeline.ts b/services/api/src/services/host/broadcast-pipeline.ts index 4900c69b..41ddd015 100644 --- a/services/api/src/services/host/broadcast-pipeline.ts +++ b/services/api/src/services/host/broadcast-pipeline.ts @@ -4,6 +4,7 @@ import type { BroadcastVarValues } from "@civfix/shared/host" import type { Mailer } from "@civfix/shared/interfaces" import type { FastifyBaseLogger } from "fastify" import type { CacheClient } from "../../auth/cache.js" +import type { AdminAuditAction } from "../admin/audit.js" import { mailFailure } from "../../adapters/mail-failure.js" import { isWithinQuietHours, pushGateAllows, type PushGateMode } from "../notification-helpers.js" import type { NotificationService } from "../notification-service.js" @@ -88,7 +89,7 @@ export interface BroadcastPipelineDeps { opts?: { startAfterSec?: number; authRetry?: number }, ) => Promise audit: ( - action: string, + action: AdminAuditAction, actorId: string | null, target: string, meta: Record, @@ -535,22 +536,26 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { let fanOutError: unknown = null for (const group of groups.values()) { try { - await deps.notifications.createNotifications(group.userIds, { - type, - title: group.rendering.inAppTitle, - body: group.rendering.inAppBody, - link: notificationLink(record, event), - push: mode, - }) + const { failed } = await deps.notifications.createNotificationsReportingFailures( + group.userIds, + { + type, + title: group.rendering.inAppTitle, + body: group.rendering.inAppBody, + link: notificationLink(record, event), + push: mode, + }, + ) + for (const userId of failed) undelivered.add(userId) } catch (err) { fanOutError = err for (const userId of group.userIds) undelivered.add(userId) } } - if (fanOutError === null) return + if (undelivered.size === 0) return deps.logger?.error( { err: fanOutError, broadcastId: record.id, undelivered: undelivered.size }, - "broadcast: in-app fan-out failed for some groups; only those rows return to pending", + "broadcast: in-app fan-out failed for some recipients; only those rows return to pending", ) for (const claim of relevant) { if (claim.userId === null || !undelivered.has(claim.userId)) continue @@ -719,7 +724,12 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { return } if (failure.kind === "permanent") { - await repo.suppressEmail(hash, "hard_bounce").catch(() => undefined) + await repo.suppressEmail(hash, "hard_bounce").catch((suppressErr: unknown) => { + deps.logger?.warn( + { err: suppressErr, deliveryId: claim.id }, + "broadcast: hard-bounce suppression write failed; the address may be mailed again", + ) + }) outcomes.push({ id: claim.id, status: "failed", failureKind: "permanent" }) return } diff --git a/services/api/src/services/host/broadcast-render.ts b/services/api/src/services/host/broadcast-render.ts index 664e5398..bdc6e50d 100644 --- a/services/api/src/services/host/broadcast-render.ts +++ b/services/api/src/services/host/broadcast-render.ts @@ -99,6 +99,7 @@ export function formatEventWhen(scheduledAt: Date, timezone: string | null): str timeZoneName: "short", }).format(scheduledAt) } catch { + // A stored zone Intl does not know must not block a send; the instant is still unambiguous. return scheduledAt.toISOString() } } diff --git a/services/api/src/services/host/broadcast-repository.drizzle.ts b/services/api/src/services/host/broadcast-repository.drizzle.ts index 52aac804..4bea3473 100644 --- a/services/api/src/services/host/broadcast-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-repository.drizzle.ts @@ -7,6 +7,7 @@ import type { DeliveryStatus, } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" +import { keysetInstant, keysetPredicate } from "../../db/cursor-helpers.js" import { writeAudit, type WriteAuditInput } from "../admin/audit.js" import { likeContains } from "../admin/like.js" import { listGuestAudiencePage, listMemberAudiencePage } from "./broadcast-audience-sql.js" @@ -19,6 +20,7 @@ import type { BroadcastRepository, DeliveryListQuery, DeliveryListRow, + KeysetRow, } from "./broadcast-repository.js" import type { NotificationPrefsRecord } from "../notification-service.js" import type { @@ -101,8 +103,16 @@ function toRecord(row: BroadcastRowSelect): BroadcastRecord { } } +type CursorRowSelect = BroadcastRowSelect & { cursor_at: string } + +function toKeysetRecord(row: CursorRowSelect): KeysetRow { + return { ...toRecord(row), cursorAt: row.cursor_at } +} + function joinSet(sql: Sql, fragments: Array>): ReturnType { - return fragments.reduce((acc, frag, i) => (i === 0 ? frag : sql`${acc}, ${frag}`)) + const [first, ...rest] = fragments + if (first === undefined) throw new Error("broadcast UPDATE needs at least one column to set") + return rest.reduce((acc, frag) => sql`${acc}, ${frag}`, first) } function jsonParam(sql: Sql, value: unknown): ReturnType | null { @@ -135,13 +145,13 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { const rows = await db` INSERT INTO broadcasts ( cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, - cta_label, cta_url, segment, channels, reply_to, scheduled_at, chunk_size + cta_label, cta_url, segment, channels, reply_to, scheduled_at, started_at, chunk_size ) VALUES ( ${input.cleanupId}, ${input.createdBy}, ${input.kind}, ${input.reminderOffsetMin ?? null}, ${input.status ?? "draft"}, ${input.subject}, ${input.bodyMd}, ${input.ctaLabel ?? null}, ${input.ctaUrl ?? null}, ${jsonParam(sql, input.segment)}, ${input.channels}::text[], ${input.replyTo ?? null}, ${input.scheduledAt ?? null}, - ${input.chunkSize ?? 200} + ${input.startedAt ?? null}, ${input.chunkSize ?? 200} ) RETURNING id, cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, cta_label, cta_url, segment, channels, reply_to, scheduled_at, planned_at, started_at, @@ -175,12 +185,12 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { const rows = await sql` INSERT INTO broadcasts ( cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, - segment, channels, reply_to + segment, channels, reply_to, started_at ) VALUES ( ${input.cleanupId}, ${input.createdBy}, ${input.kind}, ${input.reminderOffsetMin ?? null}, ${input.status ?? "sending"}, ${input.subject}, ${input.bodyMd}, ${jsonParam(sql, input.segment)}, ${input.channels}::text[], - ${input.replyTo ?? null} + ${input.replyTo ?? null}, ${input.startedAt ?? null} ) ON CONFLICT DO NOTHING RETURNING id, cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, @@ -203,42 +213,57 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { return rows[0] ? toRecord(rows[0]) : null }, - async list(query: BroadcastListQuery): Promise { + async findEventCancellation(cleanupId: string): Promise { + const rows = await sql` + SELECT id, cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, + cta_label, cta_url, segment, channels, reply_to, scheduled_at, planned_at, started_at, + finished_at, chunk_size, chunk_count, recipient_count, sent_count, failed_count, + suppressed_count, content_scrubbed_at, created_at, updated_at FROM broadcasts + WHERE cleanup_id = ${cleanupId} AND kind = 'event_cancelled' + LIMIT 1` + return rows[0] ? toRecord(rows[0]) : null + }, + + async list(query: BroadcastListQuery): Promise[]> { const statusFilter = query.status !== undefined ? sql`AND status = ${query.status}` : sql`` const cursorFilter = query.cursor !== null - ? sql`AND (created_at, id) < (${query.cursor.createdAt}, ${query.cursor.id})` + ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, query.cursor)}` : sql`` - const rows = await sql` + const rows = await sql` SELECT id, cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, cta_label, cta_url, segment, channels, reply_to, scheduled_at, planned_at, started_at, finished_at, chunk_size, chunk_count, recipient_count, sent_count, failed_count, - suppressed_count, content_scrubbed_at, created_at, updated_at FROM broadcasts + suppressed_count, content_scrubbed_at, created_at, updated_at, + ${keysetInstant(sql, sql`created_at`)} AS cursor_at + FROM broadcasts WHERE cleanup_id = ${query.cleanupId} ${statusFilter} ${cursorFilter} ORDER BY created_at DESC, id DESC LIMIT ${query.limit}` - return rows.map(toRecord) + return rows.map(toKeysetRecord) }, - async listAnnouncements(query: AnnouncementListQuery): Promise { + async listAnnouncements(query: AnnouncementListQuery): Promise[]> { const cursorFilter = query.cursor !== null - ? sql`AND (created_at, id) < (${query.cursor.createdAt}, ${query.cursor.id})` + ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, query.cursor)}` : sql`` - const rows = await sql` + const rows = await sql` SELECT id, cleanup_id, created_by, kind, reminder_offset_min, status, subject, body_md, cta_label, cta_url, segment, channels, reply_to, scheduled_at, planned_at, started_at, finished_at, chunk_size, chunk_count, recipient_count, sent_count, failed_count, - suppressed_count, content_scrubbed_at, created_at, updated_at FROM broadcasts + suppressed_count, content_scrubbed_at, created_at, updated_at, + ${keysetInstant(sql, sql`created_at`)} AS cursor_at + FROM broadcasts WHERE cleanup_id = ${query.cleanupId} AND kind = ${ANNOUNCEMENT_BROADCAST_KIND} AND status = ANY(${[...ANNOUNCEMENT_VISIBLE_STATUSES]}::text[]) ${cursorFilter} ORDER BY created_at DESC, id DESC LIMIT ${query.limit}` - return rows.map(toRecord) + return rows.map(toKeysetRecord) }, async countAnnouncementsSince(cleanupId: string, since: Date): Promise { @@ -251,7 +276,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { return rows[0]?.n ?? 0 }, - async listAdmin(query: AdminBroadcastListQuery): Promise { + async listAdmin(query: AdminBroadcastListQuery): Promise[]> { const statusFilter = query.status !== undefined ? sql`AND b.status = ${query.status}` : sql`` const kindFilter = query.kind !== undefined ? sql`AND b.kind = ${query.kind}` : sql`` const eventFilter = @@ -262,10 +287,10 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { const toFilter = query.to !== undefined ? sql`AND b.created_at <= ${query.to}` : sql`` const cursorFilter = query.cursor !== null - ? sql`AND (b.created_at, b.id) < (${query.cursor.createdAt}, ${query.cursor.id})` + ? sql`AND ${keysetPredicate(sql, sql`b.created_at`, sql`b.id`, query.cursor)}` : sql`` const rows = await sql< - (BroadcastRowSelect & { + (CursorRowSelect & { event_title: string | null created_by_name: string | null created_by_handle: string | null @@ -277,6 +302,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { b.planned_at, b.started_at, b.finished_at, b.chunk_size, b.chunk_count, b.recipient_count, b.sent_count, b.failed_count, b.suppressed_count, b.content_scrubbed_at, b.created_at, b.updated_at, + ${keysetInstant(sql, sql`b.created_at`)} AS cursor_at, c.title AS event_title, u.display_name AS created_by_name, u.handle AS created_by_handle, @@ -295,7 +321,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { ORDER BY b.created_at DESC, b.id DESC LIMIT ${query.limit}` return rows.map((row) => ({ - ...toRecord(row), + ...toKeysetRecord(row), eventTitle: row.event_title, createdByName: row.created_by_name, createdByHandle: row.created_by_handle, @@ -303,7 +329,8 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { })) }, - async listAdminHosts(params: AdminHostListParams): Promise { + async listAdminHosts(params: AdminHostListParams): Promise[]> { + const sortAt = sql`COALESCE(agg.last_broadcast_at, 'epoch'::timestamptz)` const suspendedFilter = params.suspended === undefined ? sql`` @@ -316,7 +343,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { : sql`` const cursorFilter = params.cursor !== null - ? sql`AND (COALESCE(agg.last_broadcast_at, 'epoch'::timestamptz), u.id) < (${params.cursor.at}, ${params.cursor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sortAt, sql`u.id`, params.cursor)}` : sql`` const rows = await sql< { @@ -337,7 +364,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { suppressed_count: string | number events_messaged: string | number last_broadcast_at: Date | null - sort_at: Date + cursor_at: string }[] >` WITH agg AS ( @@ -369,7 +396,8 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { COALESCE(agg.suppressed_count, 0) AS suppressed_count, COALESCE(agg.events_messaged, 0) AS events_messaged, agg.last_broadcast_at, - COALESCE(agg.last_broadcast_at, 'epoch'::timestamptz) AS sort_at + ${sortAt} AS sort_at, + ${keysetInstant(sql, sortAt)} AS cursor_at FROM candidates c JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL LEFT JOIN agg ON agg.user_id = c.user_id @@ -407,7 +435,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { suppressedCount: Number(row.suppressed_count), eventsMessaged: Number(row.events_messaged), lastBroadcastAt: row.last_broadcast_at, - sortAt: row.sort_at, + cursorAt: row.cursor_at, })) }, @@ -667,13 +695,13 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { return rows[0] ? toRecord(rows[0]) : null }, - async listDeliveries(query: DeliveryListQuery): Promise { + async listDeliveries(query: DeliveryListQuery): Promise[]> { const statusFilter = query.status !== undefined ? sql`AND status = ${query.status}` : sql`` const channelFilter = query.channel !== undefined ? sql`AND channel = ${query.channel}` : sql`` const cursorFilter = query.cursor !== null - ? sql`AND (created_at, id) < (${query.cursor.createdAt}, ${query.cursor.id})` + ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, query.cursor)}` : sql`` const rows = await sql< { @@ -686,10 +714,11 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { attempts: number sent_at: Date | null created_at: Date + cursor_at: string }[] >` SELECT id, channel, recipient_kind, status, suppression_reason, failure_kind, attempts, - sent_at, created_at + sent_at, created_at, ${keysetInstant(sql, sql`created_at`)} AS cursor_at FROM broadcast_deliveries WHERE broadcast_id = ${query.broadcastId} ${statusFilter} @@ -707,6 +736,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { attempts: row.attempts, sentAt: row.sent_at, createdAt: row.created_at, + cursorAt: row.cursor_at, })) }, @@ -883,7 +913,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { return sql.begin(async (tx) => { const rows = await tx<{ user_id: string }[]>` INSERT INTO user_moderation (user_id, host_messaging_suspended) - SELECT u.id, ${suspended} FROM users u WHERE u.id = ${userId} + SELECT u.id, ${suspended} FROM users u WHERE u.id = ${userId} AND u.deleted_at IS NULL ON CONFLICT (user_id) DO UPDATE SET host_messaging_suspended = ${suspended}, updated_at = now() RETURNING user_id` diff --git a/services/api/src/services/host/broadcast-repository.memory.ts b/services/api/src/services/host/broadcast-repository.memory.ts index f89b8f52..99ce3303 100644 --- a/services/api/src/services/host/broadcast-repository.memory.ts +++ b/services/api/src/services/host/broadcast-repository.memory.ts @@ -10,10 +10,12 @@ import type { BroadcastRepository, DeliveryListQuery, DeliveryListRow, + KeysetRow, } from "./broadcast-repository.js" import { DEFAULT_PREFS } from "../notification-helpers.js" import type { NotificationPrefsRecord } from "../notification-service.js" import type { WriteAuditInput } from "../admin/audit.js" +import type { KeysetCursor } from "../../db/cursor-helpers.js" import type { AdminBroadcastRow, AdminHostListParams, @@ -79,6 +81,26 @@ export interface MemoryGuest { name?: string } +interface Keyed { + createdAt: Date + id: string +} + +function isBeforeCursor(row: Keyed, cursor: KeysetCursor | null): boolean { + if (cursor === null) return true + const at = row.createdAt.getTime() + const cursorAt = cursor.at.getTime() + return at < cursorAt || (at === cursorAt && row.id < cursor.id) +} + +function newestFirst(a: Keyed, b: Keyed): number { + return b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1) +} + +function withCursorAt(row: T): KeysetRow { + return { ...row, cursorAt: row.createdAt.toISOString() } +} + export class InMemoryBroadcastRepository implements BroadcastRepository { private readonly broadcasts = new Map() private readonly deliveries = new Map() @@ -89,6 +111,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { private readonly guests = new Map() private readonly events = new Map() private readonly hosts = new Map() + private readonly deletedHosts = new Set() private dueReminders: DueReminder[] = [] readonly audits: WriteAuditInput[] = [] @@ -118,6 +141,11 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { }) } + /** Mirrors a soft-deleted users row: the SQL reads and upserts filter `deleted_at IS NULL`. */ + softDeleteHost(userId: string): void { + this.deletedHosts.add(userId) + } + seedMembers(cleanupId: string, rows: readonly MemoryMember[]): void { this.members.set( cleanupId, @@ -170,7 +198,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { replyTo: input.replyTo ?? null, scheduledAt: input.scheduledAt ?? null, plannedAt: null, - startedAt: null, + startedAt: input.startedAt ?? null, finishedAt: null, chunkSize: input.chunkSize ?? 200, chunkCount: 0, @@ -201,12 +229,11 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { } async createIfAbsent(input: BroadcastCreateInput): Promise { - const exists = [...this.broadcasts.values()].some( - (b) => - b.cleanupId === input.cleanupId && - b.kind === input.kind && - b.reminderOffsetMin === (input.reminderOffsetMin ?? null), - ) + const exists = [...this.broadcasts.values()].some((b) => { + if (b.cleanupId !== input.cleanupId || b.kind !== input.kind) return false + if (input.kind === "event_cancelled") return true + return input.kind === "reminder" && b.reminderOffsetMin === (input.reminderOffsetMin ?? null) + }) if (exists) return null return this.create(input) } @@ -220,27 +247,30 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return Promise.resolve(found && found.cleanupId === cleanupId ? found : null) } - list(query: BroadcastListQuery): Promise { + findEventCancellation(cleanupId: string): Promise { + const found = [...this.broadcasts.values()].find( + (b) => b.cleanupId === cleanupId && b.kind === "event_cancelled", + ) + return Promise.resolve(found ?? null) + } + + list(query: BroadcastListQuery): Promise[]> { const rows = [...this.broadcasts.values()] .filter((b) => b.cleanupId === query.cleanupId) .filter((b) => query.status === undefined || b.status === query.status) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - return Promise.resolve(rows.slice(0, query.limit)) + .filter((b) => isBeforeCursor(b, query.cursor)) + .sort(newestFirst) + return Promise.resolve(rows.slice(0, query.limit).map(withCursorAt)) } - listAnnouncements(query: AnnouncementListQuery): Promise { + listAnnouncements(query: AnnouncementListQuery): Promise[]> { const rows = [...this.broadcasts.values()] .filter((b) => b.cleanupId === query.cleanupId) .filter((b) => b.kind === ANNOUNCEMENT_BROADCAST_KIND) .filter((b) => ANNOUNCEMENT_VISIBLE_STATUSES.includes(b.status)) - .filter( - (b) => - query.cursor === null || - b.createdAt.getTime() < query.cursor.createdAt.getTime() || - (b.createdAt.getTime() === query.cursor.createdAt.getTime() && b.id < query.cursor.id), - ) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1)) - return Promise.resolve(rows.slice(0, query.limit)) + .filter((b) => isBeforeCursor(b, query.cursor)) + .sort(newestFirst) + return Promise.resolve(rows.slice(0, query.limit).map(withCursorAt)) } countAnnouncementsSince(cleanupId: string, since: Date): Promise { @@ -254,7 +284,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return Promise.resolve(rows.length) } - listAdmin(query: AdminBroadcastListQuery): Promise { + listAdmin(query: AdminBroadcastListQuery): Promise[]> { const rows = [...this.broadcasts.values()] .filter((b) => query.status === undefined || b.status === query.status) .filter((b) => query.kind === undefined || b.kind === query.kind) @@ -262,10 +292,11 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { .filter((b) => query.createdBy === undefined || b.createdBy === query.createdBy) .filter((b) => query.from === undefined || b.createdAt >= query.from) .filter((b) => query.to === undefined || b.createdAt <= query.to) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .filter((b) => isBeforeCursor(b, query.cursor)) + .sort(newestFirst) .slice(0, query.limit) .map((b) => ({ - ...b, + ...withCursorAt(b), eventTitle: this.events.get(b.cleanupId)?.title ?? null, createdByName: null, createdByHandle: null, @@ -274,7 +305,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return Promise.resolve(rows) } - listAdminHosts(params: AdminHostListParams): Promise { + listAdminHosts(params: AdminHostListParams): Promise[]> { const byUser = new Map() const blank = (userId: string): AdminHostRow => ({ userId, @@ -294,7 +325,6 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { suppressedCount: 0, eventsMessaged: 0, lastBroadcastAt: null, - sortAt: new Date(0), }) for (const b of this.broadcasts.values()) { if (b.createdBy === null || b.createdAt < params.windowStart) continue @@ -306,7 +336,6 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { row.suppressedCount += b.suppressedCount if (row.lastBroadcastAt === null || b.createdAt > row.lastBroadcastAt) { row.lastBroadcastAt = b.createdAt - row.sortAt = b.createdAt } byUser.set(b.createdBy, row) } @@ -315,8 +344,11 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { } const rows = [...byUser.values()] .filter((r) => params.suspended === undefined || r.messagingSuspended === params.suspended) - .sort((a, b) => b.sortAt.getTime() - a.sortAt.getTime()) + .map((r) => ({ row: r, createdAt: r.lastBroadcastAt ?? new Date(0), id: r.userId })) + .filter((keyed) => isBeforeCursor(keyed, params.cursor)) + .sort(newestFirst) .slice(0, params.limit) + .map((keyed) => ({ ...keyed.row, cursorAt: keyed.createdAt.toISOString() })) return Promise.resolve(rows) } @@ -574,12 +606,13 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return next } - listDeliveries(query: DeliveryListQuery): Promise { + listDeliveries(query: DeliveryListQuery): Promise[]> { const rows = [...this.deliveries.values()] .filter((d) => d.broadcastId === query.broadcastId) .filter((d) => query.status === undefined || d.status === query.status) .filter((d) => query.channel === undefined || d.channel === query.channel) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .filter((d) => isBeforeCursor(d, query.cursor)) + .sort(newestFirst) .slice(0, query.limit) .map((d) => ({ id: d.id, @@ -591,6 +624,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { attempts: d.attempts, sentAt: d.sentAt, createdAt: d.createdAt, + cursorAt: d.createdAt.toISOString(), })) return Promise.resolve(rows) } @@ -671,6 +705,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { } hostMessagingState(userId: string): Promise { + if (this.deletedHosts.has(userId)) return Promise.resolve(null) return Promise.resolve(this.hosts.get(userId) ?? null) } @@ -679,10 +714,10 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { suspended: boolean, audit: WriteAuditInput, ): Promise { - // A seeded host stands in for a users row: the Postgres upsert selects from users, so an - // unknown id changes nothing and writes no audit row. + // A seeded host stands in for a users row: the Postgres upsert selects from live users, so an + // unknown or soft-deleted id changes nothing and writes no audit row. const existing = this.hosts.get(userId) - if (existing === undefined) return Promise.resolve(false) + if (existing === undefined || this.deletedHosts.has(userId)) return Promise.resolve(false) this.hosts.set(userId, { ...existing, suspended }) this.audits.push(audit) return Promise.resolve(true) diff --git a/services/api/src/services/host/broadcast-repository.ts b/services/api/src/services/host/broadcast-repository.ts index e2d8ed77..4fad9853 100644 --- a/services/api/src/services/host/broadcast-repository.ts +++ b/services/api/src/services/host/broadcast-repository.ts @@ -4,6 +4,7 @@ import type { BroadcastStatus, DeliveryStatus, } from "@civfix/shared" +import type { KeysetCursor } from "../../db/cursor-helpers.js" import type { NotificationPrefsRecord } from "../notification-service.js" import type { WriteAuditInput } from "../admin/audit.js" import type { @@ -27,7 +28,7 @@ import type { export interface BroadcastListQuery { cleanupId: string status?: BroadcastStatus - cursor: { createdAt: Date; id: string } | null + cursor: KeysetCursor | null limit: number } @@ -35,7 +36,7 @@ export interface DeliveryListQuery { broadcastId: string status?: DeliveryStatus channel?: string - cursor: { createdAt: Date; id: string } | null + cursor: KeysetCursor | null limit: number } @@ -58,16 +59,19 @@ export interface AdminBroadcastListQuery { createdBy?: string from?: Date to?: Date - cursor: { createdAt: Date; id: string } | null + cursor: KeysetCursor | null limit: number } export interface AnnouncementListQuery { cleanupId: string - cursor: { createdAt: Date; id: string } | null + cursor: KeysetCursor | null limit: number } +/** A listed row plus its keyset instant rendered at microsecond precision, for the next cursor. */ +export type KeysetRow = T & { cursorAt: string } + export interface AudiencePageQuery { cleanupId: string segment: BroadcastSegment @@ -92,11 +96,12 @@ export interface BroadcastRepository { findById(broadcastId: string): Promise findForEvent(cleanupId: string, broadcastId: string): Promise - list(query: BroadcastListQuery): Promise - listAnnouncements(query: AnnouncementListQuery): Promise + findEventCancellation(cleanupId: string): Promise + list(query: BroadcastListQuery): Promise[]> + listAnnouncements(query: AnnouncementListQuery): Promise[]> countAnnouncementsSince(cleanupId: string, since: Date): Promise - listAdmin(query: AdminBroadcastListQuery): Promise - listAdminHosts(params: AdminHostListParams): Promise + listAdmin(query: AdminBroadcastListQuery): Promise[]> + listAdminHosts(params: AdminHostListParams): Promise[]> updateDraft( cleanupId: string, @@ -140,7 +145,7 @@ export interface BroadcastRepository { suppressRemaining(broadcastId: string, reason: string): Promise deliveryCounts(broadcastId: string): Promise refreshCounts(broadcastId: string): Promise - listDeliveries(query: DeliveryListQuery): Promise + listDeliveries(query: DeliveryListQuery): Promise[]> memberContacts(userIds: readonly string[]): Promise> pushPrefs(userIds: readonly string[]): Promise> diff --git a/services/api/src/services/host/broadcast-service.ts b/services/api/src/services/host/broadcast-service.ts index 94812e8b..05da2339 100644 --- a/services/api/src/services/host/broadcast-service.ts +++ b/services/api/src/services/host/broadcast-service.ts @@ -18,9 +18,13 @@ import type { Mailer } from "@civfix/shared/interfaces" import type { FastifyBaseLogger } from "fastify" import { assertNoSlur } from "../../abuse/slur-filter.js" import type { CounterStore } from "../../abuse/counter-store.js" -import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" +import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import type { BroadcastRepository } from "./broadcast-repository.js" -import type { BroadcastRecord, EventBroadcastContext } from "./broadcast-types.js" +import type { + BroadcastDraftPatch, + BroadcastRecord, + EventBroadcastContext, +} from "./broadcast-types.js" import { CRITICAL_BROADCAST_KINDS } from "./broadcast-types.js" import { assertBroadcastLinkPolicy, @@ -33,6 +37,7 @@ import { verifyUnsubscribeToken } from "./broadcast-capability-token.js" export const BROADCAST_DEFAULT_LIMIT = 20 export const BROADCAST_TEST_SENDS_PER_HOUR = 5 +export const TEST_SEND_WINDOW_SEC = 60 * 60 export const DAY_SECONDS = 24 * 60 * 60 export const AUDIENCE_PAGE_SIZE = 1000 export const AUDIENCE_MAX_PAGES = 100 @@ -74,6 +79,7 @@ export type CapKind = | "cooldown" | "per_event_per_day" | "recipients_per_day" + | "test_sends" | "counter_unavailable" export class BroadcastCapError extends Error { @@ -94,6 +100,7 @@ const CAP_COPY: Record = { cooldown: "You just sent a message for this event. Give it a few minutes.", per_event_per_day: "This event has reached its daily message limit.", recipients_per_day: "You have reached today's limit for how many people you can message.", + test_sends: "You have sent several test messages. Try again in an hour.", counter_unavailable: "Messaging is temporarily unavailable. Try again in a moment.", } @@ -232,7 +239,21 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi deps.logger?.warn({ err, kind }, "broadcast: cap counter unavailable; refusing (fail closed)") throw new BroadcastCapError("counter_unavailable", CAP_COPY.counter_unavailable) } - if (used > limit) throw new BroadcastCapError(kind, CAP_COPY[kind]) + if (used > limit) { + await giveBack(key, kind) + throw new BroadcastCapError(kind, CAP_COPY[kind]) + } + } + + async function giveBack(key: string, kind: CapKind): Promise { + try { + await deps.counters.decrBy(key, 1) + } catch (err) { + deps.logger?.warn( + { err, kind }, + "broadcast: a refused send could not give its charge back; it stays spent until the window ends", + ) + } } function linkPolicyText(bodyMd: string, ctaUrl: string | null | undefined): string { @@ -246,13 +267,19 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi } async function reserveSendCounters(cleanupId: string, actorId: string): Promise { - await reserve(`bcast:cool:${actorId}:${cleanupId}`, config.cooldownSec, 1, "cooldown") - await reserve( - `bcast:event:${cleanupId}:${utcDayKey(now())}`, - DAY_SECONDS, - config.perEventPerDay, - "per_event_per_day", - ) + const cooldownKey = `bcast:cool:${actorId}:${cleanupId}` + await reserve(cooldownKey, config.cooldownSec, 1, "cooldown") + try { + await reserve( + `bcast:event:${cleanupId}:${utcDayKey(now())}`, + DAY_SECONDS, + config.perEventPerDay, + "per_event_per_day", + ) + } catch (err) { + await giveBack(cooldownKey, "cooldown") + throw err + } } async function reserveSendSlot(cleanupId: string, actorId: string): Promise { @@ -323,27 +350,48 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi throw err } } - await deps.enqueuePlan(broadcastId) + try { + await deps.enqueuePlan(broadcastId) + } catch (err) { + await releaseUnplannedSend(broadcastId, record.status) + throw err + } return toBroadcastDTO(moved) } + /** + * The host is told the send failed, so the row must not stay 'sending': the stale-sending sweep would + * deliver it minutes later anyway. Rolling back is safe even when the enqueue did land, because plan() + * skips any broadcast that is no longer 'sending'. + */ + async function releaseUnplannedSend( + broadcastId: string, + previous: BroadcastStatus, + ): Promise { + try { + await repo.transition(broadcastId, ["sending"], previous, { startedAt: null }) + } catch (err) { + deps.logger?.error( + { err, broadcastId }, + "broadcast: could not roll back a send whose plan job failed to enqueue; the sweep will send it", + ) + } + } + return { async list(cleanupId, query) { const limit = query.limit ?? BROADCAST_DEFAULT_LIMIT - const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) const rows = await repo.list({ cleanupId, ...(query.status !== undefined ? { status: query.status } : {}), - cursor: cursor === null ? null : { createdAt: cursor.at, id: cursor.id }, + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) - const page = rows.slice(0, limit) - const last = page.at(-1) - const nextCursor = - rows.length > limit && last !== undefined - ? encodeTimeCursor({ at: last.createdAt, id: last.id }) - : null - return { items: page.map(toBroadcastDTO), nextCursor } + const { items, nextCursor } = paginateKeyset(rows, limit, (row) => ({ + atText: row.cursorAt, + id: row.id, + })) + return { items: items.map(toBroadcastDTO), nextCursor } }, async get(cleanupId, broadcastId) { @@ -381,7 +429,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi const bodyMd = body.bodyMd ?? current.bodyMd ?? "" const ctaUrl = "ctaUrl" in body ? (body.ctaUrl ?? null) : current.ctaUrl assertContent(subject, bodyMd, ctaUrl) - const patch = { + const patch: BroadcastDraftPatch = { ...(body.subject !== undefined ? { subject: body.subject } : {}), ...(body.bodyMd !== undefined ? { bodyMd: body.bodyMd } : {}), ...("ctaLabel" in body ? { ctaLabel: body.ctaLabel ?? null } : {}), @@ -389,7 +437,10 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi ...(body.segment !== undefined ? { segment: body.segment } : {}), ...(body.channels !== undefined ? { channels: body.channels as BroadcastChannel[] } : {}), } - const updated = await repo.updateDraft(cleanupId, body.broadcastId, patch) + let updated: BroadcastRecord | null = current.status === "draft" ? current : null + if (Object.keys(patch).length > 0) { + updated = await repo.updateDraft(cleanupId, body.broadcastId, patch) + } if (updated === null) { throw AppError.conflict("That message has already been sent or scheduled.") } @@ -454,9 +505,9 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi await guardHost(actorId) await reserve( `bcast:test:${actorId}`, - 3600, + TEST_SEND_WINDOW_SEC, BROADCAST_TEST_SENDS_PER_HOUR, - "per_event_per_day", + "test_sends", ) const record = await requireDraft(cleanupId, broadcastId) const event = await repo.eventContext(cleanupId) @@ -542,22 +593,19 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi const record = await repo.findForEvent(cleanupId, query.broadcastId) if (record === null) throw notFound() const limit = query.limit ?? BROADCAST_DEFAULT_LIMIT - const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) const rows = await repo.listDeliveries({ broadcastId: query.broadcastId, ...(query.status !== undefined ? { status: query.status } : {}), ...(query.channel !== undefined ? { channel: query.channel } : {}), - cursor: cursor === null ? null : { createdAt: cursor.at, id: cursor.id }, + cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) - const page = rows.slice(0, limit) - const last = page.at(-1) - const nextCursor = - rows.length > limit && last !== undefined - ? encodeTimeCursor({ at: last.createdAt, id: last.id }) - : null + const { items, nextCursor } = paginateKeyset(rows, limit, (row) => ({ + atText: row.cursorAt, + id: row.id, + })) return { - items: page.map((row) => ({ + items: items.map((row) => ({ id: row.id, channel: row.channel as BroadcastChannel, recipientKind: row.recipientKind, @@ -603,13 +651,10 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi async reserveRecipientBudget(actorId, recipients) { if (recipients <= 0) return true + const chargedKey = `bcast:host:${actorId}:${utcDayKey(now())}` + let used: number try { - const used = await deps.counters.incrBy( - `bcast:host:${actorId}:${utcDayKey(now())}`, - recipients, - DAY_SECONDS, - ) - return used <= config.recipientsPerDay + used = await deps.counters.incrBy(chargedKey, recipients, DAY_SECONDS) } catch (err) { deps.logger?.warn( { err, actorId }, @@ -617,6 +662,16 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi ) return false } + if (used <= config.recipientsPerDay) return true + try { + await deps.counters.decrBy(chargedKey, recipients) + } catch (err) { + deps.logger?.warn( + { err, actorId, recipients }, + "broadcast: refused recipients could not be given back; they stay charged until the UTC day rolls", + ) + } + return false }, } } diff --git a/services/api/src/services/host/broadcast-types.ts b/services/api/src/services/host/broadcast-types.ts index 80f84995..68396f38 100644 --- a/services/api/src/services/host/broadcast-types.ts +++ b/services/api/src/services/host/broadcast-types.ts @@ -7,6 +7,7 @@ import type { DeliveryStatus, DeliverySuppressionReason, } from "@civfix/shared" +import type { KeysetCursor } from "../../db/cursor-helpers.js" export type BroadcastRecipientKind = "member" | "guest" @@ -53,6 +54,7 @@ export interface BroadcastCreateInput { replyTo?: string | null status?: BroadcastStatus scheduledAt?: Date | null + startedAt?: Date | null chunkSize?: number } @@ -168,14 +170,13 @@ export interface AdminHostRow { suppressedCount: number eventsMessaged: number lastBroadcastAt: Date | null - sortAt: Date } export interface AdminHostListParams { q?: string suspended?: boolean windowStart: Date - cursor: { at: Date; id: string } | null + cursor: KeysetCursor | null limit: number } diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index 5f875fb3..43cbf2b5 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -84,6 +84,7 @@ function selfHostsOf(webOrigins: readonly string[]): string[] { try { hosts.push(new URL(origin).hostname.toLowerCase()) } catch { + // A malformed origin cannot match any referrer, so skipping it only drops a no-op entry. continue } } @@ -148,6 +149,7 @@ export function makeCommsRuntime(container: Container, logger?: CommsLogger): Co ), broadcasts, config, + ...(logger !== undefined ? { logger } : {}), }) const pipeline = makeBroadcastPipeline({ diff --git a/services/api/src/services/host/event-day.ts b/services/api/src/services/host/event-day.ts index 3ad9582b..02b300ea 100644 --- a/services/api/src/services/host/event-day.ts +++ b/services/api/src/services/host/event-day.ts @@ -8,6 +8,7 @@ export function eventDayKey(at: Date, timezone: string | null): string { day: "2-digit", }).format(at) } catch { + // Intl throws RangeError on a zone name this runtime does not know; a UTC day beats no day. return at.toISOString().slice(0, 10) } } diff --git a/services/api/src/services/host/export-repository.drizzle.ts b/services/api/src/services/host/export-repository.drizzle.ts index 221d9f2f..8a292375 100644 --- a/services/api/src/services/host/export-repository.drizzle.ts +++ b/services/api/src/services/host/export-repository.drizzle.ts @@ -37,6 +37,14 @@ export interface HostExportRepository { listForEvent(cleanupId: string, limit: number): Promise listForOrganization(organizationId: string, limit: number): Promise claimForRun(exportId: string, staleBefore: Date): Promise + /** + * Records the run's object key. `replaces` names the key the row must still hold (null for none): a + * re-claimed row keeps the crashed run's key, and overwriting it unseen would orphan that object. + */ + recordObjectKey( + exportId: string, + args: { r2Key: string; runToken: string | null; replaces: string | null }, + ): Promise markReady( exportId: string, args: { @@ -48,9 +56,15 @@ export interface HostExportRepository { runToken: string | null }, ): Promise - markFailed(exportId: string, errorCode: string): Promise + /** Fails the run holding `runToken`; the row keeps any object key for the reaper to delete. */ + markFailed(exportId: string, errorCode: string, runToken: string | null): Promise listExpired(now: Date, limit: number): Promise markExpired(exportId: string): Promise + listOrphaned(args: { staleBefore: Date; limit: number }): Promise + releaseObject( + record: Pick, + errorCode: string, + ): Promise deleteOlderThan(cutoff: Date, batchSize: number): Promise } @@ -161,6 +175,15 @@ export function makeDrizzleHostExportRepository(sql: Sql): HostExportRepository return rows[0] ? toRecord(rows[0]) : null }, + async recordObjectKey(exportId, args) { + const rows = await sql<{ id: string }[]>` + UPDATE host_exports SET r2_key = ${args.r2Key} + WHERE id = ${exportId} AND status = 'running' AND run_token IS NOT DISTINCT FROM ${args.runToken} + AND r2_key IS NOT DISTINCT FROM ${args.replaces} + RETURNING id` + return rows.length > 0 + }, + async markReady(exportId, args) { const rows = await sql` UPDATE host_exports @@ -174,11 +197,14 @@ export function makeDrizzleHostExportRepository(sql: Sql): HostExportRepository return rows[0] ? toRecord(rows[0]) : null }, - async markFailed(exportId, errorCode) { - await sql` + async markFailed(exportId, errorCode, runToken) { + const rows = await sql<{ id: string }[]>` UPDATE host_exports SET status = 'failed', error_code = ${errorCode}, completed_at = now() - WHERE id = ${exportId} AND status IN ('queued','running')` + WHERE id = ${exportId} AND status IN ('queued','running') + AND run_token IS NOT DISTINCT FROM ${runToken} + RETURNING id` + return rows.length > 0 }, async listExpired(now, limit) { @@ -198,12 +224,38 @@ export function makeDrizzleHostExportRepository(sql: Sql): HostExportRepository UPDATE host_exports SET status = 'expired', r2_key = NULL WHERE id = ${exportId}` }, + async listOrphaned({ staleBefore, limit }) { + const rows = await sql` + SELECT id, cleanup_id, organization_id, requested_by, kind, filters, status, r2_key, + row_count, byte_size, truncated, error_code, run_token, requested_at, started_at, + completed_at, expires_at + FROM host_exports + WHERE (status = 'failed' AND r2_key IS NOT NULL) + OR (status = 'running' AND started_at < ${staleBefore}) + OR (status = 'queued' AND requested_at < ${staleBefore}) + ORDER BY requested_at + LIMIT ${limit}` + return rows.map(toRecord) + }, + + async releaseObject(record, errorCode) { + const rows = await sql<{ id: string }[]>` + UPDATE host_exports + SET status = 'failed', error_code = COALESCE(error_code, ${errorCode}), r2_key = NULL, + completed_at = COALESCE(completed_at, now()) + WHERE id = ${record.id} AND status = ${record.status} + AND run_token IS NOT DISTINCT FROM ${record.runToken} + RETURNING id` + return rows.length > 0 + }, + + /** A row that still names an object is kept until the reaper has deleted that object. */ async deleteOlderThan(cutoff, batchSize) { const rows = await sql<{ id: string }[]>` DELETE FROM host_exports WHERE id IN ( SELECT id FROM host_exports - WHERE requested_at < ${cutoff} + WHERE requested_at < ${cutoff} AND r2_key IS NULL ORDER BY requested_at LIMIT ${batchSize} ) diff --git a/services/api/src/services/host/export-service.ts b/services/api/src/services/host/export-service.ts index bfdaa37d..848ad126 100644 --- a/services/api/src/services/host/export-service.ts +++ b/services/api/src/services/host/export-service.ts @@ -11,6 +11,7 @@ export const EXPORT_DOWNLOAD_URL_TTL_SEC = 300 export const EXPORT_LIST_LIMIT = 50 export const EXPORT_YIELD_EVERY_ROWS = 1000 export const EXPORT_RUN_STALE_MS = 10 * 60 * 1000 +export const EXPORT_ABANDON_AFTER_MS = 60 * 60 * 1000 export interface HostExportConfig { maxRows: number @@ -74,10 +75,32 @@ export interface HostExportService { export function makeHostExportService(deps: HostExportServiceDeps): HostExportService { const now = deps.now ?? (() => new Date()) - function storageKey(exportId: string, at: Date): string { + /** + * Each run writes its own object: two runs of one export in one month would otherwise share a key, + * and a superseded run discarding "its" object would delete the winner's. The export id stays the + * last segment because the download filename is read from it. + */ + function storageKey(claimed: HostExportRecord, at: Date): string { const year = at.getUTCFullYear() const month = String(at.getUTCMonth() + 1).padStart(2, "0") - return `exports/host/${year}/${month}/${exportId}.csv` + const run = claimed.runToken === null ? "" : `${claimed.runToken}/` + return `exports/host/${year}/${month}/${run}${claimed.id}.csv` + } + + /** The failed row keeps its key until this succeeds, so the reaper can finish a partial cleanup. */ + async function discardFailedObject(claimed: HostExportRecord, key: string): Promise { + try { + await deps.storage.delete(key) + await deps.repo.releaseObject( + { id: claimed.id, status: "failed", runToken: claimed.runToken }, + "build_failed", + ) + } catch (err) { + deps.logger?.warn( + { err, exportId: claimed.id }, + "host export: failed run could not clean up its object; the reaper will retry", + ) + } } return { @@ -126,10 +149,11 @@ export function makeHostExportService(deps: HostExportServiceDeps): HostExportSe { err, exportId: claimed.id }, "host export refused: the requester no longer holds the capability", ) - await deps.repo.markFailed(claimed.id, "forbidden") + await deps.repo.markFailed(claimed.id, "forbidden", claimed.runToken) return { status: "failed" } } } + let key: string | null = null const ctx: HostExportContext = { exportId: claimed.id, cleanupId: claimed.cleanupId, @@ -179,7 +203,22 @@ export function makeHostExportService(deps: HostExportServiceDeps): HostExportSe bytes += note.byteLength } - const key = storageKey(claimed.id, at) + // A re-claimed row still names the crashed run's object. It is deleted before this run's key + // replaces it; if the delete fails the row fails holding that key, so the reaper finishes it. + if (claimed.r2Key !== null) await deps.storage.delete(claimed.r2Key) + key = storageKey(claimed, at) + const owned = await deps.repo.recordObjectKey(claimed.id, { + r2Key: key, + runToken: claimed.runToken, + replaces: claimed.r2Key, + }) + if (!owned) { + deps.logger?.warn( + { evt: "host.export.superseded", exportId: claimed.id }, + "host export run superseded by a newer claim before it uploaded", + ) + return { status: "skipped" } + } await deps.storage.put(key, Buffer.concat(parts), { contentType: "text/csv; charset=utf-8", contentDisposition: `attachment; filename="${builder.filename(ctx)}"`, @@ -213,7 +252,8 @@ export function makeHostExportService(deps: HostExportServiceDeps): HostExportSe return { status: "ready" } } catch (err) { deps.logger?.error({ err, exportId: claimed.id }, "host export failed") - await deps.repo.markFailed(claimed.id, "build_failed") + await deps.repo.markFailed(claimed.id, "build_failed", claimed.runToken) + if (key !== null) await discardFailedObject(claimed, key) return { status: "failed" } } }, @@ -222,6 +262,9 @@ export function makeHostExportService(deps: HostExportServiceDeps): HostExportSe if (record.status !== "ready" || record.r2Key === null) { throw AppError.conflict("That export is not ready to download.") } + if (record.expiresAt !== null && record.expiresAt.getTime() <= now().getTime()) { + throw AppError.conflict("That export has expired.") + } const url = await deps.storage.presignGet(record.r2Key, EXPORT_DOWNLOAD_URL_TTL_SEC, { forceSigned: true, }) @@ -251,6 +294,40 @@ export function makeHostExportService(deps: HostExportServiceDeps): HostExportSe await deps.repo.markExpired(record.id) reaped += 1 } + const orphaned = await deps.repo.listOrphaned({ + staleBefore: new Date(now().getTime() - EXPORT_ABANDON_AFTER_MS), + limit, + }) + for (const record of orphaned) { + const errorCode = record.status === "queued" ? "not_started" : "build_failed" + // Fence first: failing the row under its run token makes a still-live run's markReady miss, so + // no ready row can end up naming the object deleted below. The key stays on the failed row + // until the delete succeeds. + if ( + record.status !== "failed" && + !(await deps.repo.markFailed(record.id, errorCode, record.runToken)) + ) { + continue + } + if (record.r2Key === null) { + reaped += 1 + continue + } + try { + await deps.storage.delete(record.r2Key) + } catch (err) { + deps.logger?.warn( + { err, exportId: record.id }, + "host export reap: orphaned object delete failed; row kept for the next pass", + ) + continue + } + const released = await deps.repo.releaseObject( + { id: record.id, status: "failed", runToken: record.runToken }, + errorCode, + ) + if (released) reaped += 1 + } return { reaped } }, } diff --git a/services/api/src/services/host/host-portfolio-repository.drizzle.ts b/services/api/src/services/host/host-portfolio-repository.drizzle.ts index c52bb6cf..ae842c0c 100644 --- a/services/api/src/services/host/host-portfolio-repository.drizzle.ts +++ b/services/api/src/services/host/host-portfolio-repository.drizzle.ts @@ -1,10 +1,11 @@ import type { CleanupMemberRole, CleanupStatus, + EventPageStatus, EventVisibility, OrganizationMemberRole, } from "@civfix/shared" -import type { Sql } from "../../db/client.js" +import type { Queryable, Sql } from "../../db/client.js" import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" import { publicServedKeyExpr } from "../media-served-key.js" import { cleanupStatusExpr } from "../cleanup-sql.js" @@ -25,6 +26,7 @@ export interface HostedEventRecord { orgId: string | null orgName: string | null pageSlug: string | null + pageStatus: EventPageStatus | null } export interface HostPortfolioKpiRecord { @@ -32,6 +34,16 @@ export interface HostPortfolioKpiRecord { upcomingEvents: number } +export interface HostPortfolioTotals { + totalRegistrations: number + totalCheckedIn: number +} + +export interface HostPortfolioTotalsArgs { + userId: string + organizationId: string | null +} + export interface ListHostedEventsArgs { userId: string when: "upcoming" | "past" | "all" @@ -69,6 +81,7 @@ interface HostedEventRowSelect { org_id: string | null org_name: string | null page_slug: string | null + page_status: EventPageStatus | null } function toRecord(row: HostedEventRowSelect): HostedEventRecord { @@ -88,11 +101,12 @@ function toRecord(row: HostedEventRowSelect): HostedEventRecord { orgId: row.org_id, orgName: row.org_name, pageSlug: row.page_slug, + pageStatus: row.page_status, } } -export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepository { - const hostedIds = (userId: string) => sql`( +function hostedIds(sql: Queryable, userId: string) { + return sql`( SELECT m.cleanup_id AS id FROM cleanup_members m WHERE m.user_id = ${userId} AND m.role <> 'member' @@ -103,10 +117,45 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos JOIN cleanups oc ON oc.organization_id = om.organization_id WHERE om.user_id = ${userId} AND om.role <> 'member' )` +} - const orgFilter = (organizationId: string | null) => - organizationId !== null ? sql`AND c.organization_id = ${organizationId}` : sql`` +function orgFilter(sql: Queryable, organizationId: string | null) { + return organizationId !== null ? sql`AND c.organization_id = ${organizationId}` : sql`` +} +/** + * Portfolio-wide like eventsHosted (no `when` tab, no page bound), with the same registered and + * checked-in definitions hostedEventCounts uses per row. + */ +export async function hostedRegistrationTotals( + sql: Queryable, + args: HostPortfolioTotalsArgs, +): Promise { + const rows = await sql<{ total_registrations: number; total_checked_in: number }[]>` + WITH hosted AS ${hostedIds(sql, args.userId)}, + scoped AS ( + SELECT c.id FROM hosted h JOIN cleanups c ON c.id = h.id + WHERE TRUE ${orgFilter(sql, args.organizationId)} + ) + SELECT + COALESCE(( + SELECT sum(r.party_size)::int FROM cleanup_registrations r + WHERE r.cleanup_id IN (SELECT id FROM scoped) AND r.status = 'registered' + ), 0) AS total_registrations, + COALESCE(( + SELECT count(*)::int FROM cleanup_registration_seats s + WHERE s.cleanup_id IN (SELECT id FROM scoped) + AND s.status = 'active' AND s.checked_in_at IS NOT NULL + ), 0) AS total_checked_in + ` + const row = rows[0] + return { + totalRegistrations: row?.total_registrations ?? 0, + totalCheckedIn: row?.total_checked_in ?? 0, + } +} + +export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepository { return { async listHostedEvents( args: ListHostedEventsArgs, @@ -129,7 +178,7 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos ? sql`ORDER BY c.scheduled_at DESC, c.id DESC` : sql`ORDER BY c.scheduled_at ASC, c.id ASC` const rows = await sql` - WITH hosted AS ${hostedIds(args.userId)} + WITH hosted AS ${hostedIds(sql, args.userId)} SELECT c.id, c.reference_code, @@ -142,6 +191,7 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos ${publicServedKeyExpr(sql, "ma")} AS cover_key, c.capacity, c.page_slug, + p.status AS page_status, ( SELECT m.role FROM cleanup_members m WHERE m.cleanup_id = c.id AND m.user_id = ${args.userId} @@ -149,6 +199,7 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos ) AS event_role, ( SELECT om.role FROM organization_members om + JOIN organizations oo ON oo.id = om.organization_id AND oo.deleted_at IS NULL WHERE om.organization_id = c.organization_id AND om.user_id = ${args.userId} LIMIT 1 ) AS org_role, @@ -157,10 +208,11 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos FROM hosted h JOIN cleanups c ON c.id = h.id LEFT JOIN media_assets ma ON ma.id = c.cover_media_id + LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id LEFT JOIN organizations o ON o.id = c.organization_id AND o.deleted_at IS NULL WHERE TRUE ${whenFilter} - ${orgFilter(args.organizationId)} + ${orgFilter(sql, args.organizationId)} ${cursorFilter} ${order} LIMIT ${args.limit + 1} @@ -172,7 +224,7 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos async kpisFor(args: HostPortfolioKpisArgs): Promise { const rows = await sql<{ events_hosted: number; upcoming_events: number }[]>` - WITH hosted AS ${hostedIds(args.userId)} + WITH hosted AS ${hostedIds(sql, args.userId)} SELECT count(*)::int AS events_hosted, count(*) FILTER ( @@ -181,7 +233,7 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos FROM hosted h JOIN cleanups c ON c.id = h.id WHERE TRUE - ${orgFilter(args.organizationId)} + ${orgFilter(sql, args.organizationId)} ` const row = rows[0] return { diff --git a/services/api/src/services/host/host-portfolio-service.ts b/services/api/src/services/host/host-portfolio-service.ts index 595e7163..7d91e3f9 100644 --- a/services/api/src/services/host/host-portfolio-service.ts +++ b/services/api/src/services/host/host-portfolio-service.ts @@ -4,6 +4,8 @@ import type { EventMediaPresigner } from "./event-media.js" import type { HostedEventRecord, HostPortfolioRepository, + HostPortfolioTotals, + HostPortfolioTotalsArgs, } from "./host-portfolio-repository.drizzle.js" import { ZERO_HOSTED_EVENT_COUNTS, type HostedEventCounts } from "./portfolio-counts.js" import { isEventPubliclyVisible } from "./authz.js" @@ -15,9 +17,14 @@ export interface HostPortfolioCountsLoader { (cleanupIds: readonly string[]): Promise> } +export interface HostPortfolioTotalsLoader { + (args: HostPortfolioTotalsArgs): Promise +} + export interface HostPortfolioServiceDeps { repo: HostPortfolioRepository counts: HostPortfolioCountsLoader + totals: HostPortfolioTotalsLoader presignEventMedia?: EventMediaPresigner now?: () => Date } @@ -62,9 +69,10 @@ export function makeHostPortfolioService(deps: HostPortfolioServiceDeps): HostPo limit, }) const ids = items.map((r) => r.id) - const [counts, kpiBase] = await Promise.all([ + const [counts, kpiBase, totals] = await Promise.all([ deps.counts(ids), deps.repo.kpisFor({ userId, organizationId, now: now() }), + deps.totals({ userId, organizationId }), ]) const presign = deps.presignEventMedia @@ -77,13 +85,9 @@ export function makeHostPortfolioService(deps: HostPortfolioServiceDeps): HostPo ) const dtos: HostedEventDTO[] = [] - let totalRegistrations = 0 - let totalCheckedIn = 0 for (const [index, record] of items.entries()) { const standing = standingOf(record) const rowCounts = counts.get(record.id) ?? ZERO_HOSTED_EVENT_COUNTS - totalRegistrations += rowCounts.registered - totalCheckedIn += rowCounts.checkedIn const coverThumbUrl = coverUrls[index] ?? null dtos.push({ id: record.id, @@ -105,15 +109,15 @@ export function makeHostPortfolioService(deps: HostPortfolioServiceDeps): HostPo orgId: record.orgId, orgName: record.orgName, pageSlug: record.pageSlug, - pageStatus: null, + pageStatus: record.pageStatus, }) } const kpis: HostPortfolioKpis = { eventsHosted: kpiBase.eventsHosted, upcomingEvents: kpiBase.upcomingEvents, - totalRegistrations, - totalCheckedIn, + totalRegistrations: totals.totalRegistrations, + totalCheckedIn: totals.totalCheckedIn, } return { items: dtos, nextCursor, kpis } }, diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 3102765c..20a1b77e 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -1,5 +1,6 @@ import { AppError, + MAX_TEAM_INVITES_PER_EVENT, type CleanupMemberRole, type CleanupStatus, type EventTeamInviteStatus, @@ -7,7 +8,12 @@ import { type EventVisibility, } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" -import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { publicServedKeyExpr } from "../media-served-key.js" import { cleanupStatusExpr } from "../cleanup-sql.js" import { writeHostAudit } from "./host-audit.js" @@ -26,6 +32,7 @@ import type { PendingInviteForUserRecord, RevokeTeamInviteOutcome, } from "./host-team-repository.types.js" +import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" interface InviteRowSelect { id: string @@ -390,6 +397,15 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { } const open = await reofferOpenInvite(tx, args) if (open !== null) return open + // Serializes concurrent inviters on one event so the cap is counted, not raced. + await tx`SELECT pg_advisory_xact_lock(hashtext('team_invites:' || ${args.cleanupId}))` + const pending = await tx<{ count: number }[]>` + SELECT count(*)::int AS count FROM cleanup_team_invites + WHERE cleanup_id = ${args.cleanupId} AND status = 'pending' + ` + if ((pending[0]?.count ?? 0) >= MAX_TEAM_INVITES_PER_EVENT) { + throw AppError.conflict(TEAM_INVITE_CAP_MESSAGE) + } const inserted = await tx` WITH ins AS ( INSERT INTO cleanup_team_invites ( @@ -537,12 +553,15 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { async listInvitesForUser( args: ListInvitesForUserArgs, ): Promise<{ items: PendingInviteForUserRecord[]; nextCursor: string | null }> { - const cursor = parseTimeCursor(args.cursor) + const cursor = parseKeysetCursor(args.cursor) const cursorFilter = - cursor !== null ? sql`AND (i.created_at, i.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` - const rows = await sql` + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`i.created_at`, sql`i.id`, cursor)}` + : sql`` + const rows = await sql<(PendingInviteForUserRowSelect & { cursor_at: string })[]>` SELECT i.id, + ${keysetInstant(sql, sql`i.created_at`)} AS cursor_at, i.role, i.created_at, i.expires_at, @@ -570,9 +589,11 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { ORDER BY i.created_at DESC, i.id DESC LIMIT ${args.limit + 1} ` - return pageWith(rows.map(toPendingInviteForUser), args.limit, (last) => - encodeTimeCursor({ at: last.createdAt, id: last.id }), - ) + const page = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.id, + })) + return { items: page.items.map(toPendingInviteForUser), nextCursor: page.nextCursor } }, async acceptInviteByIdTx(args: { diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index 52f14fdd..5119fcd9 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto" +import { AppError, MAX_TEAM_INVITES_PER_EVENT } from "@civfix/shared" import type { CleanupMemberRole, CleanupStatus, @@ -24,6 +25,7 @@ import type { PendingInviteForUserRecord, RevokeTeamInviteOutcome, } from "./host-team-repository.types.js" +import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" interface StoredInvite { id: string @@ -279,6 +281,12 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { }) return Promise.resolve({ kind: "updated", invite: this.toInviteRecord(open) }) } + const pending = this.invites.filter( + (i) => i.cleanupId === args.cleanupId && i.status === "pending", + ).length + if (pending >= MAX_TEAM_INVITES_PER_EVENT) { + return Promise.reject(AppError.conflict(TEAM_INVITE_CAP_MESSAGE)) + } const invite: StoredInvite = { id: args.inviteId, cleanupId: args.cleanupId, diff --git a/services/api/src/services/host/host-team-repository.types.ts b/services/api/src/services/host/host-team-repository.types.ts index 050eeccd..5ad6abcc 100644 --- a/services/api/src/services/host/host-team-repository.types.ts +++ b/services/api/src/services/host/host-team-repository.types.ts @@ -7,6 +7,9 @@ import type { } from "@civfix/shared" import type { CleanupPersonView } from "../cleanup-repository.types.js" +export const TEAM_INVITE_CAP_MESSAGE = + "This event already has the maximum number of open invitations." + export interface EventTeamMemberRecord { person: CleanupPersonView role: CleanupMemberRole diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index a53ebed7..f8746a90 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto" import { AppError, - MAX_TEAM_INVITES_PER_EVENT, type AcceptEventTeamInviteResponse, type AcceptMyEventInviteResponse, type CleanupDTO, @@ -316,10 +315,6 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService "This event has sent too many team invitations today. Please try again tomorrow.", ) } - const pending = await deps.repo.countPendingInvites(cleanupId) - if (pending >= MAX_TEAM_INVITES_PER_EVENT) { - throw AppError.conflict("This event already has the maximum number of open invitations.") - } } const token = newToken() const outcome = await deps.repo.createInviteTx({ diff --git a/services/api/src/services/host/insights-service.ts b/services/api/src/services/host/insights-service.ts index 628c9d2a..a5ebb6f7 100644 --- a/services/api/src/services/host/insights-service.ts +++ b/services/api/src/services/host/insights-service.ts @@ -29,6 +29,7 @@ import type { HostRegistrationRepository, } from "./registration-repository.types.js" import { CHECKIN_COARSEN_DAYS } from "./registration-retention.js" +import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" export const INSIGHTS_LIVE_CACHE_TTL_SEC = 15 @@ -115,7 +116,7 @@ function clockOf(clock: EventClockRecord): EventInsightsClock { endsAt: clock.endsAt?.toISOString() ?? null, completedAt: clock.completedAt?.toISOString() ?? null, registrationClosesAt: clock.registrationClosesAt?.toISOString() ?? null, - timezone: clock.timezone ?? "UTC", + timezone: clock.timezone ?? DEFAULT_EVENT_TIME_ZONE, } } @@ -167,7 +168,7 @@ export function makeInsightsService(deps: InsightsServiceDeps): InsightsService const [counters, trend, bySource, broadcasts, hours, topVolunteers, returning] = await Promise.all([ deps.registrations.checkinCounters(cleanupId), - deps.analytics.seatTrend(cleanupId, clock.timezone ?? "UTC"), + deps.analytics.seatTrend(cleanupId, clock.timezone ?? DEFAULT_EVENT_TIME_ZONE), deps.analytics.registrationsBySource(cleanupId), deps.analytics.broadcastsForEvent(cleanupId, MAX_INSIGHTS_BROADCASTS), deps.analytics.eventHoursTotals(cleanupId), diff --git a/services/api/src/services/host/metrics-repository.drizzle.ts b/services/api/src/services/host/metrics-repository.drizzle.ts index 5ef8bb7e..919ab662 100644 --- a/services/api/src/services/host/metrics-repository.drizzle.ts +++ b/services/api/src/services/host/metrics-repository.drizzle.ts @@ -18,7 +18,7 @@ export interface MetricRow { export interface MetricsRepository { resolveSlug(slug: string): Promise<{ cleanupId: string; timezone: string | null } | null> eventTimezone(cleanupId: string): Promise - listRollupEvents(since: Date, limit: number): Promise + listRollupEvents(since: Date, after: string | null, limit: number): Promise recomputeFromSource(cleanupId: string, timezone: string, since: Date): Promise upsertExact(rows: readonly MetricUpsert[]): Promise upsertGreatest(rows: readonly MetricUpsert[]): Promise @@ -53,72 +53,82 @@ export function makeDrizzleMetricsRepository(sql: Sql): MetricsRepository { return rows[0]?.timezone ?? null }, - async listRollupEvents(since: Date, limit: number) { + async listRollupEvents(since: Date, after: string | null, limit: number) { + const afterFilter = after === null ? sql`` : sql`AND c.id > ${after}` const rows = await sql<{ id: string }[]>` - SELECT DISTINCT c.id + SELECT c.id FROM cleanups c - WHERE c.updated_at >= ${since} + WHERE (c.updated_at >= ${since} OR EXISTS ( SELECT 1 FROM cleanup_registrations r WHERE r.cleanup_id = c.id AND r.registered_at >= ${since}) OR EXISTS ( SELECT 1 FROM broadcasts b - WHERE b.cleanup_id = c.id AND b.created_at >= ${since}) + WHERE b.cleanup_id = c.id AND b.created_at >= ${since})) + ${afterFilter} ORDER BY c.id LIMIT ${limit}` return rows.map((r) => r.id) }, + /** + * upsertExact overwrites whole days, so the window must start at a local midnight: a mid-day + * instant would rewrite the oldest day in the window with only the part after that instant. + */ async recomputeFromSource(cleanupId: string, timezone: string, since: Date) { const rows = await sql<{ day: string; metric: string; bucket: string; n: string }[]>` + WITH bound AS ( + SELECT date_trunc('day', ${since}::timestamptz AT TIME ZONE ${timezone}) + AT TIME ZONE ${timezone} AS since + ) SELECT to_char((r.registered_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD') AS day, 'registrations' AS metric, '' AS bucket, count(*)::text AS n FROM cleanup_registrations r - WHERE r.cleanup_id = ${cleanupId} AND r.registered_at >= ${since} + WHERE r.cleanup_id = ${cleanupId} AND r.registered_at >= (SELECT since FROM bound) GROUP BY 1 UNION ALL SELECT to_char((r.cancelled_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'cancellations', '', count(*)::text FROM cleanup_registrations r - WHERE r.cleanup_id = ${cleanupId} AND r.cancelled_at IS NOT NULL AND r.cancelled_at >= ${since} + WHERE r.cleanup_id = ${cleanupId} AND r.cancelled_at IS NOT NULL AND r.cancelled_at >= (SELECT since FROM bound) GROUP BY 1 UNION ALL SELECT to_char((s.checked_in_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'checkins', '', count(*)::text FROM cleanup_registration_seats s - WHERE s.cleanup_id = ${cleanupId} AND s.checked_in_at IS NOT NULL AND s.checked_in_at >= ${since} + WHERE s.cleanup_id = ${cleanupId} AND s.checked_in_at IS NOT NULL AND s.checked_in_at >= (SELECT since FROM bound) GROUP BY 1 UNION ALL SELECT to_char((s.no_show_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'no_shows', '', count(*)::text FROM cleanup_registration_seats s - WHERE s.cleanup_id = ${cleanupId} AND s.no_show_at IS NOT NULL AND s.no_show_at >= ${since} + WHERE s.cleanup_id = ${cleanupId} AND s.no_show_at IS NOT NULL AND s.no_show_at >= (SELECT since FROM bound) GROUP BY 1 UNION ALL SELECT to_char((w.created_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'waitlist_joined', '', count(*)::text FROM cleanup_waitlist w - WHERE w.cleanup_id = ${cleanupId} AND w.created_at >= ${since} + WHERE w.cleanup_id = ${cleanupId} AND w.created_at >= (SELECT since FROM bound) GROUP BY 1 UNION ALL SELECT to_char((b.finished_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'broadcast_recipients', '', sum(b.recipient_count)::text FROM broadcasts b - WHERE b.cleanup_id = ${cleanupId} AND b.finished_at IS NOT NULL AND b.finished_at >= ${since} + WHERE b.cleanup_id = ${cleanupId} AND b.finished_at IS NOT NULL AND b.finished_at >= (SELECT since FROM bound) GROUP BY 1 UNION ALL SELECT to_char((d.created_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'broadcast_' || d.status, d.channel, count(*)::text FROM broadcast_deliveries d JOIN broadcasts b ON b.id = d.broadcast_id - WHERE b.cleanup_id = ${cleanupId} AND d.created_at >= ${since} + WHERE b.cleanup_id = ${cleanupId} AND d.created_at >= (SELECT since FROM bound) AND d.status IN ('sent','failed','suppressed') GROUP BY 1, 2, 3 UNION ALL SELECT to_char((bu.created_at AT TIME ZONE ${timezone})::date, 'YYYY-MM-DD'), 'unsubscribes', '', count(*)::text FROM broadcast_unsubscribes bu - WHERE bu.cleanup_id = ${cleanupId} AND bu.created_at >= ${since} + WHERE bu.cleanup_id = ${cleanupId} AND bu.created_at >= (SELECT since FROM bound) GROUP BY 1` return rows.map((row) => ({ cleanupId, diff --git a/services/api/src/services/host/metrics-service.ts b/services/api/src/services/host/metrics-service.ts index a2262e01..2c1221d2 100644 --- a/services/api/src/services/host/metrics-service.ts +++ b/services/api/src/services/host/metrics-service.ts @@ -3,6 +3,7 @@ import type { FastifyBaseLogger } from "fastify" import type { CacheClient } from "../../auth/cache.js" import type { MetricUpsert, MetricsRepository } from "./metrics-repository.drizzle.js" import { eventDayKey } from "./event-day.js" +import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" export { eventDayKey } @@ -12,6 +13,10 @@ export const METRIC_DONATION_CLICKS = "donation_clicks" export const COUNTER_TTL_SEC = 4 * 24 * 60 * 60 +export const ROLLUP_PAGE_SIZE = 500 + +const DAY_MS = 86_400_000 + const COUNTER_PREFIX = "evm:v1" const BOT_UA_RE = @@ -89,6 +94,7 @@ function hostOf(referrer: string | undefined): string | null { try { return new URL(referrer).hostname.toLowerCase() } catch { + // An unparseable Referer is client-controlled noise; it is counted as a direct visit. return null } } @@ -134,7 +140,7 @@ export function makeMetricsService(deps: MetricsServiceDeps): MetricsService { metric: string, bucket: string, ): Promise { - const day = eventDayKey(now(), timezone) + const day = eventDayKey(now(), timezone ?? DEFAULT_EVENT_TIME_ZONE) const key = counterKey(cleanupId, day, metric, bucket) try { await deps.cache.incr(key, COUNTER_TTL_SEC) @@ -170,9 +176,10 @@ export function makeMetricsService(deps: MetricsServiceDeps): MetricsService { }, async flushCounters() { + // A bump keys its dirty set by the event-local day, which east of UTC is already tomorrow. const days: string[] = [] - for (let i = 0; i <= deps.lookbackDays; i += 1) { - days.push(new Date(now().getTime() - i * 86_400_000).toISOString().slice(0, 10)) + for (let i = -1; i <= deps.lookbackDays; i += 1) { + days.push(new Date(now().getTime() - i * DAY_MS).toISOString().slice(0, 10)) } const upserts: MetricUpsert[] = [] for (const day of days) { @@ -203,17 +210,25 @@ export function makeMetricsService(deps: MetricsServiceDeps): MetricsService { }, async rollup() { - const since = new Date(now().getTime() - deps.lookbackDays * 86_400_000) - const cleanupIds = await deps.repo.listRollupEvents(since, 500) + const since = new Date(now().getTime() - deps.lookbackDays * DAY_MS) + let after: string | null = null + let events = 0 let rows = 0 - for (const cleanupId of cleanupIds) { - const timezone = (await deps.repo.eventTimezone(cleanupId)) ?? "UTC" - const computed = await deps.repo.recomputeFromSource(cleanupId, timezone, since) - await deps.repo.upsertExact(computed) - rows += computed.length - await new Promise((resolve) => setImmediate(resolve)) + for (;;) { + const cleanupIds = await deps.repo.listRollupEvents(since, after, ROLLUP_PAGE_SIZE) + for (const cleanupId of cleanupIds) { + const timezone = (await deps.repo.eventTimezone(cleanupId)) ?? DEFAULT_EVENT_TIME_ZONE + const computed = await deps.repo.recomputeFromSource(cleanupId, timezone, since) + await deps.repo.upsertExact(computed) + rows += computed.length + await new Promise((resolve) => setImmediate(resolve)) + } + events += cleanupIds.length + const last = cleanupIds.at(-1) + if (cleanupIds.length < ROLLUP_PAGE_SIZE || last === undefined) break + after = last } - return { events: cleanupIds.length, rows } + return { events, rows } }, } } diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 90c8eb01..ca5900fc 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -6,10 +6,19 @@ import type { OrgVerificationStatus, SocialLinks, } from "@civfix/shared" -import { AppError } from "@civfix/shared" +import { AppError, MAX_ORG_INVITES_PER_ORG } from "@civfix/shared" import type postgres from "postgres" import type { Queryable, Sql } from "../../db/client.js" -import { encodeTimeCursor, isUuid, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" +import { + encodeTimeCursor, + isUuid, + keysetInstant, + keysetPredicate, + pageWith, + paginateKeyset, + parseKeysetCursor, + parseTimeCursor, +} from "../../db/cursor-helpers.js" import { likeContains } from "../admin/like.js" import { publicServedKeyExpr } from "../media-served-key.js" import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" @@ -62,6 +71,7 @@ import { inviterRevocationReason, roleChangeWithdrawsInvites, } from "./organization-repository.types.js" +import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" const PG_UNIQUE_VIOLATION = "23505" @@ -358,6 +368,13 @@ async function countAdminSeats(tx: Queryable, organizationId: string): Promise { - const cursor = parseTimeCursor(args.cursor) - const cursorFilter = - cursor !== null - ? sql`AND (m.joined_at, m.user_id) > (${cursor.at}, ${cursor.id}::uuid)` - : sql`` const rows = await sql< { user_id: string @@ -588,18 +600,24 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit avatar_url: string | null role: OrganizationMemberRole joined_at: Date + cursor_at: string }[] >` - SELECT m.user_id, u.display_name, u.handle, u.bio, u.avatar_url, m.role, m.joined_at + SELECT m.user_id, u.display_name, u.handle, u.bio, u.avatar_url, m.role, m.joined_at, + ${keysetInstant(sql, sql`m.joined_at`)} AS cursor_at FROM organization_members m JOIN users u ON u.id = m.user_id WHERE m.organization_id = ${args.organizationId} - ${cursorFilter} + ${memberCursorFilter(args.cursor)} ORDER BY m.joined_at ASC, m.user_id ASC LIMIT ${args.limit + 1} ` - return pageWith( - rows.map((r) => ({ + const page = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.user_id, + })) + return { + items: page.items.map((r) => ({ person: { id: r.user_id, displayName: r.display_name, @@ -610,9 +628,8 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit role: r.role, joinedAt: r.joined_at, })), - args.limit, - (last) => encodeTimeCursor({ at: last.joinedAt, id: last.person.id }), - ) + nextCursor: page.nextCursor, + } }, async findMember( @@ -729,9 +746,9 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit if (inserted.length === 0) return "already_member" await writeHostAudit(tx, { actorId: args.actorId, - action: "org.member_role_changed", + action: "org.member_added", target: `organization:${args.organizationId}`, - meta: { targetUserId: args.userId, from: null, to: args.role }, + meta: { targetUserId: args.userId, role: args.role, via: "handle" }, }) return "added" }) @@ -1012,7 +1029,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit nextCursor: string | null counts: AdminOrganizationCounts | null }> { - const cursor = parseTimeCursor(query.cursor) + const cursor = parseKeysetCursor(query.cursor) const q = query.q?.trim() ?? "" const qFilter = q.length > 0 @@ -1030,9 +1047,12 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit ? sql`AND o.suspended_at IS NOT NULL` : sql`AND o.suspended_at IS NULL` const cursorFilter = - cursor !== null ? sql`AND (o.created_at, o.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` - const rows = await sql` - SELECT ${adminOrganizationColumns(sql)} + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`o.created_at`, sql`o.id`, cursor)}` + : sql`` + const rows = await sql<(AdminOrganizationRowSelect & { cursor_at: string })[]>` + SELECT ${adminOrganizationColumns(sql)}, + ${keysetInstant(sql, sql`o.created_at`)} AS cursor_at FROM organizations o ${adminOrganizationJoins(sql)} WHERE o.deleted_at IS NULL @@ -1044,9 +1064,14 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit ORDER BY o.created_at DESC, o.id DESC LIMIT ${query.limit + 1} ` - const page = pageWith(rows.map(toAdminOrganizationRecord), query.limit, (last) => - encodeTimeCursor({ at: last.createdAt, id: last.id }), - ) + const keyed = paginateKeyset(rows, query.limit, (last) => ({ + atText: last.cursor_at, + id: last.id, + })) + const page = { + items: keyed.items.map(toAdminOrganizationRecord), + nextCursor: keyed.nextCursor, + } // Facet counts span the SEARCHED set but ignore the facets, and only on page one (the shared // admin-list policy: the console reads the chip numbers off the first page). let counts: AdminOrganizationCounts | null = null @@ -1103,11 +1128,6 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit cursor: string | null limit: number }): Promise<{ items: AdminOrgMemberRecord[]; nextCursor: string | null }> { - const cursor = parseTimeCursor(args.cursor) - const cursorFilter = - cursor !== null - ? sql`AND (m.joined_at, m.user_id) > (${cursor.at}, ${cursor.id}::uuid)` - : sql`` const rows = await sql< { user_id: string @@ -1116,25 +1136,30 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit created_at: Date role: OrganizationMemberRole joined_at: Date + cursor_at: string }[] >` - SELECT m.user_id, u.display_name, u.handle, u.created_at, m.role, m.joined_at + SELECT m.user_id, u.display_name, u.handle, u.created_at, m.role, m.joined_at, + ${keysetInstant(sql, sql`m.joined_at`)} AS cursor_at FROM organization_members m JOIN users u ON u.id = m.user_id WHERE m.organization_id = ${args.organizationId} - ${cursorFilter} + ${memberCursorFilter(args.cursor)} ORDER BY m.joined_at ASC, m.user_id ASC LIMIT ${args.limit + 1} ` - return pageWith( - rows.map((r) => ({ + const page = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.user_id, + })) + return { + items: page.items.map((r) => ({ user: { id: r.user_id, name: r.display_name, handle: r.handle, joined: r.created_at }, role: r.role, joinedAt: r.joined_at, })), - args.limit, - (last) => encodeTimeCursor({ at: last.joinedAt, id: last.user.id }), - ) + nextCursor: page.nextCursor, + } }, async adminAddMemberTx(args: AdminAddMemberArgs): Promise { @@ -1259,10 +1284,23 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit args: CreateOrganizationInviteArgs, ): Promise { return sql.begin(async (tx): Promise => { + // The organization row lock also serializes concurrent inviters on one org (this is the only + // insert into organization_invites), so the cap below is counted, not raced. if (!(await lockOrgForActorIn(tx, args.organizationId, args.invitedBy))) { return { kind: "forbidden" } } await expireInvitesInTx(tx, args.organizationId, args.now) + // The cap is checked before the idempotent re-invite path on purpose: it must not depend on + // the address. + const pending = await tx<{ count: number }[]>` + SELECT count(*)::int AS count FROM organization_invites + WHERE organization_id = ${args.organizationId} + AND status = 'pending' + AND expires_at > ${args.now} + ` + if (Number(pending[0]?.count ?? 0) >= MAX_ORG_INVITES_PER_ORG) { + throw AppError.conflict(ORG_INVITE_CAP_MESSAGE) + } const inserted = await tx<{ id: string }[]>` INSERT INTO organization_invites ( id, organization_id, email, user_id, role, token_hash, status, invited_by, created_at, diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index a6ab0850..79b12007 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto" +import { AppError, MAX_ORG_INVITES_PER_ORG } from "@civfix/shared" import type { OrganizationInviteRole, OrganizationInviteStatus, @@ -52,6 +53,7 @@ import { inviterRevocationReason, roleChangeWithdrawsInvites, } from "./organization-repository.types.js" +import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" interface StoredOrganization { id: string @@ -463,7 +465,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { }) this.audits.push({ actorId: args.actorId, - action: "org.member_role_changed", + action: "org.member_added", target: `organization:${args.organizationId}`, }) return Promise.resolve("added") @@ -951,6 +953,15 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve({ kind: "forbidden" }) } this.expireInvites(args.organizationId, args.now) + const pending = this.invites.filter( + (i) => + i.organizationId === args.organizationId && + i.status === "pending" && + i.expiresAt.getTime() > args.now.getTime(), + ).length + if (pending >= MAX_ORG_INVITES_PER_ORG) { + return Promise.reject(AppError.conflict(ORG_INVITE_CAP_MESSAGE)) + } const email = args.email.toLowerCase() const open = this.invites.find( (i) => diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index d32a1949..84ed0d6a 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -15,6 +15,9 @@ export function canManageOrgMembers(role: OrganizationMemberRole | null): boolea return role !== null && can({ eventRole: null, orgRole: role }, "manage_org_members") } +export const ORG_INVITE_CAP_MESSAGE = + "This organization already has the maximum number of open invitations." + /** * A role change that takes away the power to invite withdraws the invites already sent with it, so * they stop showing up as open in the inviter's org and the invitee's inbox. diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index 9e91ecb3..8e11918f 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto" import { AppError, - MAX_ORG_INVITES_PER_ORG, MAX_ORG_VERIFICATION_DOCUMENTS, type AcceptOrganizationInviteResponse, type AdminActorRef, @@ -823,12 +822,6 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza // that already belongs to a member gets the same pending row; accepting closes it as a no-op. if (input.identifierKind === "email") { const email = input.identifier.toLowerCase() - const pending = await deps.repo.countPendingInvites(id, now()) - if (pending >= MAX_ORG_INVITES_PER_ORG) { - throw AppError.conflict( - "This organization already has the maximum number of open invitations.", - ) - } const token = newToken() const at = now() const outcome = await deps.repo.createInviteTx({ diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index 51a25627..32a57048 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -1,4 +1,4 @@ -import { AppError, MAX_EVENT_PAGE_BLOCKS } from "@civfix/shared" +import { AppError, MAX_EVENT_PAGE_BLOCKS, PAGE_SLUG_MAX, PageSlugSchema } from "@civfix/shared" import type { CheckEventPageSlugRequest, CheckEventPageSlugResponse, @@ -27,6 +27,20 @@ export const HOST_PAGE_PUBLISH_PER_DAY = 20 export const DAY_SECONDS = 24 * 60 * 60 +const RESERVED_SLUG_SUFFIXES = ["-event", "-2", "-3"] as const + +const TAKEN_SLUG_SUFFIXES = ["-2", "-3", "-4"] as const + +function pageUnderReviewError(): AppError { + return AppError.forbidden("This page is under review and cannot be published.") +} + +function slugWithSuffix(slug: string, suffix: string): string | null { + const base = slug.slice(0, PAGE_SLUG_MAX - suffix.length).replace(/-+$/u, "") + const candidate = `${base}${suffix}` + return PageSlugSchema.safeParse(candidate).success ? candidate : null +} + export interface PageServiceDeps { repo: HostRegistrationRepository presignCover?: (r2Key: string) => Promise<{ url: string }> @@ -228,6 +242,7 @@ export function makePageService(deps: PageServiceDeps): PageService { async function coverUrlOf(record: PageRecord): Promise { if (record.coverKey === null || deps.presignCover === undefined) return null + // A signing failure costs the page its image, never the page: presigning is decoration. try { return (await deps.presignCover(record.coverKey)).url } catch (err) { @@ -242,13 +257,7 @@ export function makePageService(deps: PageServiceDeps): PageService { ): Promise { const ids = blockMediaIds(blocks) if (ids.length === 0 || deps.presignCover === undefined) return [...blocks] - let keys: Map - try { - keys = await deps.repo.mediaKeysFor(cleanupId, ids) - } catch (err) { - deps.logger?.warn({ err }, "event page: block media lookup failed (suppressed)") - return [...blocks] - } + const keys = await deps.repo.mediaKeysFor(cleanupId, ids) const urls = new Map() await mapWithLimit([...keys.entries()], PRESIGN_CONCURRENCY, async ([id, key]) => { try { @@ -295,6 +304,19 @@ export function makePageService(deps: PageServiceDeps): PageService { ) } + async function freeSlugSuggestion( + cleanupId: string, + slug: string, + suffixes: readonly string[], + ): Promise { + for (const suffix of suffixes) { + const candidate = slugWithSuffix(slug, suffix) + if (candidate === null || RESERVED_SLUGS.has(candidate)) continue + if (!(await deps.repo.slugTaken(cleanupId, candidate))) return candidate + } + return null + } + async function reservePublishBudget(actorId: string): Promise { if (deps.counters === undefined) return let used: number @@ -349,7 +371,6 @@ export function makePageService(deps: PageServiceDeps): PageService { }, async publish(input, actorId): Promise { - await reservePublishBudget(actorId) const current = await deps.repo.getPage(input.id) if (current === null) throw AppError.notFound("Cleanup not found") if (input.published) { @@ -359,18 +380,19 @@ export function makePageService(deps: PageServiceDeps): PageService { if (current.blocks.length === 0) { throw AppError.validation({ blocks: "add at least one block before publishing" }) } - if (current.flaggedAt !== null) { - throw AppError.forbidden("This page is under review and cannot be published.") - } + if (current.flaggedAt !== null) throw pageUnderReviewError() } - const record = await deps.repo.publishPage({ + await reservePublishBudget(actorId) + const outcome = await deps.repo.publishPage({ cleanupId: input.id, published: input.published, actorId, now: now(), }) - if (record === null) throw AppError.notFound("Cleanup not found") + if (outcome.kind === "not_found") throw AppError.notFound("Cleanup not found") + if (outcome.kind === "flagged") throw pageUnderReviewError() + const record = outcome.record await deps.audit?.({ actorId, action: "event.page_published", @@ -382,11 +404,19 @@ export function makePageService(deps: PageServiceDeps): PageService { async checkSlug(query): Promise { if (RESERVED_SLUGS.has(query.slug)) { - return { available: false, reason: "reserved", suggestion: `${query.slug}-event` } + return { + available: false, + reason: "reserved", + suggestion: await freeSlugSuggestion(query.id, query.slug, RESERVED_SLUG_SUFFIXES), + } } const taken = await deps.repo.slugTaken(query.id, query.slug) if (!taken) return { available: true, reason: null, suggestion: null } - return { available: false, reason: "taken", suggestion: `${query.slug}-2` } + return { + available: false, + reason: "taken", + suggestion: await freeSlugSuggestion(query.id, query.slug, TAKEN_SLUG_SUFFIXES), + } }, async getPublicEventPage(query, viewerUserId): Promise { diff --git a/services/api/src/services/host/question-service.ts b/services/api/src/services/host/question-service.ts index aeb0321a..1f1f358b 100644 --- a/services/api/src/services/host/question-service.ts +++ b/services/api/src/services/host/question-service.ts @@ -93,6 +93,14 @@ export function makeQuestionService(deps: QuestionServiceDeps): QuestionService } } + const typeIds = desired.map((q) => q.ticketTypeId).filter((id): id is string => id !== null) + if (typeIds.length > 0) { + const own = new Set((await deps.repo.listTicketTypes(input.id)).map((type) => type.id)) + if (typeIds.some((id) => !own.has(id))) { + throw AppError.validation({ ticketTypeId: "not a ticket type on this event" }) + } + } + const records = await deps.repo.reconcileQuestions(input.id, desired, now()) return { items: records.map(toEventQuestionDTO) } }, diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 14ec9084..9bffe939 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -3,13 +3,16 @@ import type { CheckinMethod, EventVisibility, RegistrationRosterSort } from "@ci import type { Queryable, Sql, TransactionSql } from "../../db/client.js" import { constantTimeStringEqual } from "../../auth/crypto.js" import { + encodeKeysetCursor, encodeNameCursor, - encodeTimeCursor, + keysetInstant, + keysetPredicate, pageWith, + paginateKeyset, + parseKeysetCursor, parseNameCursor, - parseTimeCursor, } from "../../db/cursor-helpers.js" -import { eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" +import { DEFAULT_EVENT_DURATION_MS, eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" import { cleanupStatusExpr } from "../cleanup-sql.js" import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "../media-bindings.js" import { userUploader } from "../media-uploader.js" @@ -62,6 +65,7 @@ import type { JoinWaitlistOutcome, PageRecord, PublicPageRecord, + PublishPageOutcome, QuestionRecord, RegisterSnapshot, RegisterTxArgs, @@ -90,20 +94,66 @@ export const REGISTER_IDEMPOTENCY_SCOPE = "event.register" export const ARRIVAL_BUCKET_MINUTES = 15 +const DEFAULT_EVENT_DURATION_SEC = DEFAULT_EVENT_DURATION_MS / 1000 + +export const SLOT_NOT_FOUND_MESSAGE = "That slot no longer exists." + +export const SLOT_FULL_MESSAGE = "That slot is already full." + const ACTIVE_REGISTRATION_CONSTRAINTS = [ "cleanup_registrations_active_user_uidx", "cleanup_registrations_active_guest_uidx", ] +const rosterCheckedInKey = (tag: Sql) => tag`COALESCE(ci.first_at, 'epoch'::timestamptz)` + const ROSTER_SORTS = Object.freeze({ registered_at_desc: (tag: Sql) => tag`r.registered_at DESC, r.id DESC`, registered_at_asc: (tag: Sql) => tag`r.registered_at ASC, r.id ASC`, name_asc: (tag: Sql) => tag`lower(COALESCE(u.display_name, g.name, '')) ASC, r.registered_at DESC, r.id DESC`, checked_in_at_desc: (tag: Sql) => - tag`COALESCE(ci.first_at, 'epoch'::timestamptz) DESC, r.registered_at DESC, r.id DESC`, + tag`${rosterCheckedInKey(tag)} DESC, r.registered_at DESC, r.id DESC`, }) satisfies Readonly unknown>> +/** + * Both instants are the microsecond text Postgres rendered (or a legacy cursor spelled), bound back as + * text: a Date anchor drops the microseconds and skips every row sharing the anchor's millisecond. + */ +interface CheckedInRosterCursor { + checkedInAtText: string + registeredAtText: string | null + id: string +} + +type RosterRowSelect = RegistrationRowSelect & { cursor_at: string; checked_in_cursor_at: string } + +// Carries every ORDER BY key: everyone not yet checked in shares the epoch sort key, so a cursor +// without registered_at cannot say where inside that tie the previous page stopped. +function encodeCheckedInRosterCursor(row: RosterRowSelect): string { + return `${row.checked_in_cursor_at}|${encodeKeysetCursor(row.cursor_at, row.id)}` +} + +function parseCheckedInRosterCursor(cursor: string): CheckedInRosterCursor | null { + const parts = cursor.split("|") + if (parts.length <= 2) { + const legacy = parseKeysetCursor(cursor, { direction: "desc" }) + return legacy === null + ? null + : { checkedInAtText: legacy.atText, registeredAtText: null, id: legacy.id } + } + if (parts.length !== 3) return null + const [checkedInText, registeredText, id] = parts + const checkedIn = parseKeysetCursor(`${checkedInText}|${id}`) + const registered = parseKeysetCursor(`${registeredText}|${id}`) + if (checkedIn === null || registered === null) return null + return { + checkedInAtText: checkedIn.atText, + registeredAtText: registered.atText, + id: registered.id, + } +} + async function isBannedIn(tag: Queryable, cleanupId: string, userId: string): Promise { const banned = await tag<{ one: number }[]>` SELECT 1 AS one FROM cleanup_bans @@ -113,6 +163,60 @@ async function isBannedIn(tag: Queryable, cleanupId: string, userId: string): Pr return banned.length > 0 } +/** + * Takes the slot row lock before counting, as the standalone slot claim in cleanup-repository does, so + * registrations on different ticket types (which share no other lock) cannot both fill the last place. + * A refusal throws so the whole registration rolls back rather than succeeding without the slot. + */ +async function claimSlotIn( + tx: TransactionSql, + args: { cleanupId: string; userId: string; slotId: string }, +): Promise { + const slots = await tx<{ capacity: number | null }[]>` + SELECT capacity FROM cleanup_slots + WHERE id = ${args.slotId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 + FOR UPDATE + ` + const slot = slots[0] + if (slot === undefined) throw AppError.notFound(SLOT_NOT_FOUND_MESSAGE) + + const mine = await tx<{ slot_id: string }[]>` + SELECT slot_id FROM cleanup_slot_claims + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} + LIMIT 1 + ` + if (mine[0]?.slot_id === args.slotId) return + + if (slot.capacity !== null) { + const counted = await tx<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_slot_claims WHERE slot_id = ${args.slotId} + ` + if ((counted[0]?.n ?? 0) >= slot.capacity) throw AppError.conflict(SLOT_FULL_MESSAGE) + } + + await tx` + INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id) + VALUES (${args.cleanupId}, ${args.userId}, ${args.slotId}) + ON CONFLICT (cleanup_id, user_id) + DO UPDATE SET slot_id = EXCLUDED.slot_id, claimed_at = now() + ` +} + +/** + * An EXISTS probe, not a join: a join under FOR UPDATE would also lock the cleanups row and invert the + * cleanups -> cleanup_ticket_types -> cleanup_waitlist lock order every writer takes. The end instant + * mirrors hasEventEnded (ends_at, else scheduled_at plus the default duration) on the DB clock. + */ +function waitlistEventStillLive(tag: Queryable) { + return tag`EXISTS ( + SELECT 1 FROM cleanups c WHERE c.id = w.cleanup_id + AND c.status <> 'cancelled' + AND COALESCE(c.ends_at, c.scheduled_at + make_interval(secs => ${DEFAULT_EVENT_DURATION_SEC})) + > now() + )` +} + export async function cancelWaitlistEntriesIn( tag: Queryable, args: { @@ -657,11 +761,13 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio args.status === null || args.status === "waiting" || args.status === "offered" ? tag`AND ${waitlistEntryNotBanned(tag)}` : tag`` - const cursor = parseTimeCursor(args.cursor, { direction: "asc" }) + const cursor = parseKeysetCursor(args.cursor, { direction: "asc" }) const cursorFilter = - cursor === null ? tag`` : tag`AND (w.created_at, w.id) > (${cursor.at}, ${cursor.id}::uuid)` - const rows = await tag` - SELECT ${waitlistColumns(tag)} + cursor === null + ? tag`` + : tag`AND ${keysetPredicate(tag, tag`w.created_at`, tag`w.id`, cursor, { direction: "asc" })}` + const rows = await tag<(WaitlistRowSelect & { cursor_at: string })[]>` + SELECT ${waitlistColumns(tag)}, ${keysetInstant(tag, tag`w.created_at`)} AS cursor_at FROM cleanup_waitlist w ${waitlistJoins(tag)} WHERE w.cleanup_id = ${args.cleanupId} @@ -672,9 +778,10 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ORDER BY w.created_at ASC, w.id ASC LIMIT ${args.limit + 1} ` - const { items, nextCursor } = pageWith(rows, args.limit, (last) => - encodeTimeCursor({ at: last.created_at, id: last.id }), - ) + const { items, nextCursor } = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.id, + })) return { rows: items.map(toWaitlistRecord), nextCursor } } @@ -753,9 +860,10 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ): Promise { if (err instanceof RegistrationRefusal) return err.outcome if (isUniqueViolationOn(err, "idempotency_key_scope_owner_uk")) return replaySnapshot(sql, args) - if (isUniqueViolationOn(err, ...ACTIVE_REGISTRATION_CONSTRAINTS)) { - return { kind: "already_registered" } - } + // A concurrent twin carrying the same key blocks on the active-registration index and fails + // there before its idempotency insert can; once the winner commits its snapshot is readable. + if (isUniqueViolationOn(err, ...ACTIVE_REGISTRATION_CONSTRAINTS)) + return replaySnapshot(sql, args) if (isReservedSeatsBackstopViolation(err)) { throw AppError.internal( "Registration could not be completed. The capacity guard rejected the write.", @@ -766,6 +874,13 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio async function registerIn(tx: TransactionSql, args: RegisterTxArgs): Promise { const partySize = args.seats.length + // A same-key twin (a double tap) waits here until the first attempt commits, then reads its + // snapshot and replays; without it the twin could queue on a seat lock and answer "full" to the + // user who holds the seat. Only twins share this lock, and it precedes every lock taken below, so + // it adds no lock-order edge. + await tx` + SELECT pg_advisory_xact_lock(hashtext('register_idempotency:' || ${registerIdempotencyKey(args)})) + ` const locked = await tx< { status: EventRegistrationContext["status"] @@ -783,6 +898,11 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) { return { kind: "not_found" as const } } + + // A retry of a committed registration replays it even when a gate below has closed since. + const replay = await findRegisterSnapshot(tx, args) + if (replay !== undefined) return replayOf(tx, args, replay) + if (event.status === "cancelled") return { kind: "closed" as const } if (!withinSalesWindow(args.now, event.registration_opens_at, event.registration_closes_at)) { return { kind: "registration_closed" as const } @@ -795,9 +915,6 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio return { kind: "banned" as const } } - const replay = await findRegisterSnapshot(tx, args) - if (replay !== undefined) return replayOf(tx, args, replay) - const active = await tx<{ id: string }[]>` SELECT id FROM cleanup_registrations WHERE cleanup_id = ${args.cleanupId} @@ -877,14 +994,6 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio } } - if (args.subject.kind === "user") { - await tx` - INSERT INTO cleanup_members (cleanup_id, user_id, role) - VALUES (${args.cleanupId}, ${args.subject.userId}, 'member') - ON CONFLICT (cleanup_id, user_id) DO NOTHING - ` - } - if (ticketType !== null && args.waitlistId === null) { const reserved = await tx<{ id: string }[]>` UPDATE cleanup_ticket_types @@ -902,6 +1011,24 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio } } + // Ticket type, then slot, then member and registration rows: the standalone slot claim takes the + // slot before its member and registration writes, so the reverse order here could deadlock. + if (args.slotId !== null && args.subject.kind === "user") { + await claimSlotIn(tx, { + cleanupId: args.cleanupId, + userId: args.subject.userId, + slotId: args.slotId, + }) + } + + if (args.subject.kind === "user") { + await tx` + INSERT INTO cleanup_members (cleanup_id, user_id, role) + VALUES (${args.cleanupId}, ${args.subject.userId}, 'member') + ON CONFLICT (cleanup_id, user_id) DO NOTHING + ` + } + const insertedRegistration = await tx<{ id: string }[]>` INSERT INTO cleanup_registrations ( cleanup_id, ticket_type_id, user_id, guest_id, party_size, status, source, registered_at @@ -991,22 +1118,6 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ` } - if (args.slotId !== null && args.subject.kind === "user") { - await tx` - INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id) - SELECT ${args.cleanupId}, ${args.subject.userId}, s.id - FROM cleanup_slots s - WHERE s.id = ${args.slotId} - AND s.cleanup_id = ${args.cleanupId} - AND ( - s.capacity IS NULL - OR (SELECT count(*) FROM cleanup_slot_claims c WHERE c.slot_id = s.id) < s.capacity - ) - ON CONFLICT (cleanup_id, user_id) - DO UPDATE SET slot_id = EXCLUDED.slot_id, claimed_at = now() - ` - } - if (args.waitlistId !== null) { await tx` UPDATE cleanup_waitlist @@ -1466,8 +1577,21 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio now: args.now, } const outcome = await registerIn(tx, registerArgs) - if (outcome.kind === "registered") return outcome - throw new RegistrationRefusal(outcome) + if (outcome.kind !== "registered") throw new RegistrationRefusal(outcome) + if (args.checkIn === undefined || args.checkIn === null) return outcome + await tx` + UPDATE cleanup_registration_seats + SET checked_in_at = ${args.now}, + checked_in_by = ${args.checkIn.actorId}, + checkin_method = ${args.checkIn.method} + WHERE registration_id = ${outcome.registration.id} + AND cleanup_id = ${args.cleanupId} + AND status = 'active' + AND checked_in_at IS NULL + ` + const checkedIn = await loadRegistrationById(tx, args.cleanupId, outcome.registration.id) + if (checkedIn === null) throw new Error("walk-up reload returned no row") + return { kind: "registered" as const, registration: checkedIn } }) } catch (err) { if (registerArgs === null) throw err @@ -1590,20 +1714,26 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio if (cursor === null) return sql`` return sql`AND (lower(COALESCE(u.display_name, g.name, '')), r.id) > (${cursor.name}, ${cursor.id}::uuid)` } - const direction = query.sort === "registered_at_asc" ? "asc" : "desc" - const cursor = parseTimeCursor(query.cursor, { direction }) - if (cursor === null) return sql`` - if (query.sort === "registered_at_asc") { - return sql`AND (r.registered_at, r.id) > (${cursor.at}, ${cursor.id}::uuid)` - } if (query.sort === "checked_in_at_desc") { - return sql`AND (COALESCE(ci.first_at, 'epoch'::timestamptz), r.id) < (${cursor.at}, ${cursor.id}::uuid)` + const cursor = parseCheckedInRosterCursor(query.cursor) + if (cursor === null) return sql`` + if (cursor.registeredAtText === null) { + // A cursor minted before it carried registered_at cannot place itself inside a tie, so it + // resumes at the start of that tie: a row may repeat once, none is skipped. + return sql`AND ${rosterCheckedInKey(sql)} <= ${cursor.checkedInAtText}::timestamptz` + } + return sql`AND (${rosterCheckedInKey(sql)}, r.registered_at, r.id) < (${cursor.checkedInAtText}::timestamptz, ${cursor.registeredAtText}::timestamptz, ${cursor.id}::uuid)` } - return sql`AND (r.registered_at, r.id) < (${cursor.at}, ${cursor.id}::uuid)` + const direction = query.sort === "registered_at_asc" ? "asc" : "desc" + const cursor = parseKeysetCursor(query.cursor, { direction }) + if (cursor === null) return sql`` + return sql`AND ${keysetPredicate(sql, sql`r.registered_at`, sql`r.id`, cursor, { direction })}` })() - const rows = await sql` - SELECT ${registrationColumns(sql)} + const rows = await sql` + SELECT ${registrationColumns(sql)}, + ${keysetInstant(sql, sql`r.registered_at`)} AS cursor_at, + ${keysetInstant(sql, rosterCheckedInKey(sql))} AS checked_in_cursor_at FROM cleanup_registrations r ${registrationJoins(sql)} WHERE r.cleanup_id = ${query.cleanupId} @@ -1623,10 +1753,8 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio id: last.id, }) } - if (query.sort === "checked_in_at_desc") { - return encodeTimeCursor({ at: last.checked_in_at ?? new Date(0), id: last.id }) - } - return encodeTimeCursor({ at: last.registered_at, id: last.id }) + if (query.sort === "checked_in_at_desc") return encodeCheckedInRosterCursor(last) + return encodeKeysetCursor(last.cursor_at, last.id) }) const page: RosterPage = { rows: await hydrate(sql, items), nextCursor } @@ -1791,7 +1919,11 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const registration = await loadRegistrationById(tx, args.cleanupId, args.registrationId) if (registration === null) return { kind: "not_found" as const } - return { kind: "transferred" as const, registration } + return { + kind: "transferred" as const, + registration, + previousTicketTypeId: current.ticket_type_id, + } }) }, @@ -1988,6 +2120,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio FROM cleanup_waitlist w WHERE w.ticket_type_id = ${args.ticketTypeId} AND w.status = 'waiting' AND ${waitlistEntryNotBanned(tx)} + AND ${waitlistEventStillLive(tx)} ORDER BY w.created_at, w.id LIMIT 1 FOR UPDATE SKIP LOCKED @@ -2044,6 +2177,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio AND w.cleanup_id = ${args.cleanupId} AND w.status = 'waiting' AND ${waitlistEntryNotBanned(tx)} + AND ${waitlistEventStillLive(tx)} LIMIT 1 FOR UPDATE ` @@ -2140,7 +2274,18 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio try { return await sql.begin(async (tx) => { - await tx`SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE` + const events = await tx< + { + status: EventRegistrationContext["status"] + scheduled_at: Date + ends_at: Date | null + now: Date + }[] + >` + SELECT status, scheduled_at, ends_at, now() AS now FROM cleanups + WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE + ` + const event = events[0] const claimed = await tx< { party_size: number; user_id: string | null; guest_id: string | null }[] >` @@ -2164,6 +2309,13 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio : row.guest_id === subject.guestId if (!owns) throw new ClaimRefusal({ kind: "not_found" }) + // registerIn only gates self sign-ups on the event end, so an offer outliving its event + // would otherwise still turn into a seat. + if (event !== undefined && hasEventEnded(eventWindowOfRow(event), event.now.getTime())) { + await releaseWaitlistHold(tx, args.cleanupId, args.waitlistId, args.now) + return { kind: "not_offered" as const } + } + const outcome = await registerIn(tx, registerArgs) if (outcome.kind === "registered") { return { kind: "claimed" as const, registration: outcome.registration } @@ -2575,7 +2727,8 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio published: boolean actorId: string now: Date - }): Promise { + }): Promise { + // The flag is re-checked in the write: an operator may flag the page after the service read it. const rows = await sql<{ cleanup_id: string }[]>` UPDATE cleanup_pages SET status = ${args.published ? "published" : "unpublished"}, @@ -2583,10 +2736,21 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio published_by = ${args.published ? args.actorId : sql`published_by`}, updated_at = ${args.now} WHERE cleanup_id = ${args.cleanupId} + AND (NOT ${args.published} OR flagged_at IS NULL) RETURNING cleanup_id ` - if (rows.length === 0) return null - return loadPage(sql, args.cleanupId) + if (rows.length === 0) { + if (!args.published) return { kind: "not_found" } + const flagged = await sql<{ flagged_at: Date | null }[]>` + SELECT flagged_at FROM cleanup_pages WHERE cleanup_id = ${args.cleanupId} LIMIT 1 + ` + const row = flagged[0] + return row !== undefined && row.flagged_at !== null + ? { kind: "flagged" } + : { kind: "not_found" } + } + const record = await loadPage(sql, args.cleanupId) + return record === null ? { kind: "not_found" } : { kind: "published", record } }, async slugTaken(cleanupId: string, slug: string): Promise { diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index 5b3f4354..1cc38494 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -32,6 +32,7 @@ import type { JoinWaitlistOutcome, PageRecord, PublicPageRecord, + PublishPageOutcome, QuestionRecord, RegisterTxArgs, RegisterTxOutcome, @@ -411,8 +412,21 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos waitlistId: null, now: args.now, }) - if (outcome.kind !== "registered") this.guests.delete(guestId) - return outcome + if (outcome.kind !== "registered") { + this.guests.delete(guestId) + return outcome + } + const checkIn = args.checkIn + if (checkIn === undefined || checkIn === null) return outcome + const registration = this.registrations.get(outcome.registration.id) + if (registration === undefined) return outcome + for (const seat of registration.seats) { + if (seat.status !== "active" || seat.checkedInAt !== null) continue + seat.checkedInAt = args.now + seat.checkedInBy = checkIn.actorId + seat.checkinMethod = checkIn.method + } + return { kind: "registered", registration: this.toRecord(registration) } } ensureSignupRegistration(args: { @@ -509,13 +523,6 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos const event = this.events.get(args.cleanupId) if (event === undefined) return { kind: "not_found" } if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) return { kind: "not_found" } - if (event.status === "cancelled") return { kind: "closed" } - if (!withinWindow(args.now, event.registrationOpensAt, event.registrationClosesAt)) { - return { kind: "registration_closed" } - } - if (args.subject.kind === "user" && this.isBanned(args.cleanupId, args.subject.userId)) { - return { kind: "banned" } - } const owner = args.idempotencyOwner ?? @@ -532,6 +539,14 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos } } + if (event.status === "cancelled") return { kind: "closed" } + if (!withinWindow(args.now, event.registrationOpensAt, event.registrationClosesAt)) { + return { kind: "registration_closed" } + } + if (args.subject.kind === "user" && this.isBanned(args.cleanupId, args.subject.userId)) { + return { kind: "banned" } + } + const active = [...this.registrations.values()].find( (r) => r.cleanupId === args.cleanupId && @@ -888,6 +903,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (target.capacity !== null && target.reservedSeats + record.partySize > target.capacity) { return { kind: "full" } } + const previousTicketTypeId = record.ticketTypeId target.reservedSeats += record.partySize if (record.ticketTypeId !== null) { const previous = this.ticketTypes.get(record.ticketTypeId) @@ -899,13 +915,19 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos record.ticketTypeId = target.id record.source = "transfer" this.recomputeSold(target.id) - return { kind: "transferred", registration: this.toRecord(record) } + return { kind: "transferred", registration: this.toRecord(record), previousTicketTypeId } } private isBanned(cleanupId: string, userId: string): boolean { return this.bans.has(`${cleanupId}:${userId}`) } + private eventStillLive(cleanupId: string, now: Date): boolean { + const event = this.events.get(cleanupId) + if (event === undefined || event.status === "cancelled") return false + return !hasEventEnded(eventWindowOf(event), now.getTime()) + } + private entryBanned(entry: WaitlistRecord): boolean { return entry.userId !== null && this.isBanned(entry.cleanupId, entry.userId) } @@ -1073,6 +1095,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos const candidates = [...this.waitlist.values()] .filter((w) => w.ticketTypeId === args.ticketTypeId && w.status === "waiting") .filter((w) => w.userId === null || !this.isBanned(w.cleanupId, w.userId)) + .filter((w) => this.eventStillLive(w.cleanupId, args.now)) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)) const candidate = candidates[0] if (candidate === undefined) return null @@ -1108,6 +1131,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (candidate.userId !== null && this.isBanned(candidate.cleanupId, candidate.userId)) { return null } + if (!this.eventStillLive(candidate.cleanupId, args.now)) return null const type = this.ticketTypes.get(candidate.ticketTypeId) if (type === undefined) return null if (type.capacity !== null && type.reservedSeats + candidate.partySize > type.capacity) { @@ -1165,6 +1189,14 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return { kind: "not_offered" } } + const event = this.events.get(args.cleanupId) + if (event !== undefined && hasEventEnded(eventWindowOf(event), args.now.getTime())) { + entry.status = "expired" + const type = this.ticketTypes.get(entry.ticketTypeId) + if (type !== undefined) type.reservedSeats = Math.max(type.reservedSeats - entry.partySize, 0) + return { kind: "not_offered" } + } + const subject: RegistrationSubject = entry.userId !== null ? { kind: "user", userId: entry.userId } @@ -1426,13 +1458,14 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos published: boolean actorId: string now: Date - }): Promise { + }): Promise { const current = this.pages.get(args.cleanupId) - if (current === undefined) return null + if (current === undefined) return { kind: "not_found" } + if (args.published && current.flaggedAt !== null) return { kind: "flagged" } current.status = args.published ? "published" : "unpublished" if (args.published) current.publishedAt = args.now current.updatedAt = args.now - return { ...current } + return { kind: "published", record: { ...current } } } readonly mediaKeys = new Map() diff --git a/services/api/src/services/host/registration-repository.types.ts b/services/api/src/services/host/registration-repository.types.ts index 5f8422b5..9ab2fbde 100644 --- a/services/api/src/services/host/registration-repository.types.ts +++ b/services/api/src/services/host/registration-repository.types.ts @@ -228,6 +228,11 @@ export interface RegisterTxArgs { now: Date } +export interface WalkupCheckIn { + actorId: string + method: CheckinMethod +} + export interface WalkupRegisterArgs { cleanupId: string name: string @@ -237,6 +242,7 @@ export interface WalkupRegisterArgs { idempotencyKey: string idempotencyOwner: string now: Date + checkIn?: WalkupCheckIn | null } export interface RegisterSnapshot { @@ -277,7 +283,7 @@ export type RemoveRegistrationOutcome = CancelRegistrationOutcome & { } export type TransferRegistrationOutcome = - | { kind: "transferred"; registration: RegistrationRecord } + | { kind: "transferred"; registration: RegistrationRecord; previousTicketTypeId: string | null } | { kind: "full" } | { kind: "party_too_large" } | { kind: "same_type" } @@ -392,6 +398,11 @@ export type SavePageOutcome = | { kind: "block_media_not_found" } | { kind: "not_found" } +export type PublishPageOutcome = + | { kind: "published"; record: PageRecord } + | { kind: "flagged" } + | { kind: "not_found" } + export interface PublicPageRecord { page: PageRecord event: EventRegistrationContext @@ -543,7 +554,7 @@ export interface HostRegistrationRepository { published: boolean actorId: string now: Date - }): Promise + }): Promise slugTaken(cleanupId: string, slug: string): Promise mediaKeysFor(cleanupId: string, mediaIds: readonly string[]): Promise> getPublicPage(slug: string): Promise diff --git a/services/api/src/services/host/registration-service.ts b/services/api/src/services/host/registration-service.ts index 98dab70d..457d9ca1 100644 --- a/services/api/src/services/host/registration-service.ts +++ b/services/api/src/services/host/registration-service.ts @@ -24,6 +24,7 @@ import { assertNoSlur } from "../../abuse/slur-filter.js" import { eventEndedError, eventWindowOf, hasEventEnded } from "../cleanup-rules.js" import { sha256Hex } from "../../auth/crypto.js" import type { CounterStore } from "../../abuse/counter-store.js" +import type { AdminAuditAction } from "../admin/audit.js" import { validateAnswers } from "./question-validation.js" import { REGISTRATION_ROSTER_DEFAULT_LIMIT, @@ -81,7 +82,7 @@ export interface RegistrationNotifier { export interface RegistrationAudit { (input: { actorId: string | null - action: string + action: AdminAuditAction target: string meta?: Record }): Promise @@ -547,6 +548,7 @@ export function makeRegistrationService(deps: RegistrationServiceDeps): Registra }) switch (outcome.kind) { case "transferred": + await enqueueWaitlistPromotion(deps.jobs, [outcome.previousTicketTypeId], deps.logger) await eventChanged(input.id) await deps.audit?.({ actorId, @@ -604,6 +606,7 @@ export function makeRegistrationService(deps: RegistrationServiceDeps): Registra idempotencyKey: walkupIdempotencyKey(actorId, name, input.partySize, at), idempotencyOwner: `user:${actorId}`, now: at, + checkIn: input.checkInNow ? { actorId, method: "walkup" } : null, }) if (outcome.kind === "not_found") throw AppError.notFound("Cleanup not found") @@ -613,18 +616,6 @@ export function makeRegistrationService(deps: RegistrationServiceDeps): Registra const registration = outcome.registration if (registration === null) return { outcome: "already_registered", registration: null } - if (input.checkInNow) { - for (const seat of registration.seats) { - await deps.repo.checkInSeat({ - cleanupId: input.id, - seatId: seat.id, - actorId, - method: "walkup", - now: at, - }) - } - } - await deps.audit?.({ actorId, action: "event.attendee_registered_by_host", diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index a019bc1a..36f79184 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -175,6 +175,7 @@ export function makeContainerRegistrationServices( registrations, tickets: makeTicketTypeService({ repo, + jobs: container.jobs, counters, insightsInvalidator, ...(overrides?.now !== undefined ? { now: overrides.now } : {}), diff --git a/services/api/src/services/host/ticket-type-service.ts b/services/api/src/services/host/ticket-type-service.ts index 80e17160..41029e86 100644 --- a/services/api/src/services/host/ticket-type-service.ts +++ b/services/api/src/services/host/ticket-type-service.ts @@ -11,10 +11,12 @@ import type { } from "@civfix/shared" import { sha256Hex } from "../../auth/crypto.js" import { assertNoSlur } from "../../abuse/slur-filter.js" +import type { Jobs } from "@civfix/shared/interfaces" import type { CounterStore } from "../../abuse/counter-store.js" import { toTicketTypeDTO } from "./registration-dto.js" import type { InsightsInvalidator } from "./host-analytics-cache.js" import type { HostRegistrationRepository } from "./registration-repository.types.js" +import { enqueueWaitlistPromotion } from "./waitlist-promotion.js" export const HOST_TICKET_TYPE_COUNTER_KEY = "host:ticketTypes" @@ -24,6 +26,7 @@ export const HOST_TICKET_TYPE_WINDOW_SECONDS = 60 * 60 export interface TicketTypeServiceDeps { repo: HostRegistrationRepository + jobs?: Jobs counters?: CounterStore insightsInvalidator?: InsightsInvalidator now?: () => Date @@ -49,6 +52,11 @@ function capacityExceededError(eventCapacity: number, used: number): AppError { }) } +function capacityRaised(before: number | null, after: number | null): boolean { + if (before === null) return false + return after === null || after > before +} + function salesWindowError(): AppError { return AppError.validation({ salesClosesAt: "must be after salesOpensAt" }) } @@ -199,6 +207,9 @@ export function makeTicketTypeService(deps: TicketTypeServiceDeps): TicketTypeSe switch (outcome.kind) { case "updated": + if (capacityRaised(current.capacity, outcome.record.capacity)) { + await enqueueWaitlistPromotion(deps.jobs, [input.ticketTypeId], deps.logger) + } await eventChanged(input.id) return toTicketTypeDTO(outcome.record, now()) case "name_taken": diff --git a/services/api/src/services/job-attempt.ts b/services/api/src/services/job-attempt.ts new file mode 100644 index 00000000..3a4c2feb --- /dev/null +++ b/services/api/src/services/job-attempt.ts @@ -0,0 +1,18 @@ +import type { JobHandlerArg } from "@civfix/shared/interfaces" + +// The shared Jobs seam hands a handler only { id, data }. The pg-boss adapter adds the retry position so +// a handler can tell its last attempt apart; any other Jobs implementation leaves it out, which reads as +// "not the last attempt" and keeps the plain throw-to-retry behavior. +export interface JobAttempt { + retryCount: number + retryLimit: number +} + +export type JobHandlerArgWithAttempt = JobHandlerArg & Partial + +export function isFinalJobAttempt(job: JobHandlerArg): boolean { + const { retryCount, retryLimit } = job as JobHandlerArgWithAttempt + return ( + typeof retryCount === "number" && typeof retryLimit === "number" && retryCount >= retryLimit + ) +} diff --git a/services/api/src/services/jurisdiction-service.ts b/services/api/src/services/jurisdiction-service.ts index 339f4268..46d03f33 100644 --- a/services/api/src/services/jurisdiction-service.ts +++ b/services/api/src/services/jurisdiction-service.ts @@ -2,6 +2,7 @@ import type { JurisdictionDTO } from "@civfix/shared" import type { Geocoder, Jobs } from "@civfix/shared/interfaces" import type { Sql } from "../db/client.js" import { resolveJurisdiction } from "../db/sql/jurisdiction.js" +import { legacyContactEmailUsable } from "./admin/sql-fragments.js" import { formatCityStateLabel, uspsFromGeoid } from "../adapters/geocoder.tiger.js" import type { JurisdictionLookup, @@ -124,6 +125,8 @@ async function resolveViaLookup( try { hit = await lookup.lookup(lat, lng) } catch { + // Census is only the fallback after a local miss: its outage leaves the point unmapped and must never + // fail the report, anon report or cleanup being filed. return null } if (!hit) return null @@ -165,12 +168,22 @@ async function loadHealth(sql: Sql, geoid: string): Promise` SELECT j.geoid, - j.contact_emails, + -- Only legacy addresses that have not bounced since the last contact save make the + -- jurisdiction routable, so a bounce re-triggers discovery here as it does in the job. + ARRAY( + SELECT e FROM unnest(j.contact_emails) AS e + WHERE ${legacyContactEmailUsable(sql, { + email: sql`e`, + geoid: sql`j.geoid`, + contactUpdatedAt: sql`j.contact_updated_at`, + })} + ) AS contact_emails, j.contact_updated_at, j.population, EXISTS ( SELECT 1 FROM jurisdiction_contacts jc WHERE jc.geoid = j.geoid AND jc.email IS NOT NULL AND jc.email <> '' + AND jc.bounced_at IS NULL ) AS has_routing_contact FROM jurisdictions j WHERE j.geoid = ${geoid} diff --git a/services/api/src/services/media-byte-quota.ts b/services/api/src/services/media-byte-quota.ts index 74306eaf..a257a46e 100644 --- a/services/api/src/services/media-byte-quota.ts +++ b/services/api/src/services/media-byte-quota.ts @@ -6,15 +6,16 @@ * single source, against an orphan sweep that reclaims a bounded batch per hour. This meters the * declared byte size instead, so the budget is denominated in the resource actually consumed. * - * Semantics mirror abuse/counter-store.ts exactly, with INCRBY in place of INCR: the key is created at - * `bytes` and the TTL is applied ONLY on creation, so the window is anchored to the caller's first - * upload of the day and cannot be extended by continuing to spend. + * Semantics mirror abuse/counter-store.ts exactly, with INCRBY in place of INCR: the TTL is applied only + * while the key has none, so the window is anchored to the caller's first upload of the day and cannot + * be extended by continuing to spend, while a key that lost its expiry still gets one. * * FAIL CLOSED: `charge` never swallows a store error. A meter that cannot be read must not silently * become "unlimited" — that is exactly the failure mode H4 flagged in the rate limiter. */ import type { RedisClient } from "../adapters/redis.js" +import { attachAtomicIncrBy } from "../adapters/redis-incr.js" /** Per-subject daily budget. Generous for a real reporter (a handful of photos and a short video). */ export const MEDIA_UPLOAD_BYTES_PER_DAY = 512 * 1024 * 1024 @@ -30,37 +31,15 @@ export interface ByteMeter { add(subject: string, bytes: number): Promise } -/** - * Lua kept as a STATIC literal (never composed from input), matching adapters/redis-incr.ts: INCRBY + - * PEXPIRE-on-create in ONE round-trip so a crash between the two cannot strand a TTL-less key and - * lock a subject out of uploading forever. - */ -const INCRBY_EXPIRE_LUA = - "local n = redis.call('INCRBY', KEYS[1], ARGV[1]); if n == tonumber(ARGV[1]) then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end; return n" - -const COMMAND_NAME = "civfixIncrByExpire" - -type WithIncrBy = RedisClient & { - [COMMAND_NAME]?: (key: string, by: number, ttlMs: number) => Promise -} - export class RedisByteMeter implements ByteMeter { - private readonly client: WithIncrBy + private readonly incrBy: ReturnType constructor(redis: RedisClient) { - this.client = redis as WithIncrBy - if (typeof this.client[COMMAND_NAME] !== "function") { - redis.defineCommand(COMMAND_NAME, { numberOfKeys: 1, lua: INCRBY_EXPIRE_LUA }) - } + this.incrBy = attachAtomicIncrBy(redis) } - async add(subject: string, bytes: number): Promise { - const result = await this.client[COMMAND_NAME]!( - MEDIA_UPLOAD_BYTE_PREFIX + subject, - Math.max(0, Math.floor(bytes)), - MEDIA_UPLOAD_BYTE_WINDOW_SECONDS * 1000, - ) - return Number(result) + add(subject: string, bytes: number): Promise { + return this.incrBy(MEDIA_UPLOAD_BYTE_PREFIX + subject, bytes, MEDIA_UPLOAD_BYTE_WINDOW_SECONDS) } } diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index 1e088fe7..59ff84a5 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -95,7 +95,7 @@ export interface MediaRepository { insert(row: NewMediaAsset): Promise findByUploadId(uploadId: string): Promise findById(id: string): Promise - markFinalized(uploadId: string): Promise + markFinalized(uploadId: string, uploadEtag: string | null): Promise } export interface MediaIntakeDeps { @@ -233,6 +233,12 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic throw AppError.notFound("Unknown upload") } + // Once finalized, the worker may already have deleted the raw upload and overwritten byte_size with + // the processed size, so a client retry must be answered before either is checked again. + if (asset.status !== "validating" || asset.finalizedAt != null) { + return { mediaId: asset.id, status: "validating" } + } + const head = await deps.storage.head(asset.r2Key) if (!head) { throw AppError.mediaRejected("Uploaded object not found in storage") @@ -244,11 +250,8 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic throw AppError.mediaRejected("Uploaded object size does not match the declared byteSize") } - if (asset.status !== "validating") { - return { mediaId: asset.id, status: "validating" } - } - - const claimed = await deps.repo.markFinalized(input.uploadId) + const uploadEtag = readEtag(head) + const claimed = await deps.repo.markFinalized(input.uploadId, uploadEtag) if (claimed === null) { return { mediaId: asset.id, status: "validating" } } @@ -262,7 +265,7 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic uploadId: input.uploadId, r2Key: asset.r2Key, kind: asset.kind, - uploadEtag: readEtag(head), + uploadEtag, } satisfies MediaChecksJob, { singletonKey: input.uploadId }, ) diff --git a/services/api/src/services/media-repository.drizzle.ts b/services/api/src/services/media-repository.drizzle.ts index bb03a3c7..93368e14 100644 --- a/services/api/src/services/media-repository.drizzle.ts +++ b/services/api/src/services/media-repository.drizzle.ts @@ -56,10 +56,13 @@ export function makeDrizzleMediaRepository(db: Db): MediaRepository { return row ? toView(row) : null }, - async markFinalized(uploadId: string): Promise { + async markFinalized( + uploadId: string, + uploadEtag: string | null, + ): Promise { const rows = await db .update(mediaAssets) - .set({ finalizedAt: sql`now()` }) + .set({ finalizedAt: sql`now()`, uploadEtag }) .where(and(eq(mediaAssets.uploadId, uploadId), isNull(mediaAssets.finalizedAt))) .returning() const row = rows[0] diff --git a/services/api/src/services/media-worker-repo.ts b/services/api/src/services/media-worker-repo.ts index 02effa94..7adfbb8d 100644 --- a/services/api/src/services/media-worker-repo.ts +++ b/services/api/src/services/media-worker-repo.ts @@ -6,6 +6,7 @@ import { abuseFlags } from "../db/schema/moderation.js" import { moderationItems } from "../db/schema/moderation_items.js" import { reports } from "../db/schema/reports.js" import { jurisdictions } from "../db/schema/jurisdictions.js" +import { users } from "../db/schema/users.js" import type { Db, Queryable, Sql } from "../db/client.js" import type { MediaKind, MediaStatus } from "@civfix/shared" @@ -14,6 +15,8 @@ export type { StorageHeadWithEtag } from "./media-etag.js" export type WorkerAbuseReason = "nsfw" | "phash_dup" | "gps" +const ANONYMOUS_REPORTER = "Anonymous" + export interface MediaWorkerAsset { id: string uploadId: string @@ -58,6 +61,7 @@ export interface StuckMediaRow { thumbKey: string | null kind: MediaKind checkCount: number + uploadEtag: string | null } export interface LeakedObjectRow { @@ -262,6 +266,7 @@ export function makeDrizzleMediaWorkerRepo(db: Db, tag: Sql): MediaWorkerRepo { thumbKey: mediaAssets.thumbKey, kind: mediaAssets.kind, checkCount: mediaAssets.stuckCheckCount, + uploadEtag: mediaAssets.uploadEtag, }) }) }, @@ -302,34 +307,42 @@ export function makeDrizzleMediaWorkerRepo(db: Db, tag: Sql): MediaWorkerRepo { kind?: "image" | "duplicate" note?: string | null }): Promise { - const existing = await db - .select({ id: moderationItems.id }) - .from(moderationItems) - .where( - and( - eq(moderationItems.subjectType, "report"), - eq(moderationItems.subjectId, input.reportId), - eq(moderationItems.status, "open"), - ), - ) - .limit(1) - if (existing[0]) return + // A held source folding into an open item means the owner's takedown is no longer its only + // origin, so removing it must strike the author again. + const foldIntoOpenItem = async (): Promise => { + const folded = await db + .update(moderationItems) + .set({ meta: sql`${moderationItems.meta} - 'ownerTakedown'` }) + .where( + and( + eq(moderationItems.subjectType, "report"), + eq(moderationItems.subjectId, input.reportId), + eq(moderationItems.status, "open"), + ), + ) + .returning({ id: moderationItems.id }) + return folded.length > 0 + } + if (await foldIntoOpenItem()) return const ctx = await db .select({ category: reports.category, description: reports.description, place: jurisdictions.name, + reporterName: users.displayName, + reporterUserId: users.id, }) .from(reports) .leftJoin(jurisdictions, eq(jurisdictions.geoid, reports.jurisdictionGeoid)) + .leftJoin(users, eq(users.id, reports.reporterUserId)) .where(eq(reports.id, input.reportId)) .limit(1) const row = ctx[0] if (!row) return const kind = input.kind ?? "image" - await db + const inserted = await db .insert(moderationItems) .values({ kind, @@ -342,12 +355,19 @@ export function makeDrizzleMediaWorkerRepo(db: Db, tag: Sql): MediaWorkerRepo { priority: "high", autoAction: "Hidden pending review", status: "open", - meta: { reporter: "Anonymous", desc: row.description ?? "", note: input.note ?? null }, + meta: { + reporter: row.reporterName ?? ANONYMOUS_REPORTER, + reporterUserId: row.reporterUserId ?? null, + desc: row.description ?? "", + note: input.note ?? null, + }, }) .onConflictDoNothing({ target: [moderationItems.subjectType, moderationItems.subjectId], where: sql`status = 'open'`, }) + .returning({ id: moderationItems.id }) + if (inserted.length === 0) await foldIntoOpenItem() }, async recordLeakedObjects(input: { diff --git a/services/api/src/services/notification-helpers.ts b/services/api/src/services/notification-helpers.ts index 03886e80..25226a16 100644 --- a/services/api/src/services/notification-helpers.ts +++ b/services/api/src/services/notification-helpers.ts @@ -51,6 +51,8 @@ function minutesInZone(now: Date, tz: string): number | null { if (!Number.isInteger(h) || !Number.isInteger(m)) return null return h * 60 + m } catch { + // An unknown zone cannot place "now" in the user's day; failing open delivers the push rather + // than silencing the user around the clock. return null } } diff --git a/services/api/src/services/notification-repository.drizzle.ts b/services/api/src/services/notification-repository.drizzle.ts index ed9e73ae..ed7e0205 100644 --- a/services/api/src/services/notification-repository.drizzle.ts +++ b/services/api/src/services/notification-repository.drizzle.ts @@ -1,5 +1,10 @@ import type { Sql } from "../db/client.js" -import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../db/cursor-helpers.js" import type { NewNotificationArgs, NotificationPrefsPatch, @@ -112,11 +117,12 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit cursor: string | null, limit: number, ): Promise<{ records: NotificationRecord[]; nextCursor: string | null }> { - const parsed = parseTimeCursor(cursor) + const parsed = parseKeysetCursor(cursor) const cursorFilter = - parsed !== null ? sql`AND (created_at, id) < (${parsed.at}, ${parsed.id}::uuid)` : sql`` - const rows = await sql` - SELECT id, user_id, type, title, body, link, read_at, created_at + parsed !== null ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, parsed)}` : sql`` + const rows = await sql<(NotificationRowSelect & { cursor_at: string | null })[]>` + SELECT id, user_id, type, title, body, link, read_at, created_at, + ${keysetInstant(sql, sql`created_at`)} AS cursor_at FROM notifications WHERE user_id = ${userId} AND type <> ALL(${[...FEED_HIDDEN_NOTIFICATION_TYPES]}::text[]) @@ -124,7 +130,10 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit ORDER BY created_at DESC, id DESC LIMIT ${limit + 1} ` - const { items, nextCursor } = paginate(rows, limit, (r) => ({ at: r.created_at, id: r.id })) + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) return { records: items.map(toRecord), nextCursor } }, @@ -325,25 +334,36 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit await sql`DELETE FROM push_tokens WHERE user_id = ${userId}` }, - async findRecentDuplicate(args: { - userId: string - type: NotificationType - link: string | null - body: string | null - since: Date - }): Promise { - const rows = await sql` - SELECT id, user_id, type, title, body, link, read_at, created_at - FROM notifications - WHERE user_id = ${args.userId} - AND type = ${args.type} - AND link IS NOT DISTINCT FROM ${args.link} - AND body IS NOT DISTINCT FROM ${args.body} - AND created_at > ${args.since} - ORDER BY created_at DESC - LIMIT 1 - ` - return rows[0] ? toRecord(rows[0]) : null + async insertUnlessRecentDuplicate( + args: NewNotificationArgs & { since: Date }, + ): Promise<{ record: NotificationRecord; deduped: boolean }> { + return sql.begin(async (tx) => { + // A plain look-then-insert lets two concurrent writers both miss and both insert; the key + // lock makes the second wait for the first's commit and then find its row. + await tx` + SELECT pg_advisory_xact_lock( + hashtext('notification_dedupe:' || ${args.userId} || ':' || ${args.type} || ':' || COALESCE(${args.link}::text, '')) + ) + ` + const existing = await tx` + SELECT id, user_id, type, title, body, link, read_at, created_at + FROM notifications + WHERE user_id = ${args.userId} + AND type = ${args.type} + AND link IS NOT DISTINCT FROM ${args.link} + AND body IS NOT DISTINCT FROM ${args.body} + AND created_at > ${args.since} + ORDER BY created_at DESC + LIMIT 1 + ` + if (existing[0]) return { record: toRecord(existing[0]), deduped: true } + const rows = await tx` + INSERT INTO notifications (user_id, type, title, body, link) + VALUES (${args.userId}, ${args.type}, ${args.title}, ${args.body}, ${args.link}) + RETURNING id, user_id, type, title, body, link, read_at, created_at + ` + return { record: toRecord(rows[0]!), deduped: false } + }) as Promise<{ record: NotificationRecord; deduped: boolean }> }, refreshUnreadNotification(args: RefreshUnreadArgs): Promise { @@ -354,6 +374,13 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit args: RefreshUnreadArgs, ): Promise<{ record: NotificationRecord; coalesced: boolean }> { return sql.begin(async (tx) => { + // FOR UPDATE in the refresh locks nothing when no unread row exists yet, so two concurrent + // fan-outs would each insert one; this key lock makes the second see the first's row. + await tx` + SELECT pg_advisory_xact_lock( + hashtext('notification_coalesce:' || ${args.userId} || ':' || ${args.type} || ':' || ${args.link}) + ) + ` const refreshed = await refreshUnreadWith(tx as unknown as Sql, args) if (refreshed) return { record: refreshed, coalesced: true } const rows = await tx` diff --git a/services/api/src/services/notification-service.ts b/services/api/src/services/notification-service.ts index 152c3979..ff6a65ea 100644 --- a/services/api/src/services/notification-service.ts +++ b/services/api/src/services/notification-service.ts @@ -104,13 +104,9 @@ export interface NotificationRepository { clearByTypeAndLink(userId: string, type: NotificationType, link: string): Promise - findRecentDuplicate(args: { - userId: string - type: NotificationType - link: string | null - body: string | null - since: Date - }): Promise + insertUnlessRecentDuplicate( + args: NewNotificationArgs & { since: Date }, + ): Promise<{ record: NotificationRecord; deduped: boolean }> refreshUnreadNotification(args: { userId: string @@ -189,6 +185,15 @@ export interface PostNotifier { onPostMention(a: { recipientId: string; actorName: string; postId: string }): Promise } +export interface FanOutNotificationResult { + /** + * Recipients whose in-app row was never written. The call resolves even when every write failed, + * so each caller decides what a failure means: the chat fan-out fails its job on a total failure, + * and the broadcast pipeline retries exactly these. + */ + failed: string[] +} + export interface NotificationService extends SocialNotifier, PostNotifier { listNotifications(userId: string, pagination: PaginationQuery): Promise markRead(userId: string, ids: string[]): Promise<{ ok: true }> @@ -198,9 +203,17 @@ export interface NotificationService extends SocialNotifier, PostNotifier { unregisterPushToken(userId: string, req: UnregisterPushTokenRequest): Promise<{ ok: true }> createNotification(userId: string, input: CreateNotificationInput): Promise createNotifications(userIds: string[], input: CreateNotificationInput): Promise + createNotificationsReportingFailures( + userIds: string[], + input: CreateNotificationInput, + ): Promise clearByTypeAndLink(userId: string, type: NotificationType, link: string): Promise } +type FallbackLane = "coalesce" | "dedupe" + +type FallbackReporter = (lane: FallbackLane, err: unknown) => void + interface ResolvedPrefs { byUser: Map unreadable: Set @@ -297,10 +310,25 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica } } + function logFallback( + lane: FallbackLane, + err: unknown, + userId: string, + input: CreateNotificationInput, + ) { + deps.logger?.warn( + { err, userId, type: input.type }, + lane === "coalesce" + ? "notification coalesce upsert failed; creating anyway" + : "notification dedupe insert failed; creating anyway", + ) + } + async function persistNotification( userId: string, input: CreateNotificationInput, resolvedLocales?: Map, + onFallback: FallbackReporter = (lane, err) => logFallback(lane, err, userId, input), ): Promise<{ record: NotificationRecord; deduped: boolean }> { const locale = await localeFor(userId, input, resolvedLocales) const vars = varsIn(locale, input) @@ -325,27 +353,21 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica }) return { record, deduped: coalesced } } catch (err) { - deps.logger?.warn( - { err, userId, type: input.type }, - "notification coalesce upsert failed; creating anyway", - ) + onFallback("coalesce", err) } } if (input.dedupeWindowMs !== undefined) { try { - const existing = await deps.repo.findRecentDuplicate({ + return await deps.repo.insertUnlessRecentDuplicate({ userId, type: input.type, - link, + title, body, + link, since: new Date(now().getTime() - input.dedupeWindowMs), }) - if (existing) return { record: existing, deduped: true } } catch (err) { - deps.logger?.warn( - { err, userId, type: input.type }, - "notification dedupe lookup failed; creating anyway", - ) + onFallback("dedupe", err) } } const record = await deps.repo.insertNotification({ @@ -465,26 +487,48 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica async function doCreateNotifications( userIds: string[], input: CreateNotificationInput, - ): Promise { + ): Promise { const unique = [...new Set(userIds)] - if (unique.length === 0) return + if (unique.length === 0) return { failed: [] } const locales = await localesForMany(unique, input) + const failed: string[] = [] + let firstFailure: unknown + const fallbacks = new Map() + // One line per fan-out, not per recipient: a store outage across a 2000-member room would + // otherwise write thousands of warnings for one incident. + const tallyFallback: FallbackReporter = (lane, err) => { + const seen = fallbacks.get(lane) + if (seen) seen.count += 1 + else fallbacks.set(lane, { count: 1, err }) + } const persisted = await mapWithLimit(unique, BULK_NOTIFY_CONCURRENCY, async (userId) => { try { - const { record, deduped } = await persistNotification(userId, input, locales) + const { record, deduped } = await persistNotification(userId, input, locales, tallyFallback) return deduped ? null : { userId, record } } catch (err) { - deps.logger?.warn( - { err, userId, type: input.type }, - "fan-out notification insert failed (suppressed)", - ) + if (failed.length === 0) firstFailure = err + failed.push(userId) return null } }) + for (const [lane, { count, err }] of fallbacks) { + deps.logger?.warn( + { err, type: input.type, lane, fallbacks: count, recipients: unique.length }, + lane === "coalesce" + ? "fan-out notification coalesce upsert failed; creating anyway" + : "fan-out notification dedupe insert failed; creating anyway", + ) + } + if (failed.length > 0) { + deps.logger?.warn( + { err: firstFailure, type: input.type, failed: failed.length, recipients: unique.length }, + "fan-out notification insert failed; reported to the caller", + ) + } const created = persisted.filter( (p): p is { userId: string; record: NotificationRecord } => p !== null, ) - if (created.length === 0) return + if (created.length === 0) return { failed } void sendBatchedPush(created, input.push ?? "auto").catch((err: unknown) => { deps.logger?.error( { err, count: created.length }, @@ -492,6 +536,7 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica ) }) void signalMany(created.map((c) => c.userId)) + return { failed } } return { @@ -585,7 +630,14 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica return doCreateNotification(userId, input) }, - createNotifications(userIds: string[], input: CreateNotificationInput): Promise { + async createNotifications(userIds: string[], input: CreateNotificationInput): Promise { + await doCreateNotifications(userIds, input) + }, + + createNotificationsReportingFailures( + userIds: string[], + input: CreateNotificationInput, + ): Promise { return doCreateNotifications(userIds, input) }, diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index ef5fadda..86771642 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -12,7 +12,12 @@ import type { } from "@civfix/shared" import type { Queryable, Sql } from "../db/client.js" import type { POST_KIND_VALUES, REPORT_VISIBILITY_VALUES } from "../db/schema/types.js" -import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../db/cursor-helpers.js" import { loadMentionsFor, makeMentionRepo } from "./message-mentions.drizzle.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" @@ -204,6 +209,8 @@ interface PostRowSelect { updated_at: Date } +type KeysetPostRow = PostRowSelect & { cursor_at: string } + interface AuthorRow { id: string display_name: string @@ -949,9 +956,9 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep ` } - async function pageOf(rows: PostRowSelect[], limit: number, viewerId: string): Promise { - const { items: pageRows, nextCursor } = paginate(rows, limit, (r) => ({ - at: r.created_at, + async function pageOf(rows: KeysetPostRow[], limit: number, viewerId: string): Promise { + const { items: pageRows, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) return { items: await hydrate(pageRows, viewerId), nextCursor } @@ -967,6 +974,12 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return row.id } + async function resolveLiveTarget(tx: Queryable, postId: string): Promise { + const targetId = await resolveOriginalTarget(tx, postId) + if (targetId === null) throw AppError.notFound("Post not found") + return targetId + } + return { async getPostBrief(id: string): Promise { const rows = await sql< @@ -1043,13 +1056,9 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep async createPost(args: CreatePostArgs): Promise { return sql.begin(async (tx) => { const replyToId = - args.replyToId !== null - ? ((await resolveOriginalTarget(tx, args.replyToId)) ?? args.replyToId) - : null + args.replyToId !== null ? await resolveLiveTarget(tx, args.replyToId) : null const repostOfId = - args.repostOfId !== null - ? ((await resolveOriginalTarget(tx, args.repostOfId)) ?? args.repostOfId) - : null + args.repostOfId !== null ? await resolveLiveTarget(tx, args.repostOfId) : null let threadRootId: string | null = null if (replyToId !== null) { const parentRows = await tx<{ id: string; thread_root_id: string | null }[]>` @@ -1105,7 +1114,14 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep } if (replyToId !== null) { - await tx`UPDATE posts SET reply_count = reply_count + 1 WHERE id = ${replyToId}` + // The row lock re-reads deleted_at, so a parent tombstoned after the lookup above rolls + // the reply back instead of counting it on a deleted post. + const bumped = await tx<{ id: string }[]>` + UPDATE posts SET reply_count = reply_count + 1 + WHERE id = ${replyToId} AND deleted_at IS NULL + RETURNING id + ` + if (bumped.length === 0) throw AppError.notFound("Post not found") } return postId @@ -1169,7 +1185,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep const targetId = await resolveOriginalTarget(tx, postId) if (targetId === null) return { targetId: postId, created: false } const revived = await tx<{ id: string }[]>` - UPDATE posts SET deleted_at = NULL, updated_at = now() + UPDATE posts SET deleted_at = NULL, created_at = now(), updated_at = now() WHERE author_id = ${userId} AND kind = 'repost' AND repost_of_id = ${targetId} AND deleted_at IS NOT NULL RETURNING id @@ -1264,17 +1280,19 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }, async homeFeedChronological(args: HomeFeedArgs): Promise { - const cursor = parseTimeCursor(args.cursor) + const cursor = parseKeysetCursor(args.cursor) const cursorFilter = - cursor !== null ? sql`AND (p.created_at, p.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`p.created_at`, sql`p.id`, cursor)}` + : sql`` const filterClause = args.filter === "events" ? sql`AND p.event_id IS NOT NULL` : args.filter === "fixes" ? sql`AND EXISTS (SELECT 1 FROM reports fr WHERE fr.id = p.report_id AND fr.status = 'resolved')` : sql`` - const rows = await sql` - SELECT ${postColumns(sql)} + const rows = await sql` + SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`p.created_at`)} AS cursor_at FROM posts p WHERE p.deleted_at IS NULL AND p.reply_to_id IS NULL @@ -1297,17 +1315,19 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }, async publicFeed(args: PublicFeedArgs): Promise { - const cursor = parseTimeCursor(args.cursor) + const cursor = parseKeysetCursor(args.cursor) const cursorFilter = - cursor !== null ? sql`AND (p.created_at, p.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`p.created_at`, sql`p.id`, cursor)}` + : sql`` const filterClause = args.filter === "events" ? sql`AND p.event_id IS NOT NULL` : args.filter === "fixes" ? sql`AND EXISTS (SELECT 1 FROM reports fr WHERE fr.id = p.report_id AND fr.status = 'resolved')` : sql`` - const rows = await sql` - SELECT ${postColumns(sql)} + const rows = await sql` + SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`p.created_at`)} AS cursor_at FROM posts p WHERE p.deleted_at IS NULL AND p.reply_to_id IS NULL @@ -1321,11 +1341,13 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }, async listReplies(postId: string, args: ReplyListArgs): Promise { - const cursor = parseTimeCursor(args.cursor, { direction: "asc" }) + const cursor = parseKeysetCursor(args.cursor, { direction: "asc" }) const cursorFilter = - cursor !== null ? sql`AND (p.created_at, p.id) > (${cursor.at}, ${cursor.id}::uuid)` : sql`` - const rows = await sql` - SELECT ${postColumns(sql)} + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`p.created_at`, sql`p.id`, cursor, { direction: "asc" })}` + : sql`` + const rows = await sql` + SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`p.created_at`)} AS cursor_at FROM posts p WHERE p.reply_to_id = ${postId} AND p.deleted_at IS NULL AND p.visibility = 'public' @@ -1338,8 +1360,8 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep ORDER BY p.created_at ASC, p.id ASC LIMIT ${args.limit + 1} ` - const { items: pageRows, nextCursor } = paginate(rows, args.limit, (r) => ({ - at: r.created_at, + const { items: pageRows, nextCursor } = paginateKeyset(rows, args.limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) const answerRows = await latestAnswersByAuthor( @@ -1356,11 +1378,13 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }, async listUserPosts(authorId: string, args: PostListArgs): Promise { - const cursor = parseTimeCursor(args.cursor) + const cursor = parseKeysetCursor(args.cursor) const cursorFilter = - cursor !== null ? sql`AND (p.created_at, p.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` - const rows = await sql` - SELECT ${postColumns(sql)} + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`p.created_at`, sql`p.id`, cursor)}` + : sql`` + const rows = await sql` + SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`p.created_at`)} AS cursor_at FROM posts p WHERE p.author_id = ${authorId} AND p.deleted_at IS NULL AND p.reply_to_id IS NULL AND p.visibility = 'public' @@ -1372,13 +1396,13 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }, async listSaves(args: PostListArgs): Promise { - const cursor = parseTimeCursor(args.cursor) + const cursor = parseKeysetCursor(args.cursor) const cursorFilter = cursor !== null - ? sql`AND (ps.created_at, ps.post_id) < (${cursor.at}, ${cursor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`ps.created_at`, sql`ps.post_id`, cursor)}` : sql`` - const rows = await sql<(PostRowSelect & { saved_at: Date })[]>` - SELECT ${postColumns(sql)}, ps.created_at AS saved_at + const rows = await sql` + SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`ps.created_at`)} AS cursor_at FROM post_saves ps JOIN posts p ON p.id = ps.post_id WHERE ps.user_id = ${args.viewerId} AND p.deleted_at IS NULL @@ -1392,8 +1416,8 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep ORDER BY ps.created_at DESC, ps.post_id DESC LIMIT ${args.limit + 1} ` - const { items: pageRows, nextCursor } = paginate(rows, args.limit, (r) => ({ - at: r.saved_at, + const { items: pageRows, nextCursor } = paginateKeyset(rows, args.limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) return { items: await hydrate(pageRows, args.viewerId), nextCursor } diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index 094d249d..7bb9add7 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -37,6 +37,7 @@ import { type RankedCandidate, } from "./feed-ranking.js" import { mapWithLimit } from "./media-presign.js" +import { hiddenIdentity } from "./hidden-identity.js" import type { FeedPresence, FeedSnapshotEntry } from "./feed-presence.js" import type { PostNotifier } from "./notification-service.js" @@ -44,6 +45,28 @@ function isVisible(brief: PostBrief): boolean { return brief.deletedAt === null && brief.visibility === "public" } +// The success answer for taking back a repost of a post the caller can no longer read: it clears the +// caller's repost state without carrying the original's author, text or counts. +function withdrawnPostDTO(brief: PostBrief, atMs: number): PostDTO { + return { + id: brief.id, + author: { + id: brief.authorId, + ...hiddenIdentity(brief.authorId), + followers: 0, + following: 0, + isFollowing: false, + }, + kind: brief.kind, + body: null, + createdAt: new Date(atMs).toISOString(), + counts: { likes: 0, reposts: 0, replies: 0, saves: 0 }, + viewer: { liked: false, reposted: false, saved: false }, + media: [], + mentions: [], + } +} + interface FeedScoreCursor { score: number postId: string @@ -215,10 +238,15 @@ export function makePostService(deps: PostServiceDeps): PostService { const presence = presenceFor(viewerId) if (presence !== undefined) { + // A repost card shows the original's counts, and count changes are announced under the + // original's id, so the viewer index needs it too or the card never hears about them. void presence .recordServed( viewerId, - items.map((item) => item.id), + items.flatMap((item) => { + const originalId = item.kind === "repost" ? item.repostOf?.id : undefined + return originalId === undefined ? [item.id] : [item.id, originalId] + }), ) .catch((err: unknown) => { deps.logger?.warn({ err }, "post: feed served-set write failed (suppressed)") @@ -307,6 +335,8 @@ export function makePostService(deps: PostServiceDeps): PostService { try { return await presence.writeSnapshot(viewerId, filter, ranked) } catch { + // An unstored snapshot means the page-1 cursor cannot resolve, so the caller serves the + // reproducible bucket order instead and logs that fallback. return false } } @@ -377,21 +407,25 @@ export function makePostService(deps: PostServiceDeps): PostService { return dto } - async function requireReadable(id: string, viewerId: string) { + async function readableSubject(id: string, viewerId: string): Promise { const brief = await deps.repo.getPostBrief(id) - if (!brief || !isVisible(brief) || (await isBlocked(viewerId, brief.authorId))) { - throw AppError.notFound("Post not found") - } + if (!brief || !isVisible(brief) || (await isBlocked(viewerId, brief.authorId))) return null if (brief.repostOfId) { const target = await deps.repo.getPostBrief(brief.repostOfId) if (!target || !isVisible(target) || (await isBlocked(viewerId, target.authorId))) { - throw AppError.notFound("Post not found") + return null } if (brief.kind === "repost") return target } return brief } + async function requireReadable(id: string, viewerId: string): Promise { + const subject = await readableSubject(id, viewerId) + if (subject === null) throw AppError.notFound("Post not found") + return subject + } + return { async createPost( input: PostComposeInput, @@ -582,10 +616,17 @@ export function makePostService(deps: PostServiceDeps): PostService { }, async unrepostPost(id: string, viewerId: string): Promise { - await requireReadable(id, viewerId) + // No readability gate on the removal: the repository only ever removes the caller's own repost, + // and a reposter must be able to take one back after the original went hidden or its author + // blocked them. The original's content is still answered only to a caller who can read it. const { targetId, removed } = await deps.repo.unrepost(id, viewerId) if (removed) await announceCountChange(targetId, viewerId) - return hydrateOrThrow(targetId, viewerId) + if ((await readableSubject(targetId, viewerId)) !== null) { + return hydrateOrThrow(targetId, viewerId) + } + const target = removed ? await deps.repo.getPostBrief(targetId) : null + if (target === null) throw AppError.notFound("Post not found") + return withdrawnPostDTO(target, nowMs()) }, async homeFeed( diff --git a/services/api/src/services/push-token-policy.ts b/services/api/src/services/push-token-policy.ts index 9cc3ceb4..add5dedb 100644 --- a/services/api/src/services/push-token-policy.ts +++ b/services/api/src/services/push-token-policy.ts @@ -22,6 +22,8 @@ export function makeBoundedAddressResolver( tries: opts.tries ?? PUSH_DNS_TRIES, }) return async (host) => { + // A host with no record of one family answers that lookup with an error (ENODATA), which is + // normal; if both come back empty the caller refuses the endpoint. const [v4, v6] = await Promise.all([ resolver.resolve4(host).catch(() => [] as string[]), resolver.resolve6(host).catch(() => [] as string[]), @@ -70,6 +72,7 @@ export async function resolveSafePushTarget( try { addresses = await resolve(host) } catch { + // Fail closed: an endpoint whose addresses cannot be checked is never contacted. return null } if (addresses.length === 0) return null diff --git a/services/api/src/services/report-chat-emitter.ts b/services/api/src/services/report-chat-emitter.ts index 6a230823..af75a809 100644 --- a/services/api/src/services/report-chat-emitter.ts +++ b/services/api/src/services/report-chat-emitter.ts @@ -66,13 +66,6 @@ export function makeContainerReportChatEmitter( }) const conversationMutes = makeConversationMutesRepository(sql) - const isMuted = async (userId: string, roomId: string): Promise => { - try { - return await conversationMutes.isMuted(userId, "report", roomId) - } catch { - return false - } - } /** * Batch mute shape: one query for the room's whole member set instead of one per recipient. @@ -93,8 +86,8 @@ export function makeContainerReportChatEmitter( const notify = makeReportChatNotifier({ notificationService, - reportChatRepo: { listMemberIds: (reportId) => reportChatRepo.listMemberIds(reportId) }, - isMuted, + reportChatRepo, + isMuted: (userId, roomId) => conversationMutes.isMuted(userId, "report", roomId), ...(mutedUserIdsFor ? { mutedUserIdsFor } : {}), // presence intentionally omitted — not reachable from the admin/citizen service context (see header). roomKeyFor, @@ -110,6 +103,7 @@ export function makeContainerReportChatEmitter( // requires the repo at construction time, and it would buy nothing: see above, a null actor // short-circuits the gate before either shape is consulted. isBlockedEitherWay: (a, b) => container.getBlocksRepo().isBlockedEitherWay(a, b), + logger, }) return makeReportChatSystemEmitter({ diff --git a/services/api/src/services/report-chat-notifier.ts b/services/api/src/services/report-chat-notifier.ts index 3e3061d5..eefc501b 100644 --- a/services/api/src/services/report-chat-notifier.ts +++ b/services/api/src/services/report-chat-notifier.ts @@ -1,12 +1,13 @@ import type { ChatMessageDTO } from "@civfix/shared" +import type { FastifyBaseLogger } from "fastify" import type { NotificationService } from "./notification-service.js" -import { makeRoomFanoutNotifier } from "./chat-room-fanout-notifier.js" +import { makeRoomFanoutNotifier, ROOM_FANOUT_SPEC } from "./chat-room-fanout-notifier.js" -export const REPORT_CHAT_FANOUT_MEMBER_CAP = 500 +export { REPORT_CHAT_FANOUT_MEMBER_CAP } from "./chat-room-fanout-notifier.js" export interface ReportChatNotifierDeps { - notificationService: Pick - reportChatRepo: { listMemberIds(reportId: string, limit?: number): Promise } + notificationService: Pick + reportChatRepo: { listMemberIds(reportId: string, limit: number): Promise } isMuted: (userId: string, roomId: string) => Promise mutedUserIdsFor?: (roomId: string, userIds: string[]) => Promise> presence?: { online(roomKey: string): Promise } @@ -17,27 +18,25 @@ export interface ReportChatNotifierDeps { now?: () => number claimWindow?: (roomId: string, windowMs: number) => Promise dispatchToJob?: (roomId: string, messageId: string) => Promise + logger?: Pick | undefined } export function makeReportChatNotifier( deps: ReportChatNotifierDeps, ): (reportId: string, message: ChatMessageDTO) => Promise { - return makeRoomFanoutNotifier( - { kind: "report", titleFallbackKey: "notification.report_chat.title_fallback" }, - { - notificationService: deps.notificationService, - listMemberIds: (reportId) => - deps.reportChatRepo.listMemberIds(reportId, REPORT_CHAT_FANOUT_MEMBER_CAP), - isMuted: deps.isMuted, - ...(deps.mutedUserIdsFor ? { mutedUserIdsFor: deps.mutedUserIdsFor } : {}), - presence: deps.presence, - roomKey: (reportId) => deps.roomKeyFor("report", reportId), - isBlockedEitherWay: deps.isBlockedEitherWay, - ...(deps.blockedIdsFor ? { blockedIdsFor: deps.blockedIdsFor } : {}), - ...(deps.coalesceWindowMs !== undefined ? { coalesceWindowMs: deps.coalesceWindowMs } : {}), - ...(deps.now !== undefined ? { now: deps.now } : {}), - ...(deps.claimWindow !== undefined ? { claimWindow: deps.claimWindow } : {}), - ...(deps.dispatchToJob !== undefined ? { dispatchToJob: deps.dispatchToJob } : {}), - }, - ) + return makeRoomFanoutNotifier(ROOM_FANOUT_SPEC.report, { + notificationService: deps.notificationService, + listMemberIds: (reportId, limit) => deps.reportChatRepo.listMemberIds(reportId, limit), + isMuted: deps.isMuted, + ...(deps.mutedUserIdsFor ? { mutedUserIdsFor: deps.mutedUserIdsFor } : {}), + presence: deps.presence, + roomKey: (reportId) => deps.roomKeyFor("report", reportId), + isBlockedEitherWay: deps.isBlockedEitherWay, + ...(deps.blockedIdsFor ? { blockedIdsFor: deps.blockedIdsFor } : {}), + ...(deps.coalesceWindowMs !== undefined ? { coalesceWindowMs: deps.coalesceWindowMs } : {}), + ...(deps.now !== undefined ? { now: deps.now } : {}), + ...(deps.claimWindow !== undefined ? { claimWindow: deps.claimWindow } : {}), + ...(deps.dispatchToJob !== undefined ? { dispatchToJob: deps.dispatchToJob } : {}), + ...(deps.logger !== undefined ? { logger: deps.logger } : {}), + }) } diff --git a/services/api/src/services/report-chat-repository.drizzle.ts b/services/api/src/services/report-chat-repository.drizzle.ts index 69b25f96..44a66824 100644 --- a/services/api/src/services/report-chat-repository.drizzle.ts +++ b/services/api/src/services/report-chat-repository.drizzle.ts @@ -26,7 +26,7 @@ export interface SystemChatRow { export interface ReportMemberRowSelect { user_id: string role: "owner" | "member" - joined_at: string + joined_at: Date display_name: string | null handle: string | null bio: string | null @@ -57,7 +57,7 @@ export function toReportParticipantDTO(r: ReportMemberRowSelect): ReportChatPart isFollowing: r.is_following, ...(author.deleted ? { deleted: true } : {}), } - return { user, role: r.role, joinedAt: r.joined_at } + return { user, role: r.role, joinedAt: r.joined_at.toISOString() } } export const REPORT_CHAT_ROSTER_CAP = 200 diff --git a/services/api/src/services/report-chat-send-wiring.ts b/services/api/src/services/report-chat-send-wiring.ts index 6e499eaa..14be2a03 100644 --- a/services/api/src/services/report-chat-send-wiring.ts +++ b/services/api/src/services/report-chat-send-wiring.ts @@ -6,7 +6,10 @@ import { makeReportChatNotifier } from "./report-chat-notifier.js" import { makeChatMentionNotifier, type ChatBellDeps } from "./chat-bells.js" import { makeNotificationService } from "./notification-service.js" import { makeDrizzleNotificationRepository } from "./notification-repository.drizzle.js" -import { makeConversationMutesRepository } from "./conversation-mutes-repository.drizzle.js" +import { + makeConversationMutesRepository, + makeFailOpenMuteCheck, +} from "./conversation-mutes-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "./cleanup-repository.drizzle.js" import { makeChatGroupRepository } from "./chat-group-repository.drizzle.js" import { makeContainerReportCityForward } from "./report-city-forward-wiring.js" @@ -62,17 +65,7 @@ export function makeContainerReportChatSendDeps( }) const conversationMutes = makeConversationMutesRepository(sql) - const isMutedFor = async ( - userId: string, - kind: "dm" | "cleanup" | "report" | "group", - roomId: string, - ): Promise => { - try { - return await conversationMutes.isMuted(userId, kind, roomId) - } catch { - return false - } - } + const isMutedFor = makeFailOpenMuteCheck(conversationMutes, logger) const mutedUserIdsFor = ((): | ((roomId: string, userIds: string[]) => Promise>) @@ -112,18 +105,22 @@ export function makeContainerReportChatSendDeps( const notifyMembers = makeReportChatNotifier({ notificationService, - reportChatRepo: { listMemberIds: (reportId) => reportChatRepo.listMemberIds(reportId) }, - isMuted: (userId, roomId) => isMutedFor(userId, "report", roomId), + reportChatRepo, + isMuted: (userId, roomId) => conversationMutes.isMuted(userId, "report", roomId), ...(mutedUserIdsFor ? { mutedUserIdsFor } : {}), roomKeyFor, isBlockedEitherWay, ...(blockedIdsFor ? { blockedIdsFor } : {}), + logger, }) return { ...base, notifyMembers, - forwardCityMention: makeContainerReportCityForward(container), + forwardCityMention: makeContainerReportCityForward( + container, + logger !== undefined ? { logger } : {}, + ), ...(options.mentions ? { mentions: { ...options.mentions, notifyChatMention: makeChatMentionNotifier(bellDeps) } } : {}), diff --git a/services/api/src/services/report-city-forward-wiring.ts b/services/api/src/services/report-city-forward-wiring.ts index 5b75275f..47f5638a 100644 --- a/services/api/src/services/report-city-forward-wiring.ts +++ b/services/api/src/services/report-city-forward-wiring.ts @@ -9,6 +9,7 @@ import { forwardReportCityMention, makeCityForwardThrottle, type CityForwardGate, + type CityForwardLogger, } from "./report-city-forward.js" export type ReportCityForwardEffect = ( @@ -20,6 +21,7 @@ export type ReportCityForwardEffect = ( export interface ReportCityForwardWiringOverrides { getReportRepo?: () => DiscussionRepository canForward?: CityForwardGate + logger?: CityForwardLogger } export const NOOP_REPORT_CITY_FORWARD: ReportCityForwardEffect = () => Promise.resolve() @@ -34,7 +36,8 @@ export function makeContainerReportCityForward( let throttle: CityForwardGate | undefined const canForward = (): CityForwardGate => - overrides.canForward ?? (throttle ??= makeCityForwardThrottle(container.getCounterStore())) + overrides.canForward ?? + (throttle ??= makeCityForwardThrottle(container.getCounterStore(), overrides.logger)) const getReportRepo = (): DiscussionRepository => overrides.getReportRepo?.() ?? @@ -69,6 +72,7 @@ export function makeContainerReportCityForward( canForward: canForward(), audit, messageId: message.id, + ...(overrides.logger !== undefined ? { logger: overrides.logger } : {}), }, ) } diff --git a/services/api/src/services/report-city-forward.ts b/services/api/src/services/report-city-forward.ts index 15c71b31..5d10ac08 100644 --- a/services/api/src/services/report-city-forward.ts +++ b/services/api/src/services/report-city-forward.ts @@ -33,13 +33,21 @@ export type CityForwardGate = ( actorUserId: string, ) => Promise +export interface CityForwardLogger { + warn(obj: unknown, msg?: string): void +} + export interface CityForwardOptions { canForward?: CityForwardGate audit?: ReportForwardAudit messageId?: string + logger?: CityForwardLogger } -export function makeCityForwardThrottle(counters: CounterStore): CityForwardGate { +export function makeCityForwardThrottle( + counters: CounterStore, + logger?: CityForwardLogger, +): CityForwardGate { return async (reportId, geoid, actorUserId) => { try { const dedup = await counters.incr( @@ -58,7 +66,9 @@ export function makeCityForwardThrottle(counters: CounterStore): CityForwardGate if (perGeoid > CITY_FORWARD_PER_GEOID_PER_HOUR) return false return true - } catch { + } catch (err) { + // Fail closed: without the counters nothing bounds how often one sender can mail a city. + logger?.warn({ err, reportId, geoid }, "city forward throttle unavailable; forward skipped") return false } } @@ -84,7 +94,7 @@ export async function forwardReportCityMention( if (contact === null || contact === "") { return { mentioned: true, geoid, forwarded: false, forwardedAt: null } } - const thread = await existingReportThread(outboundMail, ctx.reportId) + const thread = await existingReportThread(outboundMail, ctx.reportId, opts.logger) if (thread === null) { return { mentioned: true, geoid, forwarded: false, forwardedAt: null } } @@ -115,7 +125,8 @@ export async function forwardReportCityMention( }) await markForwarded(opts, geoid) return { mentioned: true, geoid, forwarded: true, forwardedAt: createdAt } - } catch { + } catch (err) { + opts.logger?.warn({ err, reportId: ctx.reportId, geoid }, "city forward send failed") return { mentioned: true, geoid, forwarded: false, forwardedAt: null } } } @@ -128,20 +139,32 @@ export function discussionForwardSubject(threadSubject: string | null, fallback: async function existingReportThread( outboundMail: OutboundMailService, reportId: string, + logger: CityForwardLogger | undefined, ): Promise { try { return await outboundMail.findReportThread(reportId) - } catch { + } catch (err) { + logger?.warn({ err, reportId }, "city forward thread lookup failed; forward skipped") return null } } async function recordMention(opts: CityForwardOptions, geoid: string): Promise { if (opts.audit === undefined || opts.messageId === undefined) return - await opts.audit.recordMention(opts.messageId, geoid).catch(() => {}) + const messageId = opts.messageId + await opts.audit + .recordMention(messageId, geoid) + .catch((err: unknown) => + opts.logger?.warn({ err, messageId, geoid }, "city forward mention audit write failed"), + ) } async function markForwarded(opts: CityForwardOptions, geoid: string): Promise { if (opts.audit === undefined || opts.messageId === undefined) return - await opts.audit.markForwarded(opts.messageId, geoid).catch(() => {}) + const messageId = opts.messageId + await opts.audit + .markForwarded(messageId, geoid) + .catch((err: unknown) => + opts.logger?.warn({ err, messageId, geoid }, "city forward delivery audit write failed"), + ) } diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index 5baf999d..34355456 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -8,14 +8,20 @@ import type { ReportVisibility, } from "@civfix/shared" import type { Queryable, Sql } from "../db/client.js" -import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" -import { isPubliclyVisibleStatus } from "./report-visibility.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../db/cursor-helpers.js" +import { isPubliclyVisibleStatus, ownerStatusTransition } from "./report-visibility.js" import { allocateReportReferenceCode } from "../db/reference-code.js" import { escapeLike } from "./admin/like.js" import type { BBox, CreateReportTxArgs, CreateReportTxResult, + OwnerToggleStatus, ReportMapPoint, ReportMediaView, ReportRecord, @@ -314,11 +320,11 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { cursor: string | null, limit: number, ): Promise<{ records: ReportRecord[]; nextCursor: string | null }> { - const anchor = parseTimeCursor(cursor) + const anchor = parseKeysetCursor(cursor) const cursorFilter: SqlFragment = - anchor !== null ? sql`AND (created_at, id) < (${anchor.at}, ${anchor.id}::uuid)` : sql`` - const rows = await sql` - SELECT ${reportColumns(sql)} + anchor !== null ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, anchor)}` : sql`` + const rows = await sql<(ReportRowSelect & { cursor_at: string | null })[]>` + SELECT ${reportColumns(sql)}, ${keysetInstant(sql, sql`created_at`)} AS cursor_at FROM reports WHERE reporter_user_id = ${userId} AND deleted_at IS NULL @@ -326,7 +332,10 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { ORDER BY created_at DESC, id DESC LIMIT ${limit + 1} ` - const { items, nextCursor } = paginate(rows, limit, (r) => ({ at: r.created_at, id: r.id })) + const { items, nextCursor } = paginateKeyset(rows, limit, (r) => ({ + atText: r.cursor_at, + id: r.id, + })) return { records: items.map(toRecord), nextCursor } }, @@ -364,9 +373,11 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { if (args.q !== null && args.q.length < REPORT_SEARCH_MIN_QUERY_LENGTH) { return { points: [], nextCursor: null } } - const anchor = parseTimeCursor(args.cursor) + const anchor = parseKeysetCursor(args.cursor) const cursorFilter: SqlFragment = - anchor !== null ? sql`AND (r.created_at, r.id) < (${anchor.at}, ${anchor.id}::uuid)` : sql`` + anchor !== null + ? sql`AND ${keysetPredicate(sql, sql`r.created_at`, sql`r.id`, anchor)}` + : sql`` const categoryFilter: SqlFragment = args.categories !== null && args.categories.length > 0 ? sql`AND r.category IN ${sql(args.categories)}` @@ -387,8 +398,8 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { sql`ORDER BY r.created_at DESC, r.id DESC`, args.limit + 1, ) - const { items, nextCursor } = paginate(rows, args.limit, (r) => ({ - at: r.created_at, + const { items, nextCursor } = paginateKeyset(rows, args.limit, (r) => ({ + atText: r.cursor_at, id: r.id, })) return { points: items.map(toMapPoint), nextCursor } @@ -397,8 +408,8 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { async resolveByOwner( reportId: string, userId: string, - input: { status: ReportStatus; note: string }, - ): Promise<"updated" | "not_found" | "forbidden" | "invalid_state"> { + input: { status: OwnerToggleStatus; note: string }, + ): Promise<"updated" | "unchanged" | "not_found" | "forbidden" | "invalid_state"> { return sql.begin(async (tx) => { const rows = await tx< { @@ -417,7 +428,8 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { const row = rows[0] if (!row || row.deleted_at !== null) return "not_found" if (row.reporter_user_id !== userId) return notOwnerOutcome(row) - if (!isPubliclyVisibleStatus(row.status)) return "invalid_state" + const transition = ownerStatusTransition(row.status, input.status) + if (transition !== "apply") return transition await tx`UPDATE reports SET status = ${input.status} WHERE id = ${reportId}` await tx` diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index 59dd8898..c1f873f8 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -11,7 +11,6 @@ import type { ReportClusterResponse, ReportDTO, ReportPinDTO, - ReportStatus, ReportTimelineEntryDTO, ReportType, ReportVisibility, @@ -38,6 +37,7 @@ import { REPORTS_DEFAULT_LIMIT, REPORTS_SEARCH_DEFAULT_LIMIT, type BBox, + type OwnerToggleStatus, type ReportAutoForwardJob, type ReportChatMeta, type ReportDiscussionMeta, @@ -189,7 +189,8 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { if (deps.loadDiscussionMeta === undefined) return null try { return await deps.loadDiscussionMeta(reportId) - } catch { + } catch (err) { + deps.logger?.warn({ err, reportId }, "report: discussion meta failed to load; omitted") return null } } @@ -201,11 +202,50 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { if (deps.loadReportChatMeta === undefined) return null try { return await deps.loadReportChatMeta(reportId, viewerId) - } catch { + } catch (err) { + deps.logger?.warn({ err, reportId }, "report: chat meta failed to load; omitted") return null } } + async function viewReport( + record: ReportRecord, + viewerId: string | null, + ): Promise { + if (record.deletedAt !== null) return null + const mine = viewerId !== null && record.reporterUserId === viewerId + const isPublic = isPubliclyVisibleStatus(record.status) && record.visibility === "public" + if (!isPublic && !mine) return null + + const [media, timeline, validatingCount, linkedEvents, discussionMeta, chatMeta] = + await Promise.all([ + deps.repo.findMediaForReport(record.id, mine), + deps.repo.findTimelineForReport(record.id), + deps.repo.countValidatingMediaForReport(record.id), + linkedEventsFor(record.id), + discussionMetaFor(record.id), + chatMetaFor(record.id, viewerId), + ]) + + const validatingShown = media.reduce((n, m) => (m.status === "validating" ? n + 1 : n), 0) + const mediaPending = Math.max(0, validatingCount - validatingShown) + + return toReportDTO(record, media, timeline, { + mine, + mediaPending, + linkedEvents, + discussionMeta, + chatMeta, + }) + } + + // The stored snapshot proves the key was already used, but its presigned media URLs expired minutes after + // the create while the key lives for days, so a replay answers with the live report whenever it exists. + async function replayedReport(snapshot: ReportDTO, ownerId: string): Promise { + const record = await deps.repo.findReportById(snapshot.id) + return (record ? await viewReport(record, ownerId) : null) ?? snapshot + } + const service: ReportService = { async createReport(input: CreateReportRequest, owner: SignedInReportOwner): Promise { if (input.honeypot !== undefined && input.honeypot.trim() !== "") { @@ -221,7 +261,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { REPORT_CREATE_SCOPE, owner.userId, ) - if (existing) return existing + if (existing) return replayedReport(existing, owner.userId) const category = REPORT_TYPE_TO_CATEGORY[input.type] @@ -273,7 +313,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { toReportDTO(record, media, timeline, { mine: true }), }) - if (result.kind === "replayed") return result.snapshot + if (result.kind === "replayed") return replayedReport(result.snapshot, owner.userId) await maybeJoinReportChatAsOwner(deps, result.snapshot.id, owner.userId) @@ -286,38 +326,9 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { const record = isUuid(id) ? await deps.repo.findReportById(id) : await deps.repo.findReportByReferenceCode(id) - if (!record || record.deletedAt !== null) { - throw AppError.notFound("Report not found") - } - - const viewerId = viewer.userId ?? null - const mine = viewerId !== null && record.reporterUserId === viewerId - - const isPublic = isPubliclyVisibleStatus(record.status) && record.visibility === "public" - if (!isPublic && !mine) { - throw AppError.notFound("Report not found") - } - - const [media, timeline, validatingCount, linkedEvents, discussionMeta, chatMeta] = - await Promise.all([ - deps.repo.findMediaForReport(record.id, mine), - deps.repo.findTimelineForReport(record.id), - deps.repo.countValidatingMediaForReport(record.id), - linkedEventsFor(record.id), - discussionMetaFor(record.id), - chatMetaFor(record.id, viewerId), - ]) - - const validatingShown = media.reduce((n, m) => (m.status === "validating" ? n + 1 : n), 0) - const mediaPending = Math.max(0, validatingCount - validatingShown) - - return toReportDTO(record, media, timeline, { - mine, - mediaPending, - linkedEvents, - discussionMeta, - chatMeta, - }) + const dto = record ? await viewReport(record, viewer.userId ?? null) : null + if (dto === null) throw AppError.notFound("Report not found") + return dto }, async listMyReports( @@ -401,7 +412,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { }, async resolveReport(userId: string, reportId: string, resolved: boolean): Promise { - const status: ReportStatus = resolved ? "resolved" : "published" + const status: OwnerToggleStatus = resolved ? "resolved" : "published" const note = resolved ? "Marked resolved by the reporter" : "Reopened by the reporter" const outcome = await deps.repo.resolveByOwner(reportId, userId, { status, note }) if (outcome === "not_found") throw AppError.notFound("Report not found") @@ -411,6 +422,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { if (outcome === "invalid_state") { throw AppError.conflict("This report cannot be resolved or reopened from its current state") } + if (outcome === "unchanged") return service.getReport(reportId, { userId }) await maybeEmitTimeline(deps, { reportId, status, kind: resolved ? "done" : "status", note }) return service.getReport(reportId, { userId }) }, diff --git a/services/api/src/services/report-service.types.ts b/services/api/src/services/report-service.types.ts index c60b4817..21db896d 100644 --- a/services/api/src/services/report-service.types.ts +++ b/services/api/src/services/report-service.types.ts @@ -178,8 +178,8 @@ export interface ReportRepository { resolveByOwner( reportId: string, userId: string, - input: { status: ReportStatus; note: string }, - ): Promise<"updated" | "not_found" | "forbidden" | "invalid_state"> + input: { status: OwnerToggleStatus; note: string }, + ): Promise<"updated" | "unchanged" | "not_found" | "forbidden" | "invalid_state"> setVisibilityByOwner( reportId: string, userId: string, @@ -187,6 +187,8 @@ export interface ReportRepository { ): Promise<"updated" | "unchanged" | "not_found" | "forbidden"> } +export type OwnerToggleStatus = Extract + export interface BBox { west: number south: number diff --git a/services/api/src/services/report-sql.ts b/services/api/src/services/report-sql.ts index 4756177d..1988d887 100644 --- a/services/api/src/services/report-sql.ts +++ b/services/api/src/services/report-sql.ts @@ -1,5 +1,6 @@ import type postgres from "postgres" import type { Queryable } from "../db/client.js" +import { keysetInstant } from "../db/cursor-helpers.js" import type { AddressPrecision, ReportAddressSource, @@ -160,6 +161,7 @@ export interface PublicPinRow { thumb_key: string | null r2_key: string | null created_at: Date + cursor_at: string | null } export function toMapPoint(r: PublicPinRow): ReportMapPoint { @@ -199,7 +201,8 @@ export async function selectPublicPins( r.reference_code, m.thumb_key, m.r2_key, - r.created_at + r.created_at, + ${keysetInstant(sql, sql`r.created_at`)} AS cursor_at FROM reports r ${firstReadyStillLateral(sql)} WHERE ${publicReportFilter(sql)} diff --git a/services/api/src/services/report-visibility.ts b/services/api/src/services/report-visibility.ts index 14b2d7df..2729c8ab 100644 --- a/services/api/src/services/report-visibility.ts +++ b/services/api/src/services/report-visibility.ts @@ -1,5 +1,6 @@ import type { ReportStatus } from "@civfix/shared" import type { DiscussionReportView } from "./discussion-types.js" +import type { OwnerToggleStatus } from "./report-service.types.js" /** * The statuses at which a report is PUBLICLY readable. @@ -30,6 +31,20 @@ export function isPubliclyVisibleStatus(status: string): boolean { return (PUBLIC_REPORT_STATUSES as readonly string[]).includes(status) } +export type OwnerStatusTransition = "apply" | "unchanged" | "invalid_state" + +// The owner may only toggle resolved <-> open. Reopening a report that is not resolved is a no-op so it can +// never regress the city's acknowledged / in_progress progress back to published, and repeating the current +// state writes no timeline row and announces nothing. +export function ownerStatusTransition( + current: string, + target: OwnerToggleStatus, +): OwnerStatusTransition { + if (!isPubliclyVisibleStatus(current)) return "invalid_state" + const isResolved = current === "resolved" + return isResolved === (target === "resolved") ? "unchanged" : "apply" +} + /** * The single source of truth for "may this viewer see this report". * diff --git a/services/api/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index ecbb3ee3..a3a0ce1c 100644 --- a/services/api/src/services/social-repository.drizzle.ts +++ b/services/api/src/services/social-repository.drizzle.ts @@ -20,8 +20,11 @@ import type { import { encodeNameCursor, encodeTimeCursor, + keysetInstant, + keysetPredicate, pageWith, - paginate, + paginateKeyset, + parseKeysetCursor, parseNameCursor, parseTimeCursor, } from "../db/cursor-helpers.js" @@ -231,14 +234,14 @@ function profileEventRows( ` } -type ConnectionRow = PersonRowSelectWithFollow & { edge_created_at: Date } +type ConnectionRow = PersonRowSelectWithFollow & { cursor_at: string | null } function pageConnections( rows: ConnectionRow[], limit: number, ): { items: Array; nextCursor: string | null } { - const { items, nextCursor } = paginate(rows, limit, (last) => ({ - at: last.edge_created_at, + const { items, nextCursor } = paginateKeyset(rows, limit, (last) => ({ + atText: last.cursor_at, id: last.id, })) const sorted = [...items].sort((a, b) => { @@ -256,10 +259,10 @@ async function connectionsPage( args: { viewerId: string | null; cursor: string | null; limit: number }, joinPredicate: ReturnType, ): Promise<{ items: Array; nextCursor: string | null }> { - const cursor = parseTimeCursor(args.cursor) + const cursor = parseKeysetCursor(args.cursor) const viewerId = args.viewerId const cursorFilter = - cursor !== null ? sql`AND (f.created_at, u.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` + cursor !== null ? sql`AND ${keysetPredicate(sql, sql`f.created_at`, sql`u.id`, cursor)}` : sql`` const followingExpr = viewerId !== null ? sql`EXISTS (SELECT 1 FROM follows_people ff WHERE ff.follower_id = ${viewerId} AND ff.followee_id = u.id)` @@ -284,14 +287,15 @@ async function connectionsPage( ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, u.avatar_url, u.show_volunteer_hours, - u.edge_created_at, + u.cursor_at, ${followingExpr} AS is_following FROM ( SELECT u.id, u.display_name, u.handle, u.bio, u.avatar_media_id, u.avatar_url, u.show_volunteer_hours, u.follower_count, u.following_count, - f.created_at AS edge_created_at + f.created_at AS edge_created_at, + ${keysetInstant(sql, sql`f.created_at`)} AS cursor_at FROM users u JOIN follows_people f ON ${joinPredicate} WHERE u.deleted_at IS NULL diff --git a/services/api/src/services/social-service.ts b/services/api/src/services/social-service.ts index c4705ce2..ab1fabc5 100644 --- a/services/api/src/services/social-service.ts +++ b/services/api/src/services/social-service.ts @@ -434,8 +434,11 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { if (follower) { try { await deps.notifier.onNewFollower({ followeeId: targetId, follower }) - } catch { - void 0 + } catch (err) { + deps.logger?.warn( + { err, viewerId, targetId }, + "social: new-follower notification failed (suppressed; the follow stands)", + ) } } else { deps.logger?.warn( diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 8ed7f516..53b0e45f 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -6,7 +6,7 @@ import type { VolunteerHoursSource, } from "@civfix/shared" import type { Queryable, Sql } from "../db/client.js" -import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" +import { keysetInstant, keysetPredicate, pageWith, paginateKeyset } from "../db/cursor-helpers.js" import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" import { publicServedKeyExpr } from "./media-served-key.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" @@ -499,14 +499,15 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep const sources = args.sources ?? ITEMISED_SOURCES const keyset = args.cursor !== null - ? sql`AND (vh.created_at, vh.id) < (${args.cursor.at}, ${args.cursor.id}::uuid)` + ? sql`AND ${keysetPredicate(sql, sql`vh.created_at`, sql`vh.id`, args.cursor)}` : sql`` - const rows = await sql` + const rows = await sql<(LedgerRow & { cursor_at: string })[]>` SELECT vh.id, vh.source, vh.hours::float8 AS hours, vh.created_at, + ${keysetInstant(sql, sql`vh.created_at`)} AS cursor_at, c.scheduled_at, vh.cleanup_id, c.title AS cleanup_title, @@ -528,9 +529,10 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep ORDER BY vh.created_at DESC, vh.id DESC LIMIT ${args.limit + 1} ` - const { items, nextCursor } = pageWith(rows, args.limit, (last) => - encodeTimeCursor({ at: last.created_at, id: last.id }), - ) + const { items, nextCursor } = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.id, + })) return { items: items.map(toEntryView), nextCursor } }, diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index efa1438f..9ba32ef0 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -26,7 +26,7 @@ import type { VolunteerHoursSource, } from "@civfix/shared" import { can, type HostStanding } from "@civfix/shared/host" -import { parseTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" +import { parseKeysetCursor, type KeysetCursor } from "../db/cursor-helpers.js" import { MIN_EVENT_DURATION_MS, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "./media-presign.js" import type { AffiliationLoader } from "./affiliation.js" @@ -160,7 +160,7 @@ export const ITEMISED_SOURCES: readonly VolunteerHoursSource[] = ["event", "manu export interface ListEntriesArgs { userId: string - cursor: TimeCursor | null + cursor: KeysetCursor | null limit: number sources?: VolunteerHoursSource[] } @@ -293,6 +293,12 @@ function clampOffset(offset: number | undefined): number { return Math.min(Math.max(0, Math.floor(offset)), LEADERBOARD_MAX_OFFSET) } +// An offset past the ceiling clamps back onto the page just served, so a client following it would +// loop on that page forever. +function reachableNextOffset(next: number | null): number | null { + return next !== null && next <= LEADERBOARD_MAX_OFFSET ? next : null +} + function clampEntriesLimit(limit: number | undefined): number { if (limit === undefined) return HOURS_ENTRIES_DEFAULT_LIMIT return Math.min(Math.max(1, Math.floor(limit)), HOURS_ENTRIES_MAX_LIMIT) @@ -498,7 +504,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu ): Promise { const limit = clampEntriesLimit(query.limit) const [page, totalHours] = await Promise.all([ - deps.repo.listEntries({ userId, cursor: parseTimeCursor(query.cursor), limit }), + deps.repo.listEntries({ userId, cursor: parseKeysetCursor(query.cursor), limit }), deps.repo.totalHoursFor(userId), ]) return { @@ -534,7 +540,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu visibility.items ? deps.repo.listEntries({ userId, - cursor: parseTimeCursor(query.cursor), + cursor: parseKeysetCursor(query.cursor), limit, sources: ["event"], }) @@ -670,7 +676,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu geoid, jurisdictionName: page.jurisdictionName, entries: page.entries, - nextOffset: page.nextOffset, + nextOffset: reachableNextOffset(page.nextOffset), ...(page.participantCount !== null ? { participantCount: page.participantCount } : {}), ...(viewerId !== null && withExtras ? { viewerRank: page.viewerRank, viewerHours: page.viewerHours } diff --git a/services/api/src/ws/frame-handler.ts b/services/api/src/ws/frame-handler.ts index 1d0ce385..4b5edda7 100644 --- a/services/api/src/ws/frame-handler.ts +++ b/services/api/src/ws/frame-handler.ts @@ -194,6 +194,7 @@ export async function canStillRead(session: GatewaySession, roomKey: string): Pr const auth = await authorizeRoom(deps, kind, id, userId) return auth.ok } catch { + // A lookup outage keeps the socket in its rooms; the next reauthorization pass decides again. return true } } diff --git a/services/api/src/ws/socket-lifecycle.ts b/services/api/src/ws/socket-lifecycle.ts index 729b1a15..0c95849c 100644 --- a/services/api/src/ws/socket-lifecycle.ts +++ b/services/api/src/ws/socket-lifecycle.ts @@ -127,6 +127,7 @@ export async function checkSocketAuthorization( } return { authorized: true } } catch { + // A session-store outage must not drop every live socket at once; the next heartbeat re-checks. return { authorized: true } } } @@ -348,7 +349,9 @@ export function registerChatGateway(app: FastifyInstance, opts: RegisterGatewayO return } closeForAuth() - })().catch(() => {}) + })().catch((err: unknown) => { + request.log.warn({ err }, "ws: heartbeat reauthorization failed") + }) if (socket.bufferedAmount > WS_BUFFER_DROP_THRESHOLD) { if (++overBufferTicks >= WS_BUFFER_TERMINATE_TICKS) { socket.terminate() diff --git a/services/api/src/ws/types.ts b/services/api/src/ws/types.ts index b2a80c9c..3e614a90 100644 --- a/services/api/src/ws/types.ts +++ b/services/api/src/ws/types.ts @@ -1,4 +1,5 @@ import type { RoomKind } from "@civfix/shared" +import type { FastifyBaseLogger } from "fastify" import type { ChatService, ChatConnection, UserChannel } from "@civfix/shared/interfaces" import type { ChatPresence } from "../adapters/chat-presence.js" import type { RateLimiter } from "./report-rate-limit.js" @@ -29,15 +30,17 @@ export const WS_BUFFER_DROP_THRESHOLD = 1024 * 1024 export const WS_BUFFER_TERMINATE_TICKS = 2 -export const WS_HANDSHAKE_FRAME_BUFFER = 32 - -export const WS_HANDSHAKE_BUFFER_BYTES = 64 * 1024 - // Counts the frame in flight. A full token-bucket burst, or a reconnect re-joining every room, has to // fit behind one slow handler without closing the socket (a close makes the client reconnect and // replay the same burst); the bucket still rejects the excess as each frame is dequeued. export const WS_MAX_QUEUED_FRAMES = Math.max(WS_FRAME_LIMIT.capacity, WS_MAX_JOINED_ROOMS) +// A reconnecting client pipelines the same burst before its handshake settles. A frame over this cap is +// dropped without any reply, so it must not be smaller than what the live socket would queue and answer. +export const WS_HANDSHAKE_FRAME_BUFFER = WS_MAX_QUEUED_FRAMES + +export const WS_HANDSHAKE_BUFFER_BYTES = 64 * 1024 + export const WS_MAX_QUEUED_BYTES = 256 * 1024 export const WS_FRAME_RATE_LIMITED_MESSAGE = "You're sending frames too fast. Please slow down." @@ -119,6 +122,7 @@ export interface GatewayChatMentions { roomId: string }): Promise recordChatMentions(messageId: string, mentionedUserIds: string[]): Promise + logger?: Pick | undefined notifyChatMention(input: { kind: RoomKind roomId: string diff --git a/services/api/test/helpers/cleanups.ts b/services/api/test/helpers/cleanups.ts index b1f61cfc..eaa9900e 100644 --- a/services/api/test/helpers/cleanups.ts +++ b/services/api/test/helpers/cleanups.ts @@ -4,6 +4,8 @@ import type { Sql } from "../../src/db/client.js" import type { AttendeeView, CancelCleanupOutcome, + CleanupEditOutcome, + CleanupEdits, CleanupOrganizationView, CreateCleanupOutcome, ClaimSlotOutcome, @@ -868,6 +870,52 @@ export class InMemoryCleanupRepository implements CleanupRepository { return Promise.resolve(true) } + async updateCleanupWithEdits( + id: string, + patch: UpdateCleanupPatch, + edits: CleanupEdits, + ): Promise { + const cleanup = this.cleanups.get(id) + if (!cleanup) return { kind: "not_found" } + if (cleanup.status === "cancelled") return { kind: "cancelled" } + if ( + edits.refusalOnceEnded !== null && + hasEventEnded(eventWindowOf(cleanup), this.now().getTime()) + ) { + throw edits.refusalOnceEnded + } + const rollBack = this.snapshotEditState(cleanup) + try { + await this.updateCleanup(id, patch) + if (edits.links !== null) { + await this.reconcileLinkedReports(id, edits.links, edits.actorUserId) + } + const slotDiff = + edits.slots === null ? null : await this.reconcileSlots(id, edits.slots, edits.actorUserId) + return { kind: "updated", slotDiff } + } catch (err) { + rollBack() + throw err + } + } + + /** Stands in for the Postgres transaction: every store one edit can touch goes back as it was. */ + private snapshotEditState(cleanup: StoredCleanup): () => void { + const cleanupFields = { ...cleanup } + const links = [...this.links] + const timeline = [...this.timeline] + const slots = this.slots.map((slot) => [slot, { ...slot }] as const) + const slotClaims = [...this.slotClaims] + return () => { + Object.assign(cleanup, cleanupFields) + this.links.splice(0, this.links.length, ...links) + this.timeline.splice(0, this.timeline.length, ...timeline) + for (const [slot, fields] of slots) Object.assign(slot, fields) + this.slots.splice(0, this.slots.length, ...slots.map(([slot]) => slot)) + this.slotClaims.splice(0, this.slotClaims.length, ...slotClaims) + } + } + linkReports(cleanupId: string, reportIds: string[], actorId: string | null): Promise { return Promise.resolve(this.linkInner(cleanupId, reportIds, actorId)) } diff --git a/services/api/test/helpers/guest-rsvp.ts b/services/api/test/helpers/guest-rsvp.ts index afaeb5d3..416bb682 100644 --- a/services/api/test/helpers/guest-rsvp.ts +++ b/services/api/test/helpers/guest-rsvp.ts @@ -176,7 +176,7 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo return Promise.resolve(true) } - upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string }> { + upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string; created: boolean }> { const existing = this.guests.find( (g) => g.cleanupId === args.cleanupId && @@ -191,7 +191,7 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo existing.manageTokenHash = args.manageTokenHash existing.verifiedAt = args.now existing.contactScrubbedAt = null - return Promise.resolve({ id: existing.id }) + return Promise.resolve({ id: existing.id, created: false }) } const row: StoredGuest = { id: randomUUID(), @@ -208,7 +208,7 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo createdAt: this.nextCreatedAt(), } this.guests.push(row) - return Promise.resolve({ id: row.id }) + return Promise.resolve({ id: row.id, created: true }) } findGuestByManageTokenHash( diff --git a/services/api/test/helpers/media.ts b/services/api/test/helpers/media.ts index 402d15de..d0fbede5 100644 --- a/services/api/test/helpers/media.ts +++ b/services/api/test/helpers/media.ts @@ -4,7 +4,7 @@ import type { NewMediaAsset, } from "../../src/services/media-intake-service.js" -type StoredMedia = MediaAssetView +type StoredMedia = MediaAssetView & { uploadEtag: string | null } export class InMemoryMediaRepository implements MediaRepository { readonly byId = new Map() @@ -30,6 +30,7 @@ export class InMemoryMediaRepository implements MediaRepository { chatMessageId: null, postId: null, finalizedAt: null, + uploadEtag: null, createdAt: new Date(), uploader: row.uploader, } @@ -50,13 +51,14 @@ export class InMemoryMediaRepository implements MediaRepository { return Promise.resolve(row ? { ...row } : null) } - markFinalized(uploadId: string): Promise { + markFinalized(uploadId: string, uploadEtag: string | null): Promise { const id = this.uploadIndex.get(uploadId) if (!id) return Promise.resolve(null) const row = this.byId.get(id) if (!row) return Promise.resolve(null) if (row.finalizedAt != null) return Promise.resolve(null) row.finalizedAt = new Date() + row.uploadEtag = uploadEtag return Promise.resolve({ ...row }) } diff --git a/services/api/test/helpers/notifications.ts b/services/api/test/helpers/notifications.ts index 566524ab..37657cce 100644 --- a/services/api/test/helpers/notifications.ts +++ b/services/api/test/helpers/notifications.ts @@ -233,13 +233,10 @@ export class InMemoryNotificationRepository implements NotificationRepository { return Promise.resolve() } - findRecentDuplicate(args: { - userId: string - type: NotificationType - link: string | null - body: string | null - since: Date - }): Promise { + // Find and insert run in one synchronous step, the single-threaded twin of the SQL key lock. + insertUnlessRecentDuplicate( + args: NewNotificationArgs & { since: Date }, + ): Promise<{ record: NotificationRecord; deduped: boolean }> { const match = this.notifications .filter( (n) => @@ -250,7 +247,14 @@ export class InMemoryNotificationRepository implements NotificationRepository { n.createdAt.getTime() > args.since.getTime(), ) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] - return Promise.resolve(match ?? null) + if (match) return Promise.resolve({ record: match, deduped: true }) + return this.insertNotification({ + userId: args.userId, + type: args.type, + title: args.title, + body: args.body, + link: args.link, + }).then((record) => ({ record, deduped: false })) } refreshUnreadNotification(args: { diff --git a/services/api/test/helpers/reports.ts b/services/api/test/helpers/reports.ts index cef39957..505a6fb1 100644 --- a/services/api/test/helpers/reports.ts +++ b/services/api/test/helpers/reports.ts @@ -3,6 +3,7 @@ import type { BBox, CreateReportTxArgs, CreateReportTxResult, + OwnerToggleStatus, ReportMapPoint, ReportMediaView, ReportRecord, @@ -11,7 +12,10 @@ import type { ReportVisibilityTimelineKind, } from "../../src/services/report-service.js" import { formatReferenceCode, reportScopeKey, typeCodeFor } from "../../src/db/reference-code.js" -import { isPubliclyVisibleStatus } from "../../src/services/report-visibility.js" +import { + isPubliclyVisibleStatus, + ownerStatusTransition, +} from "../../src/services/report-visibility.js" import { paginate, parseTimeCursor } from "../../src/db/cursor-helpers.js" import type { ReportDTO } from "@civfix/shared" @@ -404,12 +408,13 @@ export class InMemoryReportRepository implements ReportRepository { resolveByOwner( reportId: string, userId: string, - input: { status: ReportRecord["status"]; note: string }, - ): Promise<"updated" | "not_found" | "forbidden" | "invalid_state"> { + input: { status: OwnerToggleStatus; note: string }, + ): Promise<"updated" | "unchanged" | "not_found" | "forbidden" | "invalid_state"> { const r = this.reports.get(reportId) if (!r || r.deletedAt !== null) return Promise.resolve("not_found") if (r.reporterUserId !== userId) return Promise.resolve(notOwnerOutcome(r)) - if (!isPubliclyVisibleStatus(r.status)) return Promise.resolve("invalid_state") + const transition = ownerStatusTransition(r.status, input.status) + if (transition !== "apply") return Promise.resolve(transition) r.status = input.status this.timeline.push({ reportId, diff --git a/services/api/test/integration/admin-audit.test.ts b/services/api/test/integration/admin-audit.test.ts index eab98f5f..29becc98 100644 --- a/services/api/test/integration/admin-audit.test.ts +++ b/services/api/test/integration/admin-audit.test.ts @@ -11,7 +11,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleAuditRepository } from "../../src/services/admin/audit-repository.drizzle.js" -import { writeAudit } from "../../src/services/admin/audit.js" +import { writeAudit, type AdminAuditAction } from "../../src/services/admin/audit.js" import type { AuditRepository } from "../../src/services/admin/audit-service.js" const pg = await withPg() @@ -85,8 +85,8 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => it("filters by actor name OR exact actor id", async () => { const alice = await insertUser(h, "Alice") const bob = await insertUser(h, "Bob") - await writeAudit(h.sql, { actorId: alice, action: "x", target: "t1" }) - await writeAudit(h.sql, { actorId: bob, action: "y", target: "t2" }) + await writeAudit(h.sql, { actorId: alice, action: "report.flagged", target: "t1" }) + await writeAudit(h.sql, { actorId: bob, action: "event.flagged", target: "t2" }) const byName = await repo.list({ actor: "alice", @@ -95,7 +95,7 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => cursor: null, limit: 25, }) - expect(byName.records.map((r) => r.action)).toEqual(["x"]) + expect(byName.records.map((r) => r.action)).toEqual(["report.flagged"]) const byId = await repo.list({ actor: bob, @@ -104,12 +104,12 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => cursor: null, limit: 25, }) - expect(byId.records.map((r) => r.action)).toEqual(["y"]) + expect(byId.records.map((r) => r.action)).toEqual(["event.flagged"]) }) it("a non-uuid actor filter does not error (matches by name only)", async () => { const alice = await insertUser(h, "Alice") - await writeAudit(h.sql, { actorId: alice, action: "x", target: "t1" }) + await writeAudit(h.sql, { actorId: alice, action: "report.flagged", target: "t1" }) const page = await repo.list({ actor: "not-a-uuid", action: null, @@ -122,8 +122,15 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => it("paginates newest-first with the keyset cursor (no overlap)", async () => { const actor = await insertUser(h, "Op") - for (let i = 0; i < 5; i++) { - await writeAudit(h.sql, { actorId: actor, action: `a${i}`, target: `t${i}` }) + const actions: AdminAuditAction[] = [ + "report.flagged", + "report.unflagged", + "event.flagged", + "event.unflagged", + "user.flagged", + ] + for (const [i, action] of actions.entries()) { + await writeAudit(h.sql, { actorId: actor, action, target: `t${i}` }) } const first = await repo.list({ actor: null, diff --git a/services/api/test/integration/admin-keyset-precision-pg.test.ts b/services/api/test/integration/admin-keyset-precision-pg.test.ts new file mode 100644 index 00000000..41ee86c2 --- /dev/null +++ b/services/api/test/integration/admin-keyset-precision-pg.test.ts @@ -0,0 +1,71 @@ +/** + * Admin keyset paging over rows that share one transaction's now() (Docker-gated; CI runs it). A cursor + * built from the driver's millisecond Date skipped the rest of that instant on a DESC list and repeated the + * previous page's last row forever on an ASC list. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { makeDrizzleAuditRepository } from "../../src/services/admin/audit-repository.drizzle.js" +import { makeDrizzleActivityRepository } from "../../src/services/admin/activity-repository.drizzle.js" +import { writeAudit } from "../../src/services/admin/audit.js" + +const pg = await withPg() + +const ROWS_IN_ONE_TX = 3 +const PAGE_GUARD = 10 + +async function writeBurst(h: PgHarness): Promise { + await h.sql.begin(async (tx) => { + for (let i = 0; i < ROWS_IN_ONE_TX; i++) { + await writeAudit(tx, { actorId: null, action: "user.banned", target: `user:${i}` }) + } + }) +} + +describe.skipIf(!pg)("admin keyset cursors keep microsecond precision (integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + beforeEach(async () => { + await h.sql`TRUNCATE audit_log RESTART IDENTITY CASCADE` + await writeBurst(h) + }) + + afterAll(async () => { + await h.teardown() + }) + + it("the audit log pages through every row of one transaction, newest first", async () => { + const repo = makeDrizzleAuditRepository(h.sql) + const seen: string[] = [] + let cursor: string | null = null + for (let page = 0; page < PAGE_GUARD; page++) { + const res = await repo.list({ actor: null, action: null, target: null, cursor, limit: 1 }) + seen.push(...res.records.map((r) => r.id)) + cursor = res.nextCursor + if (cursor === null) break + } + expect(cursor).toBeNull() + expect(new Set(seen).size).toBe(ROWS_IN_ONE_TX) + expect(seen).toHaveLength(ROWS_IN_ONE_TX) + }) + + it("the activity feed pages oldest-first without repeating a row or looping", async () => { + const repo = makeDrizzleActivityRepository(h.sql) + const seen: string[] = [] + let cursor: string | null = null + for (let page = 0; page < PAGE_GUARD; page++) { + const res = await repo.list({ q: null, filter: "all", sort: "oldest", cursor, limit: 1 }) + seen.push(...res.records.filter((r) => r.source === "audit").map((r) => r.id)) + cursor = res.nextCursor + if (cursor === null) break + } + expect(cursor).toBeNull() + expect(seen).toHaveLength(ROWS_IN_ONE_TX) + expect(new Set(seen).size).toBe(ROWS_IN_ONE_TX) + }) +}) diff --git a/services/api/test/integration/admin-moderation.test.ts b/services/api/test/integration/admin-moderation.test.ts index b788781f..2d3db06b 100644 --- a/services/api/test/integration/admin-moderation.test.ts +++ b/services/api/test/integration/admin-moderation.test.ts @@ -301,6 +301,66 @@ describe.skipIf(!pg)("admin moderation repository (integration: real schema)", ( expect(um?.removals).toBe(1) }) + describe("owner takedown strikes", () => { + async function strikesFor(userId: string): Promise { + const rows = await h.sql<{ strikes: number }[]>` + SELECT strikes FROM user_moderation WHERE user_id = ${userId} + ` + return rows[0]?.strikes ?? 0 + } + + function ownerRequest(reportId: string, reporterUserId: string) { + return { + kind: "user_report" as const, + subjectType: "report" as const, + subjectId: reportId, + flag: "Owner takedown request", + reason: "please remove", + reporter: "@owner", + reporterUserId, + priority: "high" as const, + dedupeOpen: true, + } + } + + it("a pure owner takedown skips the strike", async () => { + const owner = await insertUser(h, testHandle()) + const reportId = await insertReport(h, { status: "published", reporterUserId: owner }) + const id = (await repo.createItem(ownerRequest(reportId, owner)))! + await repo.remove(id, { actorId: null, reason: null }) + expect(await strikesFor(owner)).toBe(0) + }) + + it("an owner request folded into a pipeline hold still strikes", async () => { + const owner = await insertUser(h, testHandle()) + const reportId = await insertReport(h, { status: "held", reporterUserId: owner }) + const id = await insertModerationItem(h.sql, { + kind: "image", + subjectType: "report", + subjectId: reportId, + flag: "Held media (NSFW)", + priority: "high", + }) + await expect(repo.createItem(ownerRequest(reportId, owner))).resolves.toBeNull() + await repo.remove(id, { actorId: null, reason: null }) + expect(await strikesFor(owner)).toBe(1) + }) + + it("a third-party report folded into an owner's item still strikes", async () => { + const owner = await insertUser(h, testHandle()) + const other = await insertUser(h, testHandle()) + const reportId = await insertReport(h, { status: "published", reporterUserId: owner }) + const id = (await repo.createItem(ownerRequest(reportId, owner)))! + await repo.createItem({ + ...ownerRequest(reportId, other), + flag: "User report", + priority: "med", + }) + await repo.remove(id, { actorId: null, reason: null }) + expect(await strikesFor(owner)).toBe(1) + }) + }) + it("hold extends the hold (report stays held; item leaves the queue)", async () => { const reportId = await insertReport(h, { status: "held" }) const id = (await repo.createItem({ diff --git a/services/api/test/integration/admin-reports.test.ts b/services/api/test/integration/admin-reports.test.ts index b05503e4..48b9f36c 100644 --- a/services/api/test/integration/admin-reports.test.ts +++ b/services/api/test/integration/admin-reports.test.ts @@ -184,6 +184,31 @@ describe.skipIf(!pg)("admin report repository (integration: real schema)", () => expect(routing?.routed).toBe(true) }) + it("getRouting skips a bounced per-category contact and a bounced legacy address", async () => { + const id = await insertReport(h, { category: "hazard" }) + await h.sql`UPDATE jurisdictions SET contact_emails = ARRAY['dead@lacity.gov', 'live@lacity.gov'], contact_updated_at = NULL WHERE geoid = ${GEOID}` + const threads = await h.sql<{ id: string }[]>` + INSERT INTO mail_threads (thread_token, jurisdiction_geoid, subject, status, report_id) + VALUES (${`bounce-${Math.random().toString(36).slice(2, 14)}`}, ${GEOID}, 'S', 'bounced', ${id}) + RETURNING id + ` + const threadId = threads[0]!.id + await h.sql` + INSERT INTO mail_events (thread_id, type, meta) + VALUES (${threadId}, 'bounced', ${h.sql.json({ failedRecipient: "DEAD@lacity.gov" })}) + ` + expect((await repo.getRouting(id))?.contact).toBe("live@lacity.gov") + + await h.sql`INSERT INTO jurisdiction_contacts (geoid, category, email, bounced_at) VALUES (${GEOID}, 'hazard', 'hazard@lacity.gov', now())` + expect((await repo.getRouting(id))?.contact).toBe("live@lacity.gov") + + await h.sql`UPDATE jurisdictions SET contact_emails = ARRAY['dead@lacity.gov'] WHERE geoid = ${GEOID}` + expect(await repo.getRouting(id)).toMatchObject({ contact: null, routed: false }) + + await h.sql`DELETE FROM mail_events WHERE thread_id = ${threadId}` + await h.sql`DELETE FROM mail_threads WHERE id = ${threadId}` + }) + it("setStatus writes report_timeline + an audit row", async () => { const id = await insertReport(h, { status: "submitted" }) const ok = await repo.setStatus(id, { status: "in_progress", note: "moving", actorId: null }) diff --git a/services/api/test/integration/cleanup-update-atomic-pg.test.ts b/services/api/test/integration/cleanup-update-atomic-pg.test.ts new file mode 100644 index 00000000..f32e2445 --- /dev/null +++ b/services/api/test/integration/cleanup-update-atomic-pg.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { AppError } from "@civfix/shared" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { + MAX_EVENTS_PER_REPORT, + makeDrizzleCleanupRepository, +} from "../../src/services/cleanup-repository.drizzle.js" +import type { CleanupRepository, DesiredSlot } from "../../src/services/cleanup-service.js" +import { LA_CITY } from "../../src/db/seed-fixtures.js" + +const pg = await withPg() + +const DAY_MS = 86_400_000 + +describe.skipIf(!pg)("an event edit commits whole or not at all (integration)", () => { + let h: PgHarness + let repo: CleanupRepository + + beforeAll(() => { + h = pg as PgHarness + repo = makeDrizzleCleanupRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id + ` + return u!.id + } + + async function newCleanup(organizerId: string, title: string, startsInMs = 2 * DAY_MS) { + return await seedCleanup(h.sql, { + organizerUserId: organizerId, + title, + lng: -118.25, + lat: 34.05, + scheduledAt: new Date(Date.now() + startsInMs), + jurisdictionGeoid: LA_CITY.geoid, + }) + } + + async function newPublicReport(title: string): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO reports ( + idempotency_key, geom, geom_source, category, title, status, visibility, h3_cell, jurisdiction_geoid + ) + VALUES ( + gen_random_uuid(), ST_SetSRID(ST_MakePoint(-118.35, 34.1), 4326), 'manual', 'trash', ${title}, + 'published', 'public', 'h0', ${LA_CITY.geoid} + ) + RETURNING id + ` + return row!.id + } + + async function titleOf(cleanupId: string): Promise { + const [row] = await h.sql< + { title: string }[] + >`SELECT title FROM cleanups WHERE id = ${cleanupId}` + return row!.title + } + + function newSlot(title: string): DesiredSlot { + return { + title, + description: null, + capacity: null, + startsAt: null, + endsAt: null, + sortOrder: 0, + } + } + + it("keeps the title when the link cap refuses the same save", async () => { + const org = await newUser("Atomic Org") + const cleanupId = await newCleanup(org, "Atomic sweep") + const saturated = await newPublicReport("Linked everywhere") + for (let i = 0; i < MAX_EVENTS_PER_REPORT; i += 1) { + const other = await newCleanup(org, `Other sweep ${i}`) + await h.sql` + INSERT INTO cleanup_reports (cleanup_id, report_id, linked_by_user_id) + VALUES (${other}, ${saturated}, ${org}) + ` + } + + await expect( + repo.updateCleanupWithEdits( + cleanupId, + { title: "Renamed" }, + { actorUserId: org, links: [saturated], slots: null, refusalOnceEnded: null }, + ), + ).rejects.toMatchObject({ httpStatus: 422 }) + + expect(await titleOf(cleanupId)).toBe("Atomic sweep") + const linked = await h.sql`SELECT 1 FROM cleanup_reports WHERE cleanup_id = ${cleanupId}` + expect(linked).toHaveLength(0) + }) + + it("rolls the title and the links back when the slot board hits the title index", async () => { + const org = await newUser("Slot Org") + const cleanupId = await newCleanup(org, "Slot sweep") + const report = await newPublicReport("Fresh link") + + await expect( + repo.updateCleanupWithEdits( + cleanupId, + { title: "Renamed" }, + { + actorUserId: org, + links: [report], + slots: [newSlot("Grill"), newSlot("GRILL")], + refusalOnceEnded: null, + }, + ), + ).rejects.toMatchObject({ httpStatus: 422, fields: { slots: "duplicate slot title" } }) + + expect(await titleOf(cleanupId)).toBe("Slot sweep") + const linked = await h.sql`SELECT 1 FROM cleanup_reports WHERE cleanup_id = ${cleanupId}` + expect(linked).toHaveLength(0) + }) + + it("applies the title, the link and the slot together when nothing refuses", async () => { + const org = await newUser("Happy Org") + const cleanupId = await newCleanup(org, "Happy sweep") + const report = await newPublicReport("Happy link") + + const outcome = await repo.updateCleanupWithEdits( + cleanupId, + { title: "Renamed" }, + { actorUserId: org, links: [report], slots: [newSlot("Grill")], refusalOnceEnded: null }, + ) + + expect(outcome.kind).toBe("updated") + expect(await titleOf(cleanupId)).toBe("Renamed") + const slots = await h.sql<{ title: string }[]>` + SELECT title FROM cleanup_slots WHERE cleanup_id = ${cleanupId} + ` + expect(slots.map((s) => s.title)).toEqual(["Grill"]) + }) + + it("reports a cancelled event without writing to it", async () => { + const org = await newUser("Cancelled Org") + const cleanupId = await newCleanup(org, "Cancelled sweep") + await h.sql`UPDATE cleanups SET status = 'cancelled' WHERE id = ${cleanupId}` + + const outcome = await repo.updateCleanupWithEdits( + cleanupId, + { title: "Renamed" }, + { actorUserId: org, links: null, slots: null, refusalOnceEnded: null }, + ) + + expect(outcome).toEqual({ kind: "cancelled" }) + expect(await titleOf(cleanupId)).toBe("Cancelled sweep") + }) + + it("throws the caller's refusal for an event that has already ended", async () => { + const org = await newUser("Ended Org") + const cleanupId = await newCleanup(org, "Ended sweep", -2 * DAY_MS) + const refusal = AppError.conflict("ended") + + await expect( + repo.updateCleanupWithEdits( + cleanupId, + { title: "Renamed" }, + { actorUserId: org, links: null, slots: null, refusalOnceEnded: refusal }, + ), + ).rejects.toBe(refusal) + + expect(await titleOf(cleanupId)).toBe("Ended sweep") + }) +}) diff --git a/services/api/test/integration/consumer-keyset-precision-pg.test.ts b/services/api/test/integration/consumer-keyset-precision-pg.test.ts new file mode 100644 index 00000000..133e98f1 --- /dev/null +++ b/services/api/test/integration/consumer-keyset-precision-pg.test.ts @@ -0,0 +1,61 @@ +/** + * Consumer keyset paging over rows that share one transaction's now() (Docker-gated; CI runs it). A cursor + * built from the driver's millisecond Date skipped the rest of that instant on a newest-first list. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" +import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" + +const pg = await withPg() + +const ROWS_IN_ONE_TX = 3 +const PAGE_GUARD = 10 + +describe.skipIf(!pg)("consumer keyset cursors keep microsecond precision (integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + it("the notification feed pages through every row of one transaction exactly once", async () => { + const [user] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES ('Keyset Burst', ${testHandle()}) RETURNING id + ` + const userId = user!.id + const written = await h.sql.begin(async (tx) => { + const ids: string[] = [] + for (let i = 0; i < ROWS_IN_ONE_TX; i++) { + const [row] = await tx<{ id: string }[]>` + INSERT INTO notifications (user_id, type, title, body, link) + VALUES (${userId}, 'site', ${`burst ${i}`}, ${`burst ${i}`}, null) + RETURNING id + ` + ids.push(row!.id) + } + return ids + }) + const stamps = await h.sql<{ n: number }[]>` + SELECT count(DISTINCT created_at)::int AS n FROM notifications WHERE user_id = ${userId} + ` + expect(stamps[0]!.n).toBe(1) + + const repo = makeDrizzleNotificationRepository(h.sql) + const seen: string[] = [] + let cursor: string | null = null + for (let page = 0; page < PAGE_GUARD; page++) { + const res = await repo.listNotifications(userId, cursor, 1) + seen.push(...res.records.map((r) => r.id)) + cursor = res.nextCursor + if (cursor === null) break + } + expect(cursor).toBeNull() + expect(seen).toHaveLength(ROWS_IN_ONE_TX) + expect(new Set(seen)).toEqual(new Set(written)) + }) +}) diff --git a/services/api/test/integration/guest-rsvp-pg.test.ts b/services/api/test/integration/guest-rsvp-pg.test.ts index d71f53c9..21c5a39e 100644 --- a/services/api/test/integration/guest-rsvp-pg.test.ts +++ b/services/api/test/integration/guest-rsvp-pg.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto" import type { CleanupStatus } from "@civfix/shared" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" -import { parseTimeCursor, type TimeCursor } from "../../src/db/cursor-helpers.js" +import { parseKeysetCursor, type KeysetCursor } from "../../src/db/cursor-helpers.js" import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" import { makeDrizzleSocialRepository } from "../../src/services/social-repository.drizzle.js" import type { GuestRsvpRepository } from "../../src/services/guest-rsvp-service.js" @@ -212,7 +212,7 @@ describe.skipIf(!pg)("guest rsvp storage (integration)", () => { } const seen: string[] = [] - let cursor: TimeCursor | null = null + let cursor: KeysetCursor | null = null for (let page = 0; page < 5; page++) { const result: { rows: { id: string }[]; nextCursor: string | null } = await repo.listGuests({ cleanupId, @@ -220,7 +220,7 @@ describe.skipIf(!pg)("guest rsvp storage (integration)", () => { limit: 2, }) seen.push(...result.rows.map((r) => r.id)) - cursor = parseTimeCursor(result.nextCursor, { direction: "desc" }) + cursor = parseKeysetCursor(result.nextCursor, { direction: "desc" }) if (cursor === null) break } diff --git a/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts b/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts new file mode 100644 index 00000000..a9c88b9e --- /dev/null +++ b/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts @@ -0,0 +1,68 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" +import type { GuestRsvpRepository, UpsertGuestArgs } from "../../src/services/guest-rsvp-service.js" + +const pg = await withPg() + +describe.skipIf(!pg)("guest rsvp upsert reports whether it inserted (integration)", () => { + let h: PgHarness + let repo: GuestRsvpRepository + + beforeAll(() => { + h = pg as PgHarness + repo = makeDrizzleGuestRsvpRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newCleanup(): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES ('Host') RETURNING id + ` + return seedCleanup(h.sql, { + organizerUserId: u!.id, + title: "Guest sweep", + lng: -118.25, + lat: 34.05, + scheduledAt: new Date(Date.now() + 7 * 86_400_000), + }) + } + + function args(cleanupId: string, tokenHash: string): UpsertGuestArgs { + return { + cleanupId, + name: "Ada", + channel: "email", + contactKey: "ada@example.org", + email: "ada@example.org", + phone: null, + manageTokenHash: tokenHash, + now: new Date(), + } + } + + it("is created on the first verify and not on a re-verify of the same active guest", async () => { + const cleanupId = await newCleanup() + + const first = await repo.upsertVerifiedGuest(args(cleanupId, "a".repeat(64))) + const again = await repo.upsertVerifiedGuest(args(cleanupId, "b".repeat(64))) + + expect(first.created).toBe(true) + expect(again).toEqual({ id: first.id, created: false }) + }) + + it("is created again once the earlier RSVP was cancelled", async () => { + const cleanupId = await newCleanup() + const first = await repo.upsertVerifiedGuest(args(cleanupId, "c".repeat(64))) + await repo.cancelGuest(first.id, new Date()) + + const fresh = await repo.upsertVerifiedGuest(args(cleanupId, "d".repeat(64))) + + expect(fresh.created).toBe(true) + expect(fresh.id).not.toBe(first.id) + }) +}) diff --git a/services/api/test/integration/host-broadcast-keyset-pg.test.ts b/services/api/test/integration/host-broadcast-keyset-pg.test.ts new file mode 100644 index 00000000..78dbc1e9 --- /dev/null +++ b/services/api/test/integration/host-broadcast-keyset-pg.test.ts @@ -0,0 +1,108 @@ +/** + * Host broadcast keyset paging over rows that share one statement's now() (Docker-gated; CI runs it). + * plan() inserts every delivery of a broadcast in one statement, so a cursor built from the driver's + * millisecond Date skipped the rest of that instant on the newest-first delivery list. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" + +const pg = await withPg() + +const DELIVERIES_IN_ONE_STATEMENT = 3 +const PAGE_GUARD = 10 + +describe.skipIf(!pg)( + "host broadcast keyset cursors keep microsecond precision (integration)", + () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id` + return row!.id + } + + it("pages through every delivery one statement inserted exactly once", async () => { + const host = await newUser("Keyset Host") + const cleanupId = await seedCleanup(h.sql, { + organizerUserId: host, + title: "Beach sweep", + lng: -118.25, + lat: 34.05, + scheduledAt: new Date(Date.now() + 7 * 86_400_000), + }) + const repo = makeDrizzleBroadcastRepository(h.sql) + const broadcast = await repo.create({ + cleanupId, + createdBy: host, + kind: "host_broadcast", + subject: "Bring gloves", + bodyMd: "See you there.", + segment: { kind: "all_registered" }, + channels: ["inapp"], + }) + const members = await Promise.all( + Array.from({ length: DELIVERIES_IN_ONE_STATEMENT }, (_, i) => newUser(`Member ${i}`)), + ) + await repo.insertDeliveries( + members.map((userId) => ({ + broadcastId: broadcast.id, + chunkNo: 0, + recipientKind: "member" as const, + userId, + guestId: null, + channel: "inapp" as const, + })), + ) + const stamps = await h.sql<{ n: number }[]>` + SELECT count(DISTINCT created_at)::int AS n + FROM broadcast_deliveries WHERE broadcast_id = ${broadcast.id} + ` + expect(stamps[0]!.n).toBe(1) + + const seen: string[] = [] + let cursor: string | null = null + for (let page = 0; page < PAGE_GUARD; page++) { + const rows = await repo.listDeliveries({ + broadcastId: broadcast.id, + cursor: parseKeysetCursor(cursor, { direction: "desc" }), + limit: 1, + }) + const row = rows[0] + if (row === undefined) break + seen.push(row.id) + cursor = `${row.cursorAt}|${row.id}` + } + expect(seen).toHaveLength(DELIVERIES_IN_ONE_STATEMENT) + expect(new Set(seen).size).toBe(DELIVERIES_IN_ONE_STATEMENT) + }) + + it("answers not-found when suspending a soft-deleted host and writes no audit row", async () => { + const userId = await newUser("Gone Host") + await h.sql`UPDATE users SET deleted_at = now() WHERE id = ${userId}` + const repo = makeDrizzleBroadcastRepository(h.sql) + const found = await repo.setHostMessagingSuspended(userId, true, { + action: "host.messaging_suspended", + actorId: null, + target: `user:${userId}`, + }) + expect(found).toBe(false) + const audits = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM audit_log WHERE target = ${`user:${userId}`} + ` + expect(audits[0]!.n).toBe(0) + }) + }, +) diff --git a/services/api/test/integration/host-capacity-race-pg.test.ts b/services/api/test/integration/host-capacity-race-pg.test.ts index d7ad10c1..a801cf65 100644 --- a/services/api/test/integration/host-capacity-race-pg.test.ts +++ b/services/api/test/integration/host-capacity-race-pg.test.ts @@ -230,6 +230,35 @@ describe.skipIf(!pg)("registration capacity (integration)", () => { expect(await reservedSeats(ticketTypeId)).toBe(1) }) + it("replays a same-key twin that raced for the last seat instead of answering full", async () => { + const organizer = await newUser("Organizer") + const cleanupId = await newCleanup(organizer) + const ticketTypeId = await newTicketType(cleanupId, { capacity: 1, maxPartySize: 1 }) + const userId = await newUser("Double tap") + const now = new Date() + const args = { + cleanupId, + subject: { kind: "user" as const, userId }, + ticketTypeId, + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self" as const, + idempotencyKey: "double-tap-key", + waitlistId: null, + now, + } + + const outcomes = await Promise.all([ + repo.registerTx({ ...args, seats: seats(1) }), + repo.registerTx({ ...args, seats: seats(1) }), + ]) + + expect(outcomes.map((o) => o.kind).sort()).toEqual(["registered", "replayed"]) + expect(await reservedSeats(ticketTypeId)).toBe(1) + }) + it("releases exactly the seats a cancel held, in one statement", async () => { const organizer = await newUser("Organizer") const cleanupId = await newCleanup(organizer) diff --git a/services/api/test/integration/host-correctness-pg.test.ts b/services/api/test/integration/host-correctness-pg.test.ts new file mode 100644 index 00000000..fe15c10c --- /dev/null +++ b/services/api/test/integration/host-correctness-pg.test.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" +import { hostedRegistrationTotals } from "../../src/services/host/host-portfolio-repository.drizzle.js" +import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" +import type { + HostRegistrationRepository, + RegisterTxArgs, + SeatDraft, +} from "../../src/services/host/registration-repository.types.js" + +const pg = await withPg() +const tokens = makeTicketTokenSigner("integration-host-correctness-secret-long") +const DAY_MS = 86_400_000 +const HOUR_MS = 3_600_000 +const CLAIM_WINDOW_MS = HOUR_MS +const SLOT_RACE_ROUNDS = 10 + +describe.skipIf(!pg)("host registration correctness (integration)", () => { + let h: PgHarness + let repo: HostRegistrationRepository + + beforeAll(() => { + h = pg as PgHarness + repo = makeDrizzleHostRegistrationRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id + ` + return (u as { id: string }).id + } + + async function newCleanup( + organizerId: string, + over: { scheduledAt?: Date; endsAt?: Date } = {}, + ): Promise { + const id = await seedCleanup(h.sql, { + organizerUserId: organizerId, + title: "Correctness sweep", + scheduledAt: over.scheduledAt ?? new Date(Date.now() + 7 * DAY_MS), + ...(over.endsAt !== undefined ? { endsAt: over.endsAt } : {}), + }) + await h.sql` + INSERT INTO cleanup_members (cleanup_id, user_id, role) + VALUES (${id}, ${organizerId}, 'organizer') + ON CONFLICT DO NOTHING + ` + return id + } + + async function newTicketType(cleanupId: string, capacity: number | null): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO cleanup_ticket_types (cleanup_id, name, capacity, max_party_size, waitlist_enabled) + VALUES (${cleanupId}, ${`Type ${randomUUID().slice(0, 8)}`}, ${capacity}, 4, true) + RETURNING id + ` + return (row as { id: string }).id + } + + function seats(partySize: number): SeatDraft[] { + return Array.from({ length: partySize }, () => { + const id = randomUUID() + return { id, attendeeName: null, tokenHash: tokens.hashFor(id) } + }) + } + + function registerArgs( + cleanupId: string, + userId: string, + over: Partial = {}, + ): RegisterTxArgs { + return { + cleanupId, + subject: { kind: "user", userId }, + ticketTypeId: null, + seats: seats(1), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: randomUUID(), + waitlistId: null, + now: new Date(), + ...over, + } + } + + it("never lets two registrations on different ticket types overfill one slot", async () => { + for (let round = 0; round < SLOT_RACE_ROUNDS; round++) { + const organizer = await newUser("Slot Organizer") + const cleanupId = await newCleanup(organizer) + const typeA = await newTicketType(cleanupId, null) + const typeB = await newTicketType(cleanupId, null) + const [slot] = await h.sql<{ id: string }[]>` + INSERT INTO cleanup_slots (cleanup_id, title, description, capacity, sort_order) + VALUES (${cleanupId}, ${"Grill"}, NULL, 1, 0) + RETURNING id + ` + const slotId = (slot as { id: string }).id + const [first, second] = [await newUser("First"), await newUser("Second")] + + const settled = await Promise.allSettled([ + repo.registerTx(registerArgs(cleanupId, first, { ticketTypeId: typeA, slotId })), + repo.registerTx(registerArgs(cleanupId, second, { ticketTypeId: typeB, slotId })), + ]) + + const claims = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_slot_claims WHERE slot_id = ${slotId} + ` + expect(claims[0]?.n).toBe(1) + expect(settled.filter((s) => s.status === "fulfilled")).toHaveLength(1) + const registered = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_registrations + WHERE cleanup_id = ${cleanupId} AND status = 'registered' + ` + expect(registered[0]?.n).toBe(1) + } + }) + + it("offers no waitlist place on a cancelled or ended event and reserves nothing", async () => { + for (const shape of ["cancelled", "ended"] as const) { + const organizer = await newUser("Dead Event Organizer") + const cleanupId = + shape === "ended" + ? await newCleanup(organizer, { + scheduledAt: new Date(Date.now() - 6 * HOUR_MS), + endsAt: new Date(Date.now() - HOUR_MS), + }) + : await newCleanup(organizer) + const ticketTypeId = await newTicketType(cleanupId, 1) + const [waiting] = await h.sql<{ id: string }[]>` + INSERT INTO cleanup_waitlist (cleanup_id, ticket_type_id, user_id, party_size, status) + VALUES (${cleanupId}, ${ticketTypeId}, ${await newUser("Waiter")}, 1, 'waiting') + RETURNING id + ` + if (shape === "cancelled") { + await h.sql`UPDATE cleanups SET status = 'cancelled' WHERE id = ${cleanupId}` + } + + const next = await repo.offerNextWaitlistEntry({ + ticketTypeId, + now: new Date(), + claimWindowMs: CLAIM_WINDOW_MS, + }) + const direct = await repo.offerWaitlistEntry({ + cleanupId, + waitlistId: (waiting as { id: string }).id, + now: new Date(), + claimWindowMs: CLAIM_WINDOW_MS, + }) + + expect(next).toBeNull() + expect(direct).toBeNull() + const [type] = await h.sql<{ reserved_seats: number }[]>` + SELECT reserved_seats FROM cleanup_ticket_types WHERE id = ${ticketTypeId} + ` + expect(type?.reserved_seats).toBe(0) + } + }) + + it("totals registrations and check-ins across every hosted event, not one page", async () => { + const host = await newUser("Portfolio Host") + const events = [await newCleanup(host), await newCleanup(host), await newCleanup(host)] + for (const cleanupId of events) { + const outcome = await repo.registerTx( + registerArgs(cleanupId, await newUser("Attendee"), { seats: seats(2) }), + ) + if (outcome.kind !== "registered") throw new Error(`setup: ${outcome.kind}`) + const seat = outcome.registration.seats[0] + if (seat === undefined) throw new Error("setup: no seat") + await repo.checkInSeat({ + cleanupId, + seatId: seat.id, + actorId: host, + method: "manual", + now: new Date(), + }) + } + + const totals = await hostedRegistrationTotals(h.sql, { userId: host, organizationId: null }) + + expect(totals).toEqual({ totalRegistrations: 6, totalCheckedIn: 3 }) + }) +}) diff --git a/services/api/test/integration/host-export-orphans-pg.test.ts b/services/api/test/integration/host-export-orphans-pg.test.ts new file mode 100644 index 00000000..db37dba4 --- /dev/null +++ b/services/api/test/integration/host-export-orphans-pg.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { + makeDrizzleHostExportRepository, + type HostExportRepository, +} from "../../src/services/host/export-repository.drizzle.js" + +const pg = await withPg() + +describe.skipIf(!pg)("host export orphaned objects (integration)", () => { + let h: PgHarness + let repo: HostExportRepository + let cleanupId: string + let userId: string + + async function exportRow(fields: { + status: string + r2Key: string | null + requestedAt: Date + startedAt?: Date | null + }): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO host_exports (cleanup_id, requested_by, kind, status, r2_key, requested_at, started_at) + VALUES (${cleanupId}, ${userId}, 'roster', ${fields.status}, ${fields.r2Key}, + ${fields.requestedAt}, ${fields.startedAt ?? null}) + RETURNING id` + return row!.id + } + + beforeAll(async () => { + h = pg as PgHarness + repo = makeDrizzleHostExportRepository(h.sql) + const [user] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES ('Host') RETURNING id` + userId = user!.id + cleanupId = await seedCleanup(h.sql, { organizerUserId: userId, title: "Exports" }) + }) + + afterAll(async () => { + await h.teardown() + }) + + it("keeps an old row that still names an object and deletes one that does not", async () => { + const old = new Date("2025-01-01T00:00:00Z") + const holding = await exportRow({ + status: "failed", + r2Key: "exports/host/a.csv", + requestedAt: old, + }) + const clean = await exportRow({ status: "expired", r2Key: null, requestedAt: old }) + await repo.deleteOlderThan(new Date("2025-06-01T00:00:00Z"), 100) + expect(await repo.findById(holding)).not.toBeNull() + expect(await repo.findById(clean)).toBeNull() + }) + + it("lists failed rows holding an object and stale runs, then releases them under a guard", async () => { + const staleBefore = new Date("2026-02-05T11:00:00Z") + const stale = await exportRow({ + status: "running", + r2Key: "exports/host/b.csv", + requestedAt: new Date("2026-02-05T09:00:00Z"), + startedAt: new Date("2026-02-05T09:00:00Z"), + }) + const fresh = await exportRow({ + status: "running", + r2Key: null, + requestedAt: new Date("2026-02-05T11:30:00Z"), + startedAt: new Date("2026-02-05T11:30:00Z"), + }) + const listed = await repo.listOrphaned({ staleBefore, limit: 50 }) + const ids = listed.map((row) => row.id) + expect(ids).toContain(stale) + expect(ids).not.toContain(fresh) + + const record = listed.find((row) => row.id === stale)! + expect(await repo.releaseObject(record, "build_failed")).toBe(true) + const released = await repo.findById(stale) + expect(released?.status).toBe("failed") + expect(released?.r2Key).toBeNull() + expect(released?.errorCode).toBe("build_failed") + expect(await repo.releaseObject(record, "build_failed")).toBe(false) + }) + + it("only lets the run holding the token fail the row or replace the key it was told about", async () => { + const staleBefore = new Date("2026-02-05T11:00:00Z") + const id = await exportRow({ + status: "running", + r2Key: "exports/host/crashed.csv", + requestedAt: new Date("2026-02-05T09:00:00Z"), + startedAt: new Date("2026-02-05T09:00:00Z"), + }) + const claimed = await repo.claimForRun(id, staleBefore) + expect(claimed?.r2Key).toBe("exports/host/crashed.csv") + const runToken = claimed!.runToken + + expect( + await repo.recordObjectKey(id, { r2Key: "exports/host/new.csv", runToken, replaces: null }), + ).toBe(false) + expect(await repo.markFailed(id, "build_failed", "00000000-0000-0000-0000-000000000000")).toBe( + false, + ) + expect((await repo.findById(id))?.status).toBe("running") + + expect( + await repo.recordObjectKey(id, { + r2Key: "exports/host/new.csv", + runToken, + replaces: "exports/host/crashed.csv", + }), + ).toBe(true) + expect(await repo.markFailed(id, "build_failed", runToken)).toBe(true) + const failed = await repo.findById(id) + expect(failed?.status).toBe("failed") + expect(failed?.r2Key).toBe("exports/host/new.csv") + }) +}) diff --git a/services/api/test/integration/host-metrics-rollup-pg.test.ts b/services/api/test/integration/host-metrics-rollup-pg.test.ts new file mode 100644 index 00000000..8130bf06 --- /dev/null +++ b/services/api/test/integration/host-metrics-rollup-pg.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { + makeDrizzleMetricsRepository, + type MetricsRepository, +} from "../../src/services/host/metrics-repository.drizzle.js" + +const pg = await withPg() + +const ZONE = "America/Los_Angeles" + +describe.skipIf(!pg)("event metrics rollup windows (integration)", () => { + let h: PgHarness + let metrics: MetricsRepository + let cleanupId: string + + async function user(name: string): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id` + return row!.id + } + + beforeAll(async () => { + h = pg as PgHarness + metrics = makeDrizzleMetricsRepository(h.sql) + const host = await user("Host") + cleanupId = await seedCleanup(h.sql, { + organizerUserId: host, + title: "Rollup window", + scheduledAt: new Date("2026-02-10T17:00:00Z"), + }) + for (const [name, at] of [ + ["Early", "2026-02-01T09:00:00Z"], + ["Late", "2026-02-02T07:00:00Z"], + ] as const) { + const attendee = await user(name) + await h.sql` + INSERT INTO cleanup_registrations (cleanup_id, user_id, status, registered_at) + VALUES (${cleanupId}, ${attendee}, 'registered', ${new Date(at)})` + } + }) + + afterAll(async () => { + await h.teardown() + }) + + it("counts the whole local day that contains the window start", async () => { + const since = new Date("2026-02-01T20:00:00Z") + const rows = await metrics.recomputeFromSource(cleanupId, ZONE, since) + const feb1 = rows.find((row) => row.metric === "registrations" && row.day === "2026-02-01") + expect(feb1?.value).toBe(2) + }) + + it("pages the active events after a keyset id", async () => { + const since = new Date("2026-01-01T00:00:00Z") + const first = await metrics.listRollupEvents(since, null, 1000) + expect(first).toContain(cleanupId) + const after = await metrics.listRollupEvents(since, cleanupId, 1000) + expect(after).not.toContain(cleanupId) + expect(after.every((id) => id > cleanupId)).toBe(true) + }) +}) diff --git a/services/api/test/integration/inbound-repository.test.ts b/services/api/test/integration/inbound-repository.test.ts index c32d7656..f7d09183 100644 --- a/services/api/test/integration/inbound-repository.test.ts +++ b/services/api/test/integration/inbound-repository.test.ts @@ -199,4 +199,13 @@ describe.skipIf(!pg)("inbound repository (integration: real schema)", () => { expect(due.map((r) => r.id)).not.toContain(fresh.id) expect(due.map((r) => r.id)).not.toContain(unread.id) }) + + it("counts failed bounce runs per pending object and forgets them on clear", async () => { + const key = `inbound/pending/${randomUUID()}.eml` + expect(await repo.recordBounceFailure(key)).toBe(1) + expect(await repo.recordBounceFailure(key)).toBe(2) + await repo.clearBounceFailures(key) + expect(await repo.recordBounceFailure(key)).toBe(1) + await repo.clearBounceFailures(key) + }) }) diff --git a/services/api/test/integration/mail-send-and-bounce-state.test.ts b/services/api/test/integration/mail-send-and-bounce-state.test.ts new file mode 100644 index 00000000..21e20f8c --- /dev/null +++ b/services/api/test/integration/mail-send-and-bounce-state.test.ts @@ -0,0 +1,119 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { makeDrizzleMailRepository } from "../../src/services/admin/mail-repository.drizzle.js" +import { makeDrizzleOutreachRepository } from "../../src/services/admin/outreach-repository.drizzle.js" +import { ROUTE_CLAIM_STALE_SECONDS } from "../../src/services/admin/outbound-send-policy.js" +import { LA_CITY } from "../../src/db/seed-fixtures.js" + +const pg = await withPg() + +describe.skipIf(!pg)("mail send + bounce state against real SQL", () => { + let h: PgHarness + let mail: ReturnType + + beforeAll(() => { + h = pg as PgHarness + mail = makeDrizzleMailRepository(h.sql) + }) + + beforeEach(async () => { + await h.sql`TRUNCATE mail_events, mail_messages, mail_threads RESTART IDENTITY CASCADE` + }) + + afterAll(async () => { + await h.teardown() + }) + + async function outbound(): Promise<{ threadId: string; messageId: string }> { + const thread = await mail.upsertThreadByGeoid(LA_CITY.geoid, { subject: "Digest" }) + const message = await mail.insertMessage({ + threadId: thread.id, + direction: "out", + fromAddr: "outreach@civfix.org", + toAddr: "clerk@lacity.gov", + body: "digest", + }) + return { threadId: thread.id, messageId: message!.id } + } + + it("treats a young attempt with no outcome yet as in flight, and a stale one as not", async () => { + const { threadId, messageId } = await outbound() + expect(await mail.hasSendInFlight(threadId)).toBe(true) + + await h.sql` + UPDATE mail_messages SET created_at = now() - make_interval(secs => ${ROUTE_CLAIM_STALE_SECONDS + 60}) + WHERE id = ${messageId} + ` + expect(await mail.hasSendInFlight(threadId)).toBe(false) + }) + + it("stops reporting in flight once the attempt records sent or a hard failure", async () => { + const sent = await outbound() + await mail.recordEvent({ threadId: sent.threadId, messageId: sent.messageId, type: "sent" }) + expect(await mail.hasSendInFlight(sent.threadId)).toBe(false) + }) + + it("finds a recorded bounce by thread, original Message-ID and recipient (any case)", async () => { + const { threadId } = await outbound() + const key = { + threadId, + failedRecipient: "Clerk@LACity.gov", + originalMessageId: "", + } + expect(await mail.bounceEventState(key)).toBe("none") + await mail.recordEvent({ + threadId, + type: "bounced", + meta: { failedRecipient: "clerk@lacity.gov", originalMessageId: "" }, + }) + expect(await mail.bounceEventState(key)).toBe("complete") + expect(await mail.bounceEventState({ ...key, originalMessageId: "" })).toBe( + "none", + ) + }) + + it("keeps a bounce pending until its discovery enqueue is marked", async () => { + const { threadId } = await outbound() + const key = { + threadId, + failedRecipient: "clerk@lacity.gov", + originalMessageId: "", + } + await mail.recordEvent({ + threadId, + type: "bounced", + meta: { + failedRecipient: "clerk@lacity.gov", + originalMessageId: "", + discoveryPending: true, + }, + }) + expect(await mail.bounceEventState(key)).toBe("discovery_pending") + await mail.markBounceDiscoveryEnqueued(key) + expect(await mail.bounceEventState(key)).toBe("complete") + }) + + it("never picks a legacy contact_emails address that bounced since the last contact save", async () => { + await h.sql`DELETE FROM jurisdiction_contacts WHERE geoid = ${LA_CITY.geoid}` + await h.sql` + UPDATE jurisdictions SET contact_emails = ARRAY['clerk@lacity.gov'], contact_updated_at = NULL + WHERE geoid = ${LA_CITY.geoid} + ` + await h.sql` + INSERT INTO reports (idempotency_key, title, geom, geom_source, category, status, h3_cell, jurisdiction_geoid) + VALUES (gen_random_uuid(), 'legacy-bounce', ST_SetSRID(ST_MakePoint(-118.25, 34.05), 4326), 'gps', + 'graffiti', 'published', '8a2a1072b59ffff', ${LA_CITY.geoid}) + ` + const outreach = makeDrizzleOutreachRepository(h.sql) + expect((await outreach.loadDigest(LA_CITY.geoid))?.toAddr).toBe("clerk@lacity.gov") + + const { threadId } = await outbound() + await mail.recordEvent({ + threadId, + type: "bounced", + meta: { failedRecipient: "CLERK@lacity.gov", originalMessageId: "" }, + }) + expect(await outreach.loadDigest(LA_CITY.geoid)).toBeNull() + expect(await outreach.listCandidateGeoids(10)).not.toContain(LA_CITY.geoid) + }) +}) diff --git a/services/api/test/integration/media-upload-etag-pg.test.ts b/services/api/test/integration/media-upload-etag-pg.test.ts new file mode 100644 index 00000000..a965a43d --- /dev/null +++ b/services/api/test/integration/media-upload-etag-pg.test.ts @@ -0,0 +1,98 @@ +/** + * media_assets.upload_etag against a live PostGIS database (Docker-gated; SKIPS without Docker). + * + * Finalize stores the HEAD etag in the same UPDATE that claims finalized_at, and the stuck sweep reads + * it back, so a requeued media.checks job keeps the overwrite check. Rows finalized before the column + * existed stay NULL and requeue with no etag, as before. + */ + +import { randomUUID } from "node:crypto" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { makeDrizzleMediaRepository } from "../../src/services/media-repository.drizzle.js" +import { makeDrizzleMediaWorkerRepo } from "../../src/services/media-worker-repo.js" + +const pg = await withPg() + +const FINALIZED_ETAG = "5d41402abc4b2a76b9719d911017c592" +const DAY_MS = 24 * 60 * 60 * 1000 +const HOUR_MS = 60 * 60 * 1000 + +describe.skipIf(!pg)("media_assets.upload_etag (integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + async function insertValidating(): Promise<{ id: string; uploadId: string }> { + const id = randomUUID() + const uploadId = randomUUID() + await makeDrizzleMediaRepository(h.db).insert({ + id, + uploadId, + kind: "image", + r2Key: `uploads/2026/09/${uploadId}`, + status: "validating", + byteSize: 1024, + uploader: "u:00000000-0000-4000-8000-0000000000e1", + }) + return { id, uploadId } + } + + async function uploadEtagOf(id: string): Promise { + const [row] = await h.sql<{ upload_etag: string | null }[]>` + SELECT upload_etag FROM media_assets WHERE id = ${id} + ` + return row!.upload_etag + } + + it("the column exists as nullable text with no default", async () => { + const [col] = await h.sql< + { data_type: string; is_nullable: string; column_default: string | null }[] + >` + SELECT data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = 'media_assets' AND column_name = 'upload_etag' + ` + expect(col).toEqual({ data_type: "text", is_nullable: "YES", column_default: null }) + }) + + it("markFinalized writes the etag once, and a repeat claim neither wins nor overwrites it", async () => { + const repo = makeDrizzleMediaRepository(h.db) + const { id, uploadId } = await insertValidating() + + expect(await uploadEtagOf(id)).toBeNull() + const claimed = await repo.markFinalized(uploadId, FINALIZED_ETAG) + expect(claimed?.id).toBe(id) + expect(await uploadEtagOf(id)).toBe(FINALIZED_ETAG) + + expect(await repo.markFinalized(uploadId, "someone-else")).toBeNull() + expect(await uploadEtagOf(id)).toBe(FINALIZED_ETAG) + }) + + it("the stuck sweep returns the stored etag, and NULL for a row finalized before the column", async () => { + const repo = makeDrizzleMediaRepository(h.db) + const withEtag = await insertValidating() + await repo.markFinalized(withEtag.uploadId, FINALIZED_ETAG) + const legacy = await insertValidating() + + const stuckSince = new Date(Date.now() - DAY_MS) + await h.sql` + UPDATE media_assets SET finalized_at = ${stuckSince} + WHERE id IN (${withEtag.id}, ${legacy.id}) + ` + + const worker = makeDrizzleMediaWorkerRepo(h.db, h.sql) + const picked = await worker.findStuckValidating(new Date(Date.now() - HOUR_MS), 100) + const byId = new Map(picked.map((row) => [row.id, row])) + + expect(byId.get(withEtag.id)?.uploadEtag).toBe(FINALIZED_ETAG) + expect(byId.get(legacy.id)).toBeDefined() + expect(byId.get(legacy.id)!.uploadEtag).toBeNull() + }) +}) diff --git a/services/api/test/integration/notification-coalesce-first-row-pg.test.ts b/services/api/test/integration/notification-coalesce-first-row-pg.test.ts new file mode 100644 index 00000000..ba05bd05 --- /dev/null +++ b/services/api/test/integration/notification-coalesce-first-row-pg.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" +import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" + +const pg = await withPg() + +const LINK = "/messages/group/22222222-2222-2222-2222-222222222222" +const CONCURRENT_WRITERS = 5 + +describe.skipIf(!pg)("notification coalescing with no unread row yet (integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + it("concurrent first upserts converge on ONE unread row instead of one per writer", async () => { + const repo = makeDrizzleNotificationRepository(h.sql) + const [user] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES ('Coalesce First', ${testHandle()}) RETURNING id + ` + const userId = user!.id + const since = new Date(Date.now() - 10 * 60 * 1000) + + const results = await Promise.all( + Array.from({ length: CONCURRENT_WRITERS }, (_, i) => + repo.upsertCoalescedNotification({ + userId, + type: "group_chat", + link: LINK, + title: `writer ${i}`, + body: `writer ${i}`, + since, + }), + ), + ) + + const rows = await h.sql<{ n: string }[]>` + SELECT count(*)::text AS n + FROM notifications + WHERE user_id = ${userId} AND link = ${LINK} AND read_at IS NULL + ` + expect(rows[0]!.n).toBe("1") + expect(results.filter((r) => !r.coalesced)).toHaveLength(1) + }) +}) diff --git a/services/api/test/integration/notification-dedupe-race-pg.test.ts b/services/api/test/integration/notification-dedupe-race-pg.test.ts new file mode 100644 index 00000000..17e9ef56 --- /dev/null +++ b/services/api/test/integration/notification-dedupe-race-pg.test.ts @@ -0,0 +1,48 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" +import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" + +const pg = await withPg() + +const CONCURRENT_WRITERS = 5 +const DEDUPE_WINDOW_MS = 10 * 60 * 1000 + +describe.skipIf(!pg)("deduped notifications under concurrent writers (integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + it("concurrent writers of one dedupe key leave ONE row", async () => { + const repo = makeDrizzleNotificationRepository(h.sql) + const [user] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES ('Dedupe Race', ${testHandle()}) RETURNING id + ` + const userId = user!.id + const since = new Date(Date.now() - DEDUPE_WINDOW_MS) + + const results = await Promise.all( + Array.from({ length: CONCURRENT_WRITERS }, () => + repo.insertUnlessRecentDuplicate({ + userId, + type: "system", + title: "Heads up", + body: "The meeting point moved", + link: null, + since, + }), + ), + ) + + const rows = await h.sql<{ n: string }[]>` + SELECT count(*)::text AS n FROM notifications WHERE user_id = ${userId} + ` + expect(rows[0]!.n).toBe("1") + expect(results.filter((r) => !r.deduped)).toHaveLength(1) + }) +}) diff --git a/services/api/test/integration/posts-write-races-pg.test.ts b/services/api/test/integration/posts-write-races-pg.test.ts new file mode 100644 index 00000000..dbb140cc --- /dev/null +++ b/services/api/test/integration/posts-write-races-pg.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" + +const pg = await withPg() + +describe.skipIf(!pg)("posts: revival dating and vanished targets (integration)", () => { + let h: PgHarness + + function repo() { + return makeDrizzlePostRepository(h.sql, { + presignMedia: (r2Key: string) => Promise.resolve({ url: `m://${r2Key}` }), + presignAvatar: (k: string) => Promise.resolve(`m://${k}`), + }) + } + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id + ` + return u!.id + } + + async function newPost(authorId: string): Promise { + return repo().createPost({ + authorId, + kind: "post", + body: "original", + replyToId: null, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds: [], + mentionedUserIds: [], + organizationId: null, + }) + } + + it("a revived repost is dated at the revival and is not marked edited", async () => { + const author = await newUser("Revival Author") + const actor = await newUser("Revival Actor") + const target = await newPost(author) + + await repo().repost(target, actor) + await h.sql` + UPDATE posts SET created_at = created_at - interval '3 days', updated_at = updated_at - interval '3 days' + WHERE author_id = ${actor} AND kind = 'repost' + ` + await repo().unrepost(target, actor) + await repo().repost(target, actor) + + const [row] = await h.sql<{ age_s: number; edited: boolean }[]>` + SELECT extract(epoch FROM now() - created_at)::float8 AS age_s, + updated_at > created_at AS edited + FROM posts WHERE author_id = ${actor} AND kind = 'repost' AND deleted_at IS NULL + ` + expect(row!.age_s).toBeLessThan(60) + expect(row!.edited).toBe(false) + }) + + it("a reply to a parent deleted after the service check is refused and counts nothing", async () => { + const author = await newUser("Gone Author") + const replier = await newUser("Gone Replier") + const parent = await newPost(author) + await h.sql`UPDATE posts SET deleted_at = now() WHERE id = ${parent}` + + await expect( + repo().createPost({ + authorId: replier, + kind: "reply", + body: "hello?", + replyToId: parent, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds: [], + mentionedUserIds: [], + organizationId: null, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + + const [row] = await h.sql<{ reply_count: number; replies: number }[]>` + SELECT p.reply_count::int AS reply_count, + (SELECT count(*)::int FROM posts r WHERE r.reply_to_id = p.id) AS replies + FROM posts p WHERE p.id = ${parent} + ` + expect(row).toEqual({ reply_count: 0, replies: 0 }) + }) +}) diff --git a/services/api/test/integration/volunteer-hours-pg.test.ts b/services/api/test/integration/volunteer-hours-pg.test.ts index cf18715e..034d4719 100644 --- a/services/api/test/integration/volunteer-hours-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-pg.test.ts @@ -4,7 +4,7 @@ import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.drizzle.js" import { makeVolunteerHoursService } from "../../src/services/volunteer-hours-service.js" -import { parseTimeCursor } from "../../src/db/cursor-helpers.js" +import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" import { CALIFORNIA, LA_CITY, LA_COUNTY } from "../../src/db/seed-fixtures.js" const GEOID = LA_CITY.geoid @@ -417,7 +417,7 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { expect(first.items.map((e) => e.id)).toEqual([ids[3], ids[2]]) expect(first.nextCursor).not.toBeNull() - const parsed = parseTimeCursor(first.nextCursor) + const parsed = parseKeysetCursor(first.nextCursor) const second = await repo.listEntries({ userId: owner, cursor: parsed, limit: 2 }) expect(second.items.map((e) => e.id)).toEqual([ids[1], ids[0]]) expect(second.nextCursor).toBeNull() diff --git a/services/api/test/unit/adapter-logger-wiring.test.ts b/services/api/test/unit/adapter-logger-wiring.test.ts new file mode 100644 index 00000000..d7433627 --- /dev/null +++ b/services/api/test/unit/adapter-logger-wiring.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +const bossErrorHandlers: ((err: Error) => void)[] = [] + +vi.mock("pg-boss", () => ({ + default: class { + on(event: string, handler: (err: Error) => void): void { + if (event === "error") bossErrorHandlers.push(handler) + } + start(): Promise { + return Promise.resolve() + } + createQueue(): Promise { + return Promise.resolve() + } + updateQueue(): Promise { + return Promise.resolve() + } + }, +})) + +vi.mock("nodemailer", () => ({ + createTransport: () => ({ + verify: () => Promise.reject(new Error("535 authentication failed")), + sendMail: () => Promise.resolve({ messageId: "" }), + }), +})) + +const { PgBossJobs } = await import("../../src/adapters/jobs.pgboss.js") +const { OciMailer } = await import("../../src/adapters/mailer.oci.js") +const { buildContainer } = await import("../../src/di.js") +const { loadEnv } = await import("../../src/env.js") + +function recordingLogger() { + return { warn: vi.fn(), error: vi.fn() } +} + +afterEach(() => { + bossErrorHandlers.length = 0 + vi.restoreAllMocks() +}) + +describe("adapter logging goes through the injected logger", () => { + it("routes pg-boss errors to the injected logger instead of console", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + const logger = recordingLogger() + const jobs = new PgBossJobs({ connectionString: "postgres://u:p@localhost/db", logger }) + await jobs.start() + + const failure = new Error("connection terminated") + bossErrorHandlers[0]!(failure) + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ err: failure }), + expect.any(String), + ) + expect(consoleError).not.toHaveBeenCalled() + }) + + it("routes a failed SMTP verify to the injected logger instead of console", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const logger = recordingLogger() + const mailer = new OciMailer({ + host: "smtp.example", + port: 587, + user: "u", + pass: "p", + fromNoReply: "no-reply@civfix.org", + fromOutreach: "outreach@civfix.org", + logger, + }) + + await mailer.sendOutbound({ + from: "no-reply@civfix.org", + to: "someone@example.com", + subject: "hi", + text: "hello", + }) + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalled()) + expect(consoleWarn).not.toHaveBeenCalled() + }) + + it("wires the server logger into abuse checks and the push sender built by the container", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const container = buildContainer( + loadEnv({ + NODE_ENV: "test", + USE_FAKE_ABUSE_NSFW: "0", + USE_FAKE_PUSH: "0", + DATABASE_URL: "postgres://u:p@localhost:5432/civfix", + }), + ) + const pushSender = container.pushSender as unknown as { + logger: { warn(obj: unknown, msg?: string): void } + } + const serverLogger = recordingLogger() + container.getNotificationService(serverLogger) + + await container.abuseChecks.nsfwScore(new Uint8Array([1])) + pushSender.logger.warn({ probe: true }, "push probe") + + expect(serverLogger.warn).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("NSFW model"), + ) + expect(serverLogger.warn).toHaveBeenCalledWith({ probe: true }, "push probe") + expect(consoleWarn).not.toHaveBeenCalled() + }) +}) diff --git a/services/api/test/unit/adapters-push-classification.test.ts b/services/api/test/unit/adapters-push-classification.test.ts index e2ae0a93..8d31be46 100644 --- a/services/api/test/unit/adapters-push-classification.test.ts +++ b/services/api/test/unit/adapters-push-classification.test.ts @@ -45,7 +45,7 @@ describe("isFcmPruneCode", () => { it("prunes only the dead-token codes", () => { expect(isFcmPruneCode("messaging/registration-token-not-registered")).toBe(true) expect(isFcmPruneCode("messaging/invalid-registration-token")).toBe(true) - expect(isFcmPruneCode("messaging/invalid-argument")).toBe(true) + expect(isFcmPruneCode("messaging/invalid-argument")).toBe(false) }) it("warns (does not prune) on transient/quota codes and unknown values", () => { diff --git a/services/api/test/unit/admin-activity-correctness.test.ts b/services/api/test/unit/admin-activity-correctness.test.ts new file mode 100644 index 00000000..f34f49c9 --- /dev/null +++ b/services/api/test/unit/admin-activity-correctness.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleActivityRepository } from "../../src/services/admin/activity-repository.drizzle.js" +import { describeAuditAction } from "../../src/services/admin/activity-service.js" + +describe("activity kind filter matches audit prefixes literally", () => { + it("escapes LIKE metacharacters in the dotted prefixes", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzleActivityRepository(ctl.sql as unknown as Sql) + await repo.list({ q: null, filter: "gov_onboard", sort: "newest", cursor: null, limit: 5 }) + const stmt = ctl.statements[0] + expect(stmt?.values).toContain("gov\\_claim.%") + expect(stmt?.values).not.toContain("gov_claim.%") + expect(stmt?.sql).toMatch(/a\.action LIKE \? ESCAPE '\\'/) + }) +}) + +describe("every audit action the api writes renders a label in the activity feed", () => { + it.each([ + "report_message.removed", + "operator.login_denied", + "report.takedown_requested", + "account.deleted", + "event.announcement_sent", + "data_export.undeliverable", + ])("%s", (action) => { + expect(describeAuditAction(action)).not.toBe(action) + }) +}) diff --git a/services/api/test/unit/admin-event-correctness.test.ts b/services/api/test/unit/admin-event-correctness.test.ts new file mode 100644 index 00000000..4647ef1e --- /dev/null +++ b/services/api/test/unit/admin-event-correctness.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleAdminEventRepository } from "../../src/services/admin/admin-event-repository.drizzle.js" +import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" +import { ADMIN_EVENT_MESSAGE_CAP } from "../../src/services/admin/admin-event-helpers.js" + +const EVENT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e41" +const AUDIT_ROW: SqlHandler = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } +const SEEDED_MESSAGES = 150 + +function message(i: number): { who: string; text: string; createdAt: Date } { + return { who: "host", text: `message ${i}`, createdAt: new Date(Date.UTC(2026, 0, 1, 0, i)) } +} + +describe("admin event detail shows the most recent chat messages", () => { + it("reads the newest page from the database and returns it oldest first", async () => { + const newestFirst = [3, 2, 1].map((i) => ({ + who: "host", + body: `message ${i}`, + created_at: message(i).createdAt, + })) + const ctl = makeFakeSql([{ match: /FROM chat_messages m/, rows: newestFirst }]) + const repo = makeDrizzleAdminEventRepository(ctl.sql as unknown as Sql) + const messages = await repo.listMessages(EVENT_ID) + expect(messages.map((m) => m.text)).toEqual(["message 1", "message 2", "message 3"]) + expect(ctl.statements[0]?.sql).toMatch(/ORDER BY m\.created_at DESC, m\.id DESC/) + }) + + it("the offline twin keeps the newest capped window too", async () => { + const repo = new InMemoryAdminEventRepository() + const seeded = Array.from({ length: SEEDED_MESSAGES }, (_, i) => message(i + 1)) + repo.seedEvent({ id: EVENT_ID, messages: seeded }) + const messages = await repo.listMessages(EVENT_ID) + expect(messages).toHaveLength(ADMIN_EVENT_MESSAGE_CAP) + expect(messages[0]?.text).toBe(`message ${SEEDED_MESSAGES - ADMIN_EVENT_MESSAGE_CAP + 1}`) + expect(messages.at(-1)?.text).toBe(`message ${SEEDED_MESSAGES}`) + }) +}) + +describe("admin event flag toggle serializes on the event row", () => { + it("locks the cleanup row before reading the latest flag row", async () => { + const ctl = makeFakeSql([ + { match: /SELECT id FROM cleanups/, rows: [{ id: EVENT_ID }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminEventRepository(ctl.sql as unknown as Sql) + await repo.toggleFlag(EVENT_ID, { reason: null, actorId: "op-1" }) + const lock = ctl.statements.find((s) => /SELECT id FROM cleanups/.test(s.sql)) + expect(lock?.sql).toMatch(/FOR NO KEY UPDATE/) + }) +}) + +describe("admin event outcome", () => { + it("records the outcome on the event timeline in the same transaction", async () => { + const ctl = makeFakeSql([ + { match: /UPDATE cleanups SET bags/, rows: [{ id: EVENT_ID }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminEventRepository(ctl.sql as unknown as Sql) + await repo.setBags(EVENT_ID, { bags: 12, actorId: "op-1" }) + const timeline = ctl.statements.find((s) => /INSERT INTO cleanup_timeline/.test(s.sql)) + expect(timeline?.sql).toMatch(/'outcome'/) + expect(timeline?.values).toContain("op-1") + }) + + it("the offline twin appends the outcome row", async () => { + const repo = new InMemoryAdminEventRepository() + repo.seedEvent({ id: EVENT_ID }) + await repo.setBags(EVENT_ID, { bags: 12, actorId: "op-1" }) + const timeline = await repo.listTimeline(EVENT_ID) + expect(timeline.at(-1)).toMatchObject({ kind: "outcome", who: "operator" }) + }) +}) diff --git a/services/api/test/unit/admin-home-analytics-correctness.test.ts b/services/api/test/unit/admin-home-analytics-correctness.test.ts new file mode 100644 index 00000000..7f32853c --- /dev/null +++ b/services/api/test/unit/admin-home-analytics-correctness.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleHomeRepository } from "../../src/services/admin/home-repository.drizzle.js" +import { makeDrizzleAnalyticsRepository } from "../../src/services/admin/analytics-repository.drizzle.js" + +const EVENT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e51" +const CACHE_TTL_MS = 60_000 + +describe("home map event pins", () => { + it("carry the event's real flag state", async () => { + const ctl = makeFakeSql([ + { + match: /FROM cleanups c/, + rows: [ + { + id: EVENT_ID, + lat: 1, + lng: 2, + status: "upcoming", + event_kind: "cleanup", + title: "Park", + place: null, + attendees: "3", + flagged: true, + }, + ], + }, + ]) + const repo = makeDrizzleHomeRepository(ctl.sql as unknown as Sql) + const pins = await repo.recentPins(10) + expect(pins.find((p) => p.refType === "event")?.flagged).toBe(true) + const eventQuery = ctl.statements.find((s) => /FROM cleanups c/.test(s.sql)) + expect(eventQuery?.sql).toMatch(/cleanup_timeline/) + }) +}) + +describe("analytics top contributors", () => { + it("rank only public reports and live, unbanned accounts before the cut", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzleAnalyticsRepository(ctl.sql as unknown as Sql) + await repo.topContributors(5) + const text = ctl.statements[0]?.sql ?? "" + const reportCounts = text.slice( + text.indexOf("report_counts AS"), + text.indexOf("cleanup_counts"), + ) + expect(reportCounts).toMatch(/visibility = 'public'/) + const top = text.slice(text.indexOf("top AS"), text.indexOf("user_city AS")) + expect(top).toMatch(/deleted_at IS NULL/) + expect(top).toMatch(/'banned'/) + }) +}) + +describe("analytics TTL cache", () => { + it("is scoped to the database handle a repository was built over", async () => { + const first = makeFakeSql([{ match: /FROM reports/, rows: [{ category: "litter", n: "7" }] }]) + const second = makeFakeSql([ + { match: /FROM reports/, rows: [{ category: "graffiti", n: "2" }] }, + ]) + const a = makeDrizzleAnalyticsRepository(first.sql as unknown as Sql, { + cacheTtlMs: CACHE_TTL_MS, + }) + const b = makeDrizzleAnalyticsRepository(second.sql as unknown as Sql, { + cacheTtlMs: CACHE_TTL_MS, + }) + const fromA = await a.byCategory() + const fromB = await b.byCategory() + expect(fromB).not.toEqual(fromA) + expect(second.statements).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/admin-host-platform-routes.test.ts b/services/api/test/unit/admin-host-platform-routes.test.ts index 55d13e55..0dac5b50 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -43,7 +43,7 @@ function pageRow(patch: Partial = {}): AdminEventPageRow { flaggedByName: null, flaggedByHandle: null, flaggedByJoined: null, - sortAt: new Date("2026-08-01T00:00:00.000Z"), + cursorAt: "2026-08-01T00:00:00.000000Z", ...patch, } } diff --git a/services/api/test/unit/admin-keyset-precision.test.ts b/services/api/test/unit/admin-keyset-precision.test.ts new file mode 100644 index 00000000..3f5d86ce --- /dev/null +++ b/services/api/test/unit/admin-keyset-precision.test.ts @@ -0,0 +1,169 @@ +/** + * Admin keyset cursors must carry the column's full microsecond instant. postgres-js hands timestamptz back + * as a JS Date (millisecond precision) and serializes a Date param with toISOString(), so a cursor built + * from the Date skips every row in the anchor's millisecond on a DESC list and repeats them on an ASC one. + * Rows written by one transaction share now(), so a burst of audit rows is exactly that case. + */ + +import { describe, it, expect } from "vitest" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleAuditRepository } from "../../src/services/admin/audit-repository.drizzle.js" +import { makeDrizzleAdminReportRepository } from "../../src/services/admin/admin-report-repository.drizzle.js" +import { makeDrizzleAdminUserRepository } from "../../src/services/admin/admin-user-repository.drizzle.js" +import { makeDrizzleAdminEventRepository } from "../../src/services/admin/admin-event-repository.drizzle.js" +import { makeDrizzleModerationRepository } from "../../src/services/admin/moderation-repository.drizzle.js" +import { makeDrizzleActivityRepository } from "../../src/services/admin/activity-repository.drizzle.js" +import { decodeCursor } from "../../src/services/admin/pagination.js" + +const AT = new Date("2026-09-01T10:00:00.123Z") +const AT_TEXT = "2026-09-01T10:00:00.123456Z" +const ID_A = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" +const ID_B = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e02" +const CURSOR = `${AT_TEXT}|${ID_A}` + +/** Two rows sharing one millisecond, so a limit-1 page has a next cursor anchored on the first. */ +function twoRows(extra: Record): Record[] { + return [ + { id: ID_A, cursor_at: AT_TEXT, ...extra }, + { id: ID_B, cursor_at: "2026-09-01T10:00:00.123001Z", ...extra }, + ] +} + +function lastStatement(ctl: FakeSqlControl, match: RegExp): { sql: string; values: unknown[] } { + const hit = [...ctl.statements].reverse().find((s) => match.test(s.sql)) + if (hit === undefined) throw new Error(`no statement matched ${String(match)}`) + return hit +} + +/** The page-2 statement binds the anchor as full-precision text cast to timestamptz, never a Date. */ +function expectExactAnchor(ctl: FakeSqlControl, match: RegExp): void { + const stmt = lastStatement(ctl, match) + expect(stmt.values).toContain(AT_TEXT) + expect(stmt.values.some((v) => v instanceof Date && v.getTime() === AT.getTime())).toBe(false) + expect(stmt.sql).toContain("::timestamptz") +} + +describe("decodeCursor keeps the cursor instant as text", () => { + it("returns the microsecond text alongside the Date", () => { + const anchor = decodeCursor(CURSOR, true) + expect(anchor?.atText).toBe(AT_TEXT) + expect(anchor?.id).toBe(ID_A) + }) + + it("keeps a legacy millisecond cursor and a timestamp-only cursor parseable", () => { + expect(decodeCursor(`2026-09-01T10:00:00.123Z|${ID_A}`, true)?.atText).toBe( + "2026-09-01T10:00:00.123Z", + ) + expect(decodeCursor("2026-09-01T10:00:00.123Z", true)?.atText).toBe("2026-09-01T10:00:00.123Z") + }) +}) + +describe("admin keyset lists carry microsecond cursors", () => { + it("audit log", async () => { + const ctl = makeFakeSql([{ match: /FROM audit_log a/, rows: twoRows({ created_at: AT }) }]) + const repo = makeDrizzleAuditRepository(ctl.sql as unknown as Sql) + const base = { actor: null, action: null, target: null, limit: 1 } + const page1 = await repo.list({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.list({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, /FROM audit_log a/) + }) + + it("reports", async () => { + const ctl = makeFakeSql([{ match: /FROM reports r/, rows: twoRows({ created_at: AT }) }]) + const repo = makeDrizzleAdminReportRepository(ctl.sql as unknown as Sql) + const base = { + q: null, + statuses: null, + flaggedOnly: false, + needsVerificationOnly: false, + limit: 1, + } + const page1 = await repo.listReports({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listReports({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, /FROM reports r/) + }) + + it("users", async () => { + const ctl = makeFakeSql([{ match: /FROM users u/, rows: twoRows({ created_at: AT }) }]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + const base = { q: null, status: null, flaggedOnly: false, deletedOnly: false, limit: 1 } + const page1 = await repo.listUsers({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listUsers({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, /FROM users u/) + }) + + it("a user's reports", async () => { + const ctl = makeFakeSql([ + { match: /WHERE r\.reporter_user_id/, rows: twoRows({ created_at: AT, category: "litter" }) }, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listUserReports(ID_A, null, 1) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listUserReports(ID_A, CURSOR, 1) + expectExactAnchor(ctl, /WHERE r\.reporter_user_id/) + }) + + it("a user's events", async () => { + const ctl = makeFakeSql([ + { match: /FROM cleanup_members cm/, rows: twoRows({ when_at: AT, attendees: "1" }) }, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listUserEvents(ID_A, null, 1) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listUserEvents(ID_A, CURSOR, 1) + expectExactAnchor(ctl, /FROM cleanup_members cm/) + }) + + it("a user's messages", async () => { + const ctl = makeFakeSql([ + { match: /FROM dm_messages dm/, rows: twoRows({ created_at: AT, source: "dm" }) }, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listUserMessages(ID_A, null, 1) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listUserMessages(ID_A, CURSOR, 1) + expectExactAnchor(ctl, /FROM dm_messages dm/) + }) + + it("events", async () => { + const ctl = makeFakeSql([{ match: /FROM cleanups c/, rows: twoRows({ scheduled_at: AT }) }]) + const repo = makeDrizzleAdminEventRepository(ctl.sql as unknown as Sql) + const base = { q: null, status: null, flaggedOnly: false, limit: 1 } + const page1 = await repo.listEvents({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listEvents({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, /FROM cleanups c/) + }) + + it("moderation queue", async () => { + const ctl = makeFakeSql([ + { + match: /FROM moderation_items\s+WHERE status = 'open'/, + rows: twoRows({ created_at: AT, kind: "image", subject_type: "report", meta: {} }), + }, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listOpen({ q: null, filter: "all", limit: 1, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listOpen({ q: null, filter: "all", limit: 1, cursor: CURSOR }) + expectExactAnchor(ctl, /FROM moderation_items\s+WHERE status = 'open'/) + }) + + it.each(["newest", "oldest"] as const)("activity feed (%s)", async (sort) => { + const ctl = makeFakeSql([ + { match: /FROM \(/, rows: twoRows({ source: "audit", ts: AT, action: "user.suspended" }) }, + ]) + const repo = makeDrizzleActivityRepository(ctl.sql as unknown as Sql) + const base = { q: null, filter: "all" as const, sort, limit: 1 } + const page1 = await repo.list({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.list({ ...base, cursor: CURSOR }) + const stmt = lastStatement(ctl, /FROM \(/) + expect(stmt.sql).toContain(sort === "newest" ? ") < (" : ") > (") + expectExactAnchor(ctl, /FROM \(/) + }) +}) diff --git a/services/api/test/unit/admin-mail.test.ts b/services/api/test/unit/admin-mail.test.ts index 978f9d4d..c2617fac 100644 --- a/services/api/test/unit/admin-mail.test.ts +++ b/services/api/test/unit/admin-mail.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import type { InsertMessageInput } from "../../src/services/admin/mail-repository.js" +import { ROUTE_CLAIM_STALE_SECONDS } from "../../src/services/admin/outbound-send-policy.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import { makeMailService, @@ -34,6 +36,14 @@ function harness(): Harness { return { repo, mailer, svc, objects } } +async function seedDeliveredOut( + repo: InMemoryMailRepository, + input: Omit, +): Promise { + const out = await repo.insertMessage({ ...input, direction: "out" }) + await repo.recordEvent({ threadId: input.threadId, messageId: out!.id, type: "sent" }) +} + describe("mail-service recipient-resolution helpers", () => { it("resolveCorrespondent picks the latest non-civfix from address, else null", () => { expect(resolveCorrespondent([], FROM_OUTREACH)).toBeNull() @@ -253,9 +263,8 @@ describe("mail-service: reply", () => { it("appends an OUT reply to the thread's jurisdiction contact, delivers, marks replied + read", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Question", org: "City of LA" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "clerk@city.gov", body: "Original packet.", @@ -285,9 +294,8 @@ describe("mail-service: reply", () => { it("H5: Reply targets the jurisdiction contact even after an unrelated sender joins the thread", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Pothole", org: "City of LA" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "publicworks@lacity.gov", body: "Report packet.", @@ -312,9 +320,8 @@ describe("mail-service: reply", () => { it("H5: Resend re-sends the last outbound packet to the jurisdiction contact, not the inbound sender", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Pothole", org: "City of LA" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "publicworks@lacity.gov", body: "Report packet.", @@ -369,6 +376,40 @@ describe("mail-service: reply", () => { await expect(svc.reply(t.id, { body: "any update?" }, "op-1")).resolves.toBeDefined() }) + it("refuses Reply and Resend while the newest outbound attempt is still transmitting with no outcome yet", async () => { + const { repo, mailer, svc } = harness() + const t = await repo.createThread({ subject: "Pothole", org: "City of LA" }) + await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + toAddr: "pw@lacity.gov", + body: "packet", + }) + + await expect(svc.resend(t.id, "op-1")).rejects.toMatchObject({ httpStatus: 409 }) + await expect(svc.reply(t.id, { body: "any update?" }, "op-1")).rejects.toMatchObject({ + httpStatus: 409, + }) + expect(mailer.sent).toHaveLength(0) + }) + + it("treats an outcome-less attempt older than the claim window as crashed, not in flight", async () => { + const { repo, mailer, svc } = harness() + const t = await repo.createThread({ subject: "Pothole", org: "City of LA" }) + await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + toAddr: "pw@lacity.gov", + body: "packet", + }) + repo.now = new Date(repo.now.getTime() + (ROUTE_CLAIM_STALE_SECONDS + 1) * 1000) + + await svc.resend(t.id, "op-1") + expect(mailer.sent.at(-1)?.to).toBe("pw@lacity.gov") + }) + it("H5: a thread with ONLY an inbound message has no jurisdiction contact, so Reply is refused", async () => { const { repo, svc } = harness() const t = await repo.createThread({ subject: "Cold inbound" }) @@ -384,9 +425,8 @@ describe("mail-service: reply", () => { it("M1: replies to a composed outbound-only thread using the stored OUT to_addr", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Intro" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "mayor@city.gov", body: "Hello.", @@ -399,9 +439,8 @@ describe("mail-service: reply", () => { it("F025: resolves the recipient through the point reads, not by loading every body in the thread", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Question", org: "City of LA" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "clerk@city.gov", body: "Original packet.", @@ -428,9 +467,8 @@ describe("mail-service: reply", () => { httpStatus: 404, }) const t = await repo.createThread({ subject: "S" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, body: "hi", }) @@ -473,9 +511,8 @@ describe("mail-service: resend", () => { fromAddr: "clerk@city.gov", body: "Q?", }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "clerk@city.gov", body: "original outbound", @@ -495,9 +532,8 @@ describe("mail-service: resend", () => { it("M1: resends a composed outbound-only thread using the stored OUT to_addr", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Intro" }) - await repo.insertMessage({ + await seedDeliveredOut(repo, { threadId: t.id, - direction: "out", fromAddr: FROM_OUTREACH, toAddr: "mayor@city.gov", body: "Hello.", @@ -511,9 +547,8 @@ describe("mail-service: resend", () => { const body = "x".repeat(100_000) h.objects.set("media/r2/photo.jpg", new Uint8Array([0xff, 0xd8, 0xff, 0x01])) const t = await h.repo.createThread({ subject: "Pothole", org: "City of LA" }) - await h.repo.insertMessage({ + await seedDeliveredOut(h.repo, { threadId: t.id, - direction: "out", fromAddr: '"civfix Reports" ', toAddr: "clerk@city.gov", subject: "civfix report: Pothole", diff --git a/services/api/test/unit/admin-message-broadcast.test.ts b/services/api/test/unit/admin-message-broadcast.test.ts new file mode 100644 index 00000000..3e0cfe61 --- /dev/null +++ b/services/api/test/unit/admin-message-broadcast.test.ts @@ -0,0 +1,159 @@ +/** + * Operator removals of chat and DM messages must reach connected clients the way a member's own delete + * does: one message_update frame for the room carrying the tombstone. Before, only the SQL ran, so every + * open web/mobile client kept rendering removed content until it refetched history. + */ + +import { describe, it, expect } from "vitest" +import type { ChatMessageDTO, RoomKind } from "@civfix/shared" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { + makeAdminReportChatService, + makeMessageUpdateAnnouncer, +} from "../../src/services/admin/admin-report-chat-service.js" +import { findMessageRoom } from "../../src/services/admin/admin-report-chat-repository.drizzle.js" +import { InMemoryAdminReportChatRepository } from "../../src/services/admin/admin-report-chat-repository.memory.js" +import { makeAdminUserService } from "../../src/services/admin/admin-user-service.js" +import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { makeModerationService } from "../../src/services/admin/moderation-service.js" +import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import type { ReportChatSendDeps } from "../../src/services/report-chat-send.js" + +const REPORT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e61" +const MESSAGE_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e62" +const USER_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e63" + +interface Frame { + kind: RoomKind + roomId: string + message: ChatMessageDTO +} + +function tombstone(): ChatMessageDTO { + return { + id: MESSAGE_ID, + mine: true, + reactions: [{ emoji: "👍", count: 1, mine: true }], + deletedAt: "2026-09-01T00:00:00.000Z", + } as unknown as ChatMessageDTO +} + +function recordingAnnouncer(): { announced: string[]; announce: (id: string) => Promise } { + const announced: string[] = [] + return { + announced, + announce: (id: string) => { + announced.push(id) + return Promise.resolve() + }, + } +} + +describe("makeMessageUpdateAnnouncer", () => { + it("broadcasts the current message to its room with the viewer fields neutralized", async () => { + const frames: Frame[] = [] + const announce = makeMessageUpdateAnnouncer({ + findRoom: () => Promise.resolve({ kind: "report", id: REPORT_ID }), + loadMessage: () => Promise.resolve(tombstone()), + broadcast: (kind, roomId, message) => frames.push({ kind, roomId, message }), + }) + await announce(MESSAGE_ID) + expect(frames).toHaveLength(1) + expect(frames[0]).toMatchObject({ kind: "report", roomId: REPORT_ID }) + expect(frames[0]?.message.mine).toBe(false) + expect(frames[0]?.message.reactions?.[0]?.mine).toBe(false) + }) + + it("never fails the committed removal: a broadcast error is logged", async () => { + const warned: unknown[] = [] + const announce = makeMessageUpdateAnnouncer({ + findRoom: () => Promise.reject(new Error("db down")), + loadMessage: () => Promise.resolve(null), + broadcast: () => undefined, + logger: { warn: (obj: unknown) => warned.push(obj) }, + }) + await expect(announce(MESSAGE_ID)).resolves.toBeUndefined() + expect(warned).toHaveLength(1) + }) +}) + +describe("findMessageRoom", () => { + it("resolves a report chat message to its report room", async () => { + const ctl = makeFakeSql([ + { match: /FROM chat_messages/, rows: [{ room_kind: "report", room_id: REPORT_ID }] }, + ]) + await expect(findMessageRoom(ctl.sql as unknown as Sql, MESSAGE_ID)).resolves.toEqual({ + kind: "report", + id: REPORT_ID, + }) + expect(ctl.statements[0]?.sql).toMatch(/FROM dm_messages/) + }) + + it("returns null for an unknown message", async () => { + const ctl = makeFakeSql() + await expect(findMessageRoom(ctl.sql as unknown as Sql, MESSAGE_ID)).resolves.toBeNull() + }) +}) + +describe("operator removals announce the tombstone", () => { + it("report chat remove", async () => { + const repo = new InMemoryAdminReportChatRepository() + repo.seedReport(REPORT_ID) + repo.seedMessage({ id: MESSAGE_ID, reportId: REPORT_ID }) + const rec = recordingAnnouncer() + const svc = makeAdminReportChatService({ + repo, + historySource: () => { + throw new Error("unused") + }, + send: {} as ReportChatSendDeps, + announceMessageUpdate: rec.announce, + }) + await svc.removeMessage(REPORT_ID, MESSAGE_ID, { reason: null, actorId: "op-1" }) + expect(rec.announced).toEqual([MESSAGE_ID]) + await expect( + svc.removeMessage(REPORT_ID, MESSAGE_ID, { reason: null, actorId: "op-1" }), + ).rejects.toThrow() + expect(rec.announced).toEqual([MESSAGE_ID]) + }) + + it("user message remove", async () => { + const repo = new InMemoryAdminUserRepository() + repo.seedMessage(USER_ID, { + id: MESSAGE_ID, + text: "hi", + thread: "Cleanup", + createdAt: new Date("2026-09-01T00:00:00.000Z"), + }) + const rec = recordingAnnouncer() + const svc = makeAdminUserService({ + repo, + sessions: { applyStatus: () => Promise.resolve(0), revokeAll: () => Promise.resolve(0) }, + announceMessageUpdate: rec.announce, + }) + await svc.removeMessage(USER_ID, MESSAGE_ID, { reason: null, actorId: "op-1" }) + expect(rec.announced).toEqual([MESSAGE_ID]) + }) + + it("moderation remove and appeal overturn of a chat message", async () => { + const repo = new InMemoryModerationRepository() + const removal = repo.seedItem({ subjectType: "chat", subjectId: MESSAGE_ID }) + const appeal = repo.seedItem({ kind: "appeal", subjectType: "chat", subjectId: MESSAGE_ID }) + const rec = recordingAnnouncer() + const svc = makeModerationService({ repo, announceMessageUpdate: rec.announce }) + await svc.remove(removal.id, { actorId: "op-1", reason: null }) + expect(rec.announced).toEqual([MESSAGE_ID]) + await svc.appeal(appeal.id, { decision: "overturn", actorId: "op-1", note: null }) + expect(rec.announced).toEqual([MESSAGE_ID, MESSAGE_ID]) + }) + + it("an upheld appeal changes nothing and announces nothing", async () => { + const repo = new InMemoryModerationRepository() + const appeal = repo.seedItem({ kind: "appeal", subjectType: "message", subjectId: MESSAGE_ID }) + const rec = recordingAnnouncer() + const svc = makeModerationService({ repo, announceMessageUpdate: rec.announce }) + await svc.appeal(appeal.id, { decision: "uphold", actorId: "op-1", note: null }) + expect(rec.announced).toEqual([]) + }) +}) diff --git a/services/api/test/unit/admin-moderation-correctness.test.ts b/services/api/test/unit/admin-moderation-correctness.test.ts new file mode 100644 index 00000000..ab188934 --- /dev/null +++ b/services/api/test/unit/admin-moderation-correctness.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleModerationRepository } from "../../src/services/admin/moderation-repository.drizzle.js" +import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" + +const ITEM_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e31" +const USER_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e32" +const REPORT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e33" +const AUDIT_ROW: SqlHandler = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } + +function itemRow(over: Record): Record { + return { + id: ITEM_ID, + kind: "user_report", + subject_type: "user", + subject_id: USER_ID, + flag: null, + reason: null, + category: null, + place: null, + priority: "med", + auto_action: null, + status: "removed", + signals: [], + similar: [], + meta: {}, + created_at: new Date("2026-09-01T00:00:00.000Z"), + ...over, + } +} + +const strikes = (sqlText: string): boolean => + /INSERT INTO user_moderation \(user_id, strikes, removals/.test(sqlText) + +describe("moderation remove on a user subject whose account row is gone", () => { + it("resolves the item without writing a suspension for a user that does not exist", async () => { + const ctl = makeFakeSql([ + { match: /UPDATE moderation_items\s+SET status/, rows: [itemRow({})] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + const record = await repo.remove(ITEM_ID, { actorId: "op-1", reason: null }) + expect(record?.id).toBe(ITEM_ID) + expect(record?.suspendedUserId).toBeUndefined() + expect( + ctl.statements.some((s) => + /INSERT INTO user_moderation \(user_id, account_status/.test(s.sql), + ), + ).toBe(false) + }) +}) + +describe("appeal overturn on a user subject", () => { + it("lifts only the suspension moderation imposes, never an operator ban", async () => { + const ctl = makeFakeSql([ + { + match: /UPDATE moderation_items\s+SET status = 'approved'/, + rows: [itemRow({ kind: "appeal", status: "approved" })], + }, + AUDIT_ROW, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + const record = await repo.decideAppeal(ITEM_ID, { + decision: "overturn", + actorId: "op-1", + note: null, + }) + expect(record?.restoredUserId).toBeUndefined() + const restore = ctl.statements.find((s) => /account_status = 'active'/.test(s.sql)) + expect(restore?.sql).toMatch(/account_status = 'suspended'/) + expect(restore?.sql).not.toMatch(/INSERT INTO user_moderation/) + }) + + it("the offline twin keeps a non-suspended account as it is", async () => { + const repo = new InMemoryModerationRepository() + const item = repo.seedItem({ kind: "appeal", subjectType: "user", subjectId: USER_ID }) + const record = await repo.decideAppeal(item.id, { + decision: "overturn", + actorId: "op-1", + note: null, + }) + expect(record?.restoredUserId).toBeUndefined() + expect(repo.accountStatus.get(USER_ID)).toBeUndefined() + }) +}) + +describe("owner takedown requests", () => { + const ownerRequest = { + kind: "user_report" as const, + subjectType: "report" as const, + subjectId: REPORT_ID, + flag: "Owner takedown request", + reason: "please remove", + reporter: "@owner", + reporterUserId: USER_ID, + priority: "high" as const, + dedupeOpen: true, + } + + it("folding into an item another party opened records the owner without the consent marker", async () => { + const ctl = makeFakeSql([ + { match: /SELECT id FROM moderation_items/, rows: [{ id: ITEM_ID }] }, + { match: /SELECT reporter_user_id FROM reports/, rows: [{ reporter_user_id: USER_ID }] }, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + await expect(repo.createItem(ownerRequest)).resolves.toBeNull() + const escalate = ctl.statements.find((s) => /SET priority = 'high'/.test(s.sql)) + expect(escalate?.sql).toMatch(/'\{reporters\}'/) + expect(escalate?.values).toContain(USER_ID) + expect(escalate?.values).not.toContainEqual({ ownerTakedown: true }) + expect(escalate?.values).not.toContain("Owner takedown request") + expect(escalate?.sql).not.toMatch(/- 'ownerTakedown'/) + }) + + it("a third party folding into an owner's item clears the consent marker", async () => { + const ctl = makeFakeSql([ + { match: /SELECT id FROM moderation_items/, rows: [{ id: ITEM_ID }] }, + { + match: /SELECT reporter_user_id FROM reports/, + rows: [{ reporter_user_id: "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e99" }], + }, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + await repo.createItem({ ...ownerRequest, flag: "User report", priority: "med" }) + const escalate = ctl.statements.find((s) => /SET priority = 'high'/.test(s.sql)) + expect(escalate?.sql).toMatch(/- 'ownerTakedown'/) + }) + + it("a new item filed by the report's own author carries the marker", async () => { + const ctl = makeFakeSql([ + { match: /SELECT id FROM moderation_items/, rows: [] }, + { match: /SELECT reporter_user_id FROM reports/, rows: [{ reporter_user_id: USER_ID }] }, + { match: /INSERT INTO moderation_items/, rows: [{ id: ITEM_ID }] }, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + await repo.createItem(ownerRequest) + const insert = ctl.statements.find((s) => /INSERT INTO moderation_items/.test(s.sql)) + expect(insert?.values).toContainEqual(expect.objectContaining({ ownerTakedown: true })) + }) + + it("honoring an owner takedown does not strike the owner", async () => { + const ctl = makeFakeSql([ + { + match: /UPDATE moderation_items\s+SET status/, + rows: [ + itemRow({ subject_type: "report", subject_id: REPORT_ID, meta: { ownerTakedown: true } }), + ], + }, + { match: /UPDATE reports SET status = 'rejected'/, rows: [{ id: REPORT_ID }] }, + { match: /SELECT reporter_user_id FROM reports/, rows: [{ reporter_user_id: USER_ID }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + await repo.remove(ITEM_ID, { actorId: "op-1", reason: null }) + expect(ctl.statements.some((s) => strikes(s.sql))).toBe(false) + }) + + it("a third-party report removal still strikes the author", async () => { + const ctl = makeFakeSql([ + { + match: /UPDATE moderation_items\s+SET status/, + rows: [itemRow({ subject_type: "report", subject_id: REPORT_ID })], + }, + { match: /UPDATE reports SET status = 'rejected'/, rows: [{ id: REPORT_ID }] }, + { match: /SELECT reporter_user_id FROM reports/, rows: [{ reporter_user_id: USER_ID }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + await repo.remove(ITEM_ID, { actorId: "op-1", reason: null }) + expect(ctl.statements.some((s) => strikes(s.sql))).toBe(true) + }) +}) + +describe("moderation remove on an already banned user", () => { + it("keeps the ban and does not hand the account to the suspension path", async () => { + const ctl = makeFakeSql([ + { + match: /UPDATE moderation_items\s+SET status/, + rows: [itemRow({ subject_type: "user", subject_id: USER_ID })], + }, + { match: /SELECT role FROM users/, rows: [{ role: "user" }] }, + { match: /INSERT INTO user_moderation \(user_id, account_status/, rows: [] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + const record = await repo.remove(ITEM_ID, { actorId: "op-1", reason: null }) + const upsert = ctl.statements.find((s) => + /INSERT INTO user_moderation \(user_id, account_status/.test(s.sql), + ) + expect(upsert?.sql).toMatch(/WHERE user_moderation\.account_status <> 'banned'/) + const lock = ctl.statements.find((s) => /SELECT role FROM users/.test(s.sql)) + expect(lock?.sql).toMatch(/FOR NO KEY UPDATE/) + expect(record?.suspendedUserId).toBeUndefined() + expect(ctl.statements.some((s) => strikes(s.sql))).toBe(true) + }) + + it("suspends an account that is not banned", async () => { + const ctl = makeFakeSql([ + { + match: /UPDATE moderation_items\s+SET status/, + rows: [itemRow({ subject_type: "user", subject_id: USER_ID })], + }, + { match: /SELECT role FROM users/, rows: [{ role: "user" }] }, + { + match: /INSERT INTO user_moderation \(user_id, account_status/, + rows: [{ user_id: USER_ID }], + }, + AUDIT_ROW, + ]) + const repo = makeDrizzleModerationRepository(ctl.sql as unknown as Sql) + const record = await repo.remove(ITEM_ID, { actorId: "op-1", reason: null }) + expect(record?.suspendedUserId).toBe(USER_ID) + }) + + it("the offline twin keeps a banned account banned", async () => { + const repo = new InMemoryModerationRepository() + repo.accountStatus.set(USER_ID, "banned") + const item = repo.seedItem({ kind: "user_report", subjectType: "user", subjectId: USER_ID }) + const record = await repo.remove(item.id, { actorId: "op-1", reason: null }) + expect(record?.suspendedUserId).toBeUndefined() + expect(repo.accountStatus.get(USER_ID)).toBe("banned") + }) +}) diff --git a/services/api/test/unit/admin-outreach-claim.test.ts b/services/api/test/unit/admin-outreach-claim.test.ts index 167d9a2e..ed50a67c 100644 --- a/services/api/test/unit/admin-outreach-claim.test.ts +++ b/services/api/test/unit/admin-outreach-claim.test.ts @@ -1,9 +1,12 @@ -import { describe, it, expect } from "vitest" +import { describe, it, expect, vi } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import type { OutboundEmail } from "@civfix/shared/interfaces" import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" import { InMemoryOutreachRepository } from "../../src/services/admin/outreach-repository.memory.js" -import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" +import { + makeOutboundMailService, + OutboundSendDeadlineError, +} from "../../src/services/admin/outbound-mail-service.js" import { makeOutreachService, type OutreachService, @@ -251,6 +254,36 @@ describe("outreach digest: a delivered digest is never re-sent (F109)", () => { expect(state.get(GEOID)?.lastOutreachAt).toEqual(NOW) }) + it("keeps the claim when the send hit its deadline, because the digest may still be delivered", async () => { + const { svc, state } = serviceWith(() => Promise.reject(new OutboundSendDeadlineError(1000))) + + await expect(svc.runForGeoid(GEOID)).rejects.toMatchObject({ outboundSendDeadline: true }) + expect(state.get(GEOID)?.lastOutreachAt).toEqual(NOW) + }) + + it("logs a failed claim release instead of swallowing it, and still surfaces the send error", async () => { + const { outreachRepo, mailRepo } = harness() + const warn = vi.fn() + const releaseFailure = new Error("db down") + mailRepo.setOutreachState = () => Promise.reject(releaseFailure) + const svc = makeOutreachService({ + outreachRepo, + mailRepo, + outboundMail: { + sendToCity: () => Promise.reject(new Error("OCI mail transient 500")), + } as unknown as OutboundMailService, + throttleDays: THROTTLE_DAYS, + now: () => NOW, + logger: { warn }, + }) + + await expect(svc.runForGeoid(GEOID)).rejects.toThrow("OCI mail transient 500") + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ geoid: GEOID, err: releaseFailure }), + expect.any(String), + ) + }) + it("STILL releases the claim on a plain delivery failure (untagged error), so it retries", async () => { const { svc, state } = serviceWith(() => Promise.reject(new Error("OCI mail transient 500"))) diff --git a/services/api/test/unit/admin-report-correctness.test.ts b/services/api/test/unit/admin-report-correctness.test.ts new file mode 100644 index 00000000..1d69b449 --- /dev/null +++ b/services/api/test/unit/admin-report-correctness.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { makeDrizzleAdminReportRepository } from "../../src/services/admin/admin-report-repository.drizzle.js" +import { makeAdminReportService } from "../../src/services/admin/admin-report-service.js" +import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" +import { RecordingNotifier } from "../helpers/notifications.js" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" + +const NOW = new Date("2026-06-06T00:00:00.000Z") +const REPORT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e11" + +const AUDIT_ROW = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } + +const REPORTER = { + id: "u-7", + name: "Sam", + handle: "sam", + emailVerified: true, + hasOauth: false, + joinedAt: null, +} + +interface Warned { + obj: unknown + msg: string | undefined +} + +function harness(opts: { emitterThrows?: boolean } = {}) { + const repo = new InMemoryAdminReportRepository() + repo.now = NOW + const notifier = new RecordingNotifier() + const warned: Warned[] = [] + const svc = makeAdminReportService({ + repo, + outboundMail: makeOutboundMailService({ + repo: new InMemoryMailRepository(), + mailer: new FakeMailer(), + env: { MAIL_FROM_OUTREACH: "outreach@civfix.org", MAIL_REPLY_DOMAIN: "civfix.org" }, + }), + now: () => NOW, + notifications: notifier, + logger: { warn: (obj: unknown, msg?: string) => warned.push({ obj, msg }) }, + reportChatEmitter: { + emit: () => + opts.emitterThrows === true ? Promise.reject(new Error("emit boom")) : Promise.resolve(), + }, + }) + return { repo, notifier, warned, svc } +} + +describe("admin report flag: a failed chat mirror is logged, not swallowed", () => { + it("keeps the committed toggle and warns through the injected logger", async () => { + const { repo, warned, svc } = harness({ emitterThrows: true }) + repo.seedReport({ id: REPORT_ID, flagged: false }) + await expect(svc.flag(REPORT_ID, { reason: null, actorId: "op-1" })).resolves.toBe(true) + expect(repo.reports.get(REPORT_ID)?.record.flagged).toBe(true) + expect(warned).toHaveLength(1) + expect(warned[0]?.obj).toMatchObject({ reportId: REPORT_ID }) + }) +}) + +describe("admin report verdict: the timeline row is the operator's", () => { + it("attributes the verdict timeline row to the operator, not to system", async () => { + const { repo, svc } = harness() + repo.seedReport({ id: REPORT_ID }) + await svc.setVerdict({ id: REPORT_ID, verdict: "approved", actorId: "op-1" }) + const rows = repo.timeline.get(REPORT_ID) ?? [] + expect(rows.at(-1)).toMatchObject({ note: "Approved by an operator", who: "operator" }) + expect(rows.filter((r) => r.note === "Approved by an operator")).toHaveLength(1) + }) + + it("writes the timeline row inside the verdict transaction with the operator as actor", async () => { + const ctl = makeFakeSql([ + { match: /UPDATE reports\s+SET verification_verdict/, rows: [{ reporter_user_id: null }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminReportRepository(ctl.sql as unknown as Sql) + await repo.setReportVerdict(REPORT_ID, { + verdict: "rejected", + actorId: "op-1", + note: "Rejected by an operator", + }) + const timeline = ctl.statements.find((s) => /INSERT INTO report_timeline/.test(s.sql)) + expect(timeline?.values).toContain("op-1") + expect(timeline?.values).toContain("Rejected by an operator") + }) +}) + +describe("admin report follow-up to the reporter", () => { + it("never messages the citizen when the follow-up could not be recorded", async () => { + const { repo, notifier, svc } = harness() + repo.seedReport({ id: REPORT_ID, reporter: REPORTER }) + repo.appendFollowup = () => Promise.reject(new Error("audit write failed")) + await expect( + svc.sendFollowup(REPORT_ID, { to: "reporter", body: "hello", actorId: "op-1" }), + ).rejects.toThrow("audit write failed") + expect(notifier.sent).toHaveLength(0) + }) + + it("records the follow-up and then notifies the reporter", async () => { + const { repo, notifier, svc } = harness() + repo.seedReport({ id: REPORT_ID, reporter: REPORTER }) + await svc.sendFollowup(REPORT_ID, { to: "reporter", body: "hello", actorId: "op-1" }) + expect(notifier.sent).toHaveLength(1) + expect(repo.audits.at(-1)).toMatchObject({ action: "report.followup_sent" }) + }) +}) + +describe("admin report flag toggle serializes on the report row", () => { + it("locks the report row before reading the open flag", async () => { + const ctl = makeFakeSql([ + { match: /SELECT status FROM reports/, rows: [{ status: "submitted" }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminReportRepository(ctl.sql as unknown as Sql) + await repo.toggleFlag(REPORT_ID, { reason: null, actorId: "op-1" }) + const lock = ctl.statements.find((s) => /SELECT status FROM reports/.test(s.sql)) + expect(lock?.sql).toMatch(/FOR NO KEY UPDATE/) + }) +}) + +describe("admin report route lock", () => { + it("logs a failed advisory unlock instead of dropping it", async () => { + const warned: Warned[] = [] + const reserved = Object.assign( + (strings: TemplateStringsArray): Promise => + strings.join("?").includes("pg_advisory_unlock") + ? Promise.reject(new Error("connection lost")) + : Promise.resolve([]), + { release: () => undefined }, + ) + const sql = { reserve: () => Promise.resolve(reserved) } as unknown as Sql + const repo = makeDrizzleAdminReportRepository(sql, { + logger: { warn: (obj: unknown, msg?: string) => warned.push({ obj, msg }) }, + }) + await expect(repo.withRouteLock(REPORT_ID, () => Promise.resolve("sent"))).resolves.toBe("sent") + expect(warned).toHaveLength(1) + expect(warned[0]?.obj).toMatchObject({ reportId: REPORT_ID }) + }) +}) diff --git a/services/api/test/unit/admin-report-routing-bounced.test.ts b/services/api/test/unit/admin-report-routing-bounced.test.ts new file mode 100644 index 00000000..651a1989 --- /dev/null +++ b/services/api/test/unit/admin-report-routing-bounced.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleAdminReportRepository } from "../../src/services/admin/admin-report-repository.drizzle.js" +import { makeDrizzleHomeRepository } from "../../src/services/admin/home-repository.drizzle.js" + +const REPORT_ID = "4c1f7d2e-8b3a-4f5e-9a6b-7c8d9e0f1a2b" +const LEGACY_BOUNCE_GUARD = + /me\.type = 'bounced' AND lower\(me\.meta->>'failedRecipient'\) = lower\([a-z_]+\.v\)/ + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ") +} + +describe("report routing skips bounced contacts", () => { + it("getRouting picks the first legacy address that has not bounced, not contact_emails[1]", async () => { + const fake = makeFakeSql() + await makeDrizzleAdminReportRepository(fake.sql as unknown as Sql).getRouting(REPORT_ID) + const text = flat(fake.statements[0]?.sql ?? "") + expect(text).not.toMatch(/contact_emails\[1\]/) + expect(text).toMatch(LEGACY_BOUNCE_GUARD) + expect(text).toMatch(/WITH ORDINALITY/) + }) + + it("getRouting routes to the usable legacy address the query returns", async () => { + const fake = makeFakeSql([ + { + match: /FROM reports r/, + rows: [ + { + geoid: "0644000", + place: "Los Angeles", + category: "trash", + cat_email: null, + default_email: null, + legacy_email: "second@lacity.gov", + forward_subject_template: null, + forward_body_template: null, + }, + ], + }, + ]) + const routing = await makeDrizzleAdminReportRepository(fake.sql as unknown as Sql).getRouting( + REPORT_ID, + ) + expect(routing).toMatchObject({ contact: "second@lacity.gov", routed: true }) + }) + + it("the waiting-for-routing count treats bounced per-category and legacy contacts as unusable", async () => { + const fake = makeFakeSql() + await makeDrizzleHomeRepository(fake.sql as unknown as Sql).discoverySummary() + const text = flat(fake.statements[0]?.sql ?? "") + const routable = text.slice(text.indexOf("AND NOT"), text.indexOf("per_geoid AS")) + expect(routable).toMatch(/bounced_at IS NULL/) + expect(routable).toMatch(LEGACY_BOUNCE_GUARD) + }) +}) diff --git a/services/api/test/unit/admin-user-correctness.test.ts b/services/api/test/unit/admin-user-correctness.test.ts new file mode 100644 index 00000000..6d45313d --- /dev/null +++ b/services/api/test/unit/admin-user-correctness.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest" +import { AppError, ErrorCode } from "@civfix/shared" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleAdminUserRepository } from "../../src/services/admin/admin-user-repository.drizzle.js" + +const USER_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e21" +const AUDIT_ROW = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } + +function isForbidden(err: unknown): boolean { + return err instanceof AppError && err.code === ErrorCode.FORBIDDEN +} + +describe("admin user writes re-check the target inside the transaction", () => { + it("refuses a role change when the row became an operator after the service pre-check", async () => { + const ctl = makeFakeSql([ + { match: /SELECT role FROM users/, rows: [{ role: "operator" }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + const outcome = await repo.applyRole(USER_ID, { role: "citizen", actorId: "op-1" }).then( + () => "written", + (err: unknown) => (isForbidden(err) ? "forbidden" : err), + ) + expect(outcome).toBe("forbidden") + expect(ctl.statements.some((s) => /UPDATE users SET role/.test(s.sql))).toBe(false) + }) + + it("locks the target row for the role write", async () => { + const ctl = makeFakeSql([ + { match: /SELECT role FROM users/, rows: [{ role: "citizen" }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + await expect(repo.applyRole(USER_ID, { role: "citizen", actorId: "op-1" })).resolves.toBe(true) + const read = ctl.statements.find((s) => /SELECT role FROM users/.test(s.sql)) + expect(read?.sql).toMatch(/FOR NO KEY UPDATE/) + }) + + it("refuses a status change on a row that became an operator after the service pre-check", async () => { + const ctl = makeFakeSql([ + { match: /FROM users WHERE id/, rows: [{ id: USER_ID, role: "operator" }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + const outcome = await repo + .setStatus(USER_ID, { status: "banned", reason: null, actorId: "op-1" }) + .then( + () => "written", + (err: unknown) => (isForbidden(err) ? "forbidden" : err), + ) + expect(outcome).toBe("forbidden") + expect(ctl.statements.some((s) => /INSERT INTO user_moderation/.test(s.sql))).toBe(false) + }) + + it("locks the target row without blocking foreign-key checks on it", async () => { + const ctl = makeFakeSql([ + { match: /FROM users WHERE id/, rows: [{ id: USER_ID, role: "citizen" }] }, + AUDIT_ROW, + ]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + await repo.setStatus(USER_ID, { status: "suspended", reason: null, actorId: "op-1" }) + const lock = ctl.statements.find((s) => /SELECT id, role FROM users/.test(s.sql)) + expect(lock?.sql).toMatch(/FOR NO KEY UPDATE/) + }) +}) + +describe("admin user flag toggle serializes on the user row", () => { + it("locks the user row before reading the current flag", async () => { + const ctl = makeFakeSql([{ match: /SELECT id FROM users/, rows: [{ id: USER_ID }] }, AUDIT_ROW]) + const repo = makeDrizzleAdminUserRepository(ctl.sql as unknown as Sql) + await repo.toggleFlag(USER_ID, { reason: null, actorId: "op-1" }) + const lock = ctl.statements.find((s) => /SELECT id FROM users/.test(s.sql)) + expect(lock?.sql).toMatch(/FOR NO KEY UPDATE/) + }) +}) diff --git a/services/api/test/unit/auth-routes-web-edges.test.ts b/services/api/test/unit/auth-routes-web-edges.test.ts new file mode 100644 index 00000000..9c33d6a3 --- /dev/null +++ b/services/api/test/unit/auth-routes-web-edges.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, afterEach } from "vitest" +import { makeAuthHarness, type AuthHarness } from "../helpers/auth.js" +import { resolvePostLoginRedirect } from "../../src/routes/auth.routes.js" +import type { OAuthConfig } from "../../src/auth/oauth.js" + +const WEB_ORIGIN = "https://app.civfix.org" +const DAY_MS = 24 * 60 * 60 * 1000 + +let harness: AuthHarness | undefined + +afterEach(async () => { + if (harness) { + await harness.app.close() + harness = undefined + } +}) + +function cookieLines(setCookie: string | string[] | undefined): string[] { + return Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [] +} + +function cookieValue(setCookie: string | string[] | undefined, name: string): string | null { + for (const line of cookieLines(setCookie)) { + if (!line.startsWith(`${name}=`)) continue + const pair = line.split(";")[0]! + return decodeURIComponent(pair.slice(name.length + 1)) + } + return null +} + +function cookieMaxAge(setCookie: string | string[] | undefined, name: string): number | null { + for (const line of cookieLines(setCookie)) { + if (!line.startsWith(`${name}=`)) continue + const match = /max-age=(\d+)/i.exec(line) + if (match) return Number(match[1]) + } + return null +} + +const OAUTH_WITH_APPLE_WEB: OAuthConfig = { + google: { + clientId: "test-google-client", + clientSecret: "test-google-secret", + redirectUri: "http://localhost:8080/auth/google/callback", + }, + apple: { + clientId: "test-apple-client", + teamId: "TEAMID", + keyId: "KEYID", + privateKey: "unused-in-stubbed-verify", + redirectUri: "http://localhost:8080/auth/apple/callback", + webClientId: "org.civfix.web", + }, +} + +describe("resolvePostLoginRedirect returns the value it validated", () => { + const origins = ["https://civfix.org"] + + it("strips surrounding whitespace from an accepted redirect", () => { + expect(resolvePostLoginRedirect(" /dashboard ", origins)).toBe("/dashboard") + expect(resolvePostLoginRedirect("\thttps://civfix.org/home\n", origins)).toBe( + "https://civfix.org/home", + ) + }) +}) + +describe("GET /auth/session cookie lifetimes", () => { + it("gives the CSRF cookie the same remaining lifetime as the session cookie", async () => { + harness = await makeAuthHarness({ startMs: Date.now() - 5 * DAY_MS }) + const email = "csrf.lifetime@example.com" + await harness.app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) + const code = harness.mailer.lastOtpFor(email)! + const verify = await harness.app.inject({ + method: "POST", + url: "/v1/auth/otp/verify", + headers: { "x-client": "web" }, + payload: { email, code }, + }) + const session = cookieValue(verify.headers["set-cookie"], "civfix_session")! + + const check = await harness.app.inject({ + method: "GET", + url: "/v1/auth/session", + headers: { cookie: `civfix_session=${session}` }, + }) + expect(check.json().authenticated).toBe(true) + const sessionMaxAge = cookieMaxAge(check.headers["set-cookie"], "civfix_session") + const csrfMaxAge = cookieMaxAge(check.headers["set-cookie"], "civfix_csrf") + expect(sessionMaxAge).not.toBeNull() + expect(sessionMaxAge!).toBeLessThan(harness.services.sessions.ttl) + expect(csrfMaxAge).toBe(sessionMaxAge) + }) +}) + +describe("web OAuth callbacks when the user cancels at the provider", () => { + it("Google: redirects back to the captured target and clears the handshake cookie", async () => { + harness = await makeAuthHarness({ webOrigins: [WEB_ORIGIN] }) + const start = await harness.app.inject({ + method: "GET", + url: `/auth/google/start?redirect=${encodeURIComponent("/reports/7")}`, + }) + expect(start.statusCode).toBe(302) + const state = new URL(start.headers.location as string).searchParams.get("state")! + const handshake = cookieValue(start.headers["set-cookie"], "civfix_oauth")! + + const res = await harness.app.inject({ + method: "GET", + url: `/auth/google/callback?error=access_denied&state=${encodeURIComponent(state)}`, + headers: { cookie: `civfix_oauth=${encodeURIComponent(handshake)}` }, + }) + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe("/reports/7") + expect(cookieLines(res.headers["set-cookie"]).some((l) => l.startsWith("civfix_oauth=;"))).toBe( + true, + ) + expect(cookieValue(res.headers["set-cookie"], "civfix_session")).toBeNull() + }) + + it("Google: a cancel with a foreign state neither honors the stash nor clears it", async () => { + harness = await makeAuthHarness({ webOrigins: [WEB_ORIGIN] }) + const start = await harness.app.inject({ + method: "GET", + url: `/auth/google/start?redirect=${encodeURIComponent("/reports/7")}`, + }) + const handshake = cookieValue(start.headers["set-cookie"], "civfix_oauth")! + + const res = await harness.app.inject({ + method: "GET", + url: "/auth/google/callback?error=access_denied&state=someone-elses-state", + headers: { cookie: `civfix_oauth=${encodeURIComponent(handshake)}` }, + }) + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe(WEB_ORIGIN) + expect(cookieLines(res.headers["set-cookie"]).some((l) => l.startsWith("civfix_oauth="))).toBe( + false, + ) + }) + + it("Google: a callback with neither code nor error is still rejected", async () => { + harness = await makeAuthHarness({ webOrigins: [WEB_ORIGIN] }) + const res = await harness.app.inject({ method: "GET", url: "/auth/google/callback?state=x" }) + expect(res.statusCode).toBe(422) + }) + + it("Apple: a form_post cancel redirects back to the captured target", async () => { + harness = await makeAuthHarness({ webOrigins: [WEB_ORIGIN], oauthConfig: OAUTH_WITH_APPLE_WEB }) + const start = await harness.app.inject({ + method: "GET", + url: `/auth/apple/start?redirect=${encodeURIComponent("/events")}`, + }) + expect(start.statusCode).toBe(302) + const state = new URL(start.headers.location as string).searchParams.get("state")! + const handshake = cookieValue(start.headers["set-cookie"], "civfix_oauth")! + + const res = await harness.app.inject({ + method: "POST", + url: "/auth/apple/callback", + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie: `civfix_oauth=${encodeURIComponent(handshake)}`, + }, + payload: new URLSearchParams({ error: "user_cancelled_authorize", state }).toString(), + }) + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe("/events") + expect(cookieValue(res.headers["set-cookie"], "civfix_session")).toBeNull() + }) +}) diff --git a/services/api/test/unit/auth-session-mint-race.test.ts b/services/api/test/unit/auth-session-mint-race.test.ts new file mode 100644 index 00000000..646e3101 --- /dev/null +++ b/services/api/test/unit/auth-session-mint-race.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest" +import type { Role } from "@civfix/shared" +import { InMemoryCacheClient, type CacheClient } from "../../src/auth/cache.js" +import { + InMemorySessionStore, + type AccountStatus, + type SessionInsert, +} from "../../src/auth/stores.js" +import { SessionService } from "../../src/auth/session-service.js" +import { sha256Hex } from "../../src/auth/crypto.js" + +const USER = "22222222-2222-2222-2222-222222222222" +const START_MS = 1_700_000_000_000 + +class StatusLookup { + status: AccountStatus = "active" + accountStatus(): Promise { + return Promise.resolve(this.status) + } + findById(): Promise<{ role: Role } | null> { + return Promise.resolve({ role: "citizen" }) + } +} + +class RacingSessionStore extends InMemorySessionStore { + beforeInsert: (() => Promise) | undefined + + override async insert(row: SessionInsert): Promise { + const hook = this.beforeInsert + this.beforeInsert = undefined + if (hook) await hook() + return super.insert(row) + } +} + +function makeRacingService() { + const now = (): number => START_MS + const store = new RacingSessionStore() + const cache = new InMemoryCacheClient(now) + const users = new StatusLookup() + const service = new SessionService({ store, cache, users, now }) + return { service, store, users } +} + +describe("createSession against a suspension that lands between the status read and the insert", () => { + it("does not leave a durable session for the suspended user", async () => { + const { service, store, users } = makeRacingService() + store.beforeInsert = async () => { + users.status = "suspended" + store.setAccountStatus(USER, "suspended") + await service.applyAccountStatus(USER, "suspended") + } + + await expect(service.createSession(USER, ["citizen"])).rejects.toMatchObject({ + httpStatus: 403, + }) + expect(store.count()).toBe(0) + }) + + it("keeps the new session when only a logout-everywhere raced the login", async () => { + const { service, store } = makeRacingService() + store.beforeInsert = async () => { + await service.revokeAllForUser(USER) + } + + const token = await service.createSession(USER, ["citizen"]) + const resolved = await service.resolveSession(token) + expect(resolved?.userId).toBe(USER) + expect(resolved?.source).toBe("cache") + expect(await store.findById(await sha256Hex(token))).not.toBeNull() + }) +}) + +class FailingDelCache extends InMemoryCacheClient { + override del(): Promise { + return Promise.reject(new Error("redis del down")) + } +} + +describe("session cache eviction failures are logged, not dropped", () => { + it("logs when a rejected cached projection cannot be evicted", async () => { + let clock = START_MS + const now = (): number => clock + const store = new InMemorySessionStore() + const cache: CacheClient = new FailingDelCache(now) + const errors: unknown[] = [] + const service = new SessionService({ + store, + cache, + now, + ttlSeconds: 60, + logger: { error: (obj) => errors.push(obj) }, + }) + const token = await service.createSession(USER, ["citizen"]) + const hash = await sha256Hex(token) + await service.bumpEpoch(USER) + + expect(await service.resolveSession(token)).not.toBeNull() + expect(errors).toContainEqual(expect.objectContaining({ hash })) + + errors.length = 0 + clock += 61_000 + await store.insert({ + id: hash, + userId: USER, + roles: ["citizen"], + expiresAt: new Date(clock - 1), + lastSeenAt: new Date(START_MS), + userAgent: null, + ip: null, + }) + expect(await service.resolveSession(token)).toBeNull() + expect(errors).toContainEqual(expect.objectContaining({ hash })) + }) +}) diff --git a/services/api/test/unit/backfill-keyset-cursor.test.ts b/services/api/test/unit/backfill-keyset-cursor.test.ts new file mode 100644 index 00000000..13a18b74 --- /dev/null +++ b/services/api/test/unit/backfill-keyset-cursor.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { stampReferenceCodes, type ReferenceCodeRow } from "../../src/db/backfill-keyset.js" +import type { Sql } from "../../src/db/client.js" + +const MAX_PAGES = 20 + +interface StoredRow { + id: string + createdAtIso: string + referenceCode: string | null +} + +function micros(iso: string): bigint { + const m = /^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d{1,6}))?Z$/.exec(iso) + if (m === null) throw new Error(`unexpected instant ${iso}`) + const seconds = BigInt(Date.parse(`${m[1]}Z`)) * 1000n + return seconds + BigInt((m[2] ?? "").padEnd(6, "0")) +} + +function boundMicros(value: unknown): bigint { + if (value instanceof Date) return BigInt(value.getTime()) * 1000n + return micros(String(value)) +} + +function keysetTable(rows: StoredRow[]) { + let pages = 0 + return makeFakeSql([ + { + match: /SELECT t\.id, t\.created_at/, + rows: (bound) => { + const values = bound.filter((v) => !(typeof v === "string" && v.includes("HH24"))) + pages += 1 + if (pages > MAX_PAGES) throw new Error("keyset loop never terminated") + const limit = Number(values[values.length - 1]) + const after = + values.length === 3 ? { at: boundMicros(values[0]), id: String(values[1]) } : null + return rows + .filter((r) => r.referenceCode === null) + .filter((r) => { + if (after === null) return true + const at = micros(r.createdAtIso) + return at > after.at || (at === after.at && r.id > after.id) + }) + .sort((a, b) => Number(micros(a.createdAtIso) - micros(b.createdAtIso))) + .slice(0, limit) + .map((r) => ({ + id: r.id, + created_at: new Date(r.createdAtIso), + cursor_at: r.createdAtIso, + jur_code: null, + })) + }, + }, + { + match: /UPDATE reports/, + rows: (values) => { + const row = rows.find((r) => r.id === values[1]) + if (row) row.referenceCode = String(values[0]) + return [] + }, + }, + ]) +} + +describe("stampReferenceCodes keyset cursor", () => { + it("terminates when the last unstamped row keeps failing and has sub-millisecond precision", async () => { + const rows: StoredRow[] = [ + { id: "a", createdAtIso: "2026-01-01T00:00:00.100250Z", referenceCode: null }, + { id: "b", createdAtIso: "2026-01-01T00:00:00.200750Z", referenceCode: null }, + ] + const fake = keysetTable(rows) + const result = await stampReferenceCodes(fake.sql as unknown as Sql, { + table: "reports", + batchSize: 1, + label: "test", + extraColumn: null, + allocate: (_tx, row) => + row.id === "b" ? Promise.reject(new Error("allocator refused")) : Promise.resolve("R-1"), + }) + expect(result).toEqual({ stamped: 1, failed: 1 }) + }) +}) diff --git a/services/api/test/unit/backfill-signup-seats-args.test.ts b/services/api/test/unit/backfill-signup-seats-args.test.ts new file mode 100644 index 00000000..a8f4e091 --- /dev/null +++ b/services/api/test/unit/backfill-signup-seats-args.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { + SIGNUP_SEAT_BACKFILL_MAX_BATCH, + parseSignupSeatBatchArg, +} from "../../src/db/backfill-signup-seats.js" + +describe("backfill-signup-seats --batch", () => { + it("is absent when the flag is not passed", () => { + expect(parseSignupSeatBatchArg(["--yes"])).toBeUndefined() + }) + + it("accepts a positive integer up to the cap", () => { + expect(parseSignupSeatBatchArg(["--yes", "--batch", "200"])).toBe(200) + expect(parseSignupSeatBatchArg(["--batch", String(SIGNUP_SEAT_BACKFILL_MAX_BATCH)])).toBe( + SIGNUP_SEAT_BACKFILL_MAX_BATCH, + ) + }) + + it("rejects a missing, non-numeric, fractional, zero or oversized value instead of LIMIT NaN", () => { + for (const argv of [ + ["--batch"], + ["--batch", "--yes"], + ["--batch", "abc"], + ["--batch", "2.5"], + ["--batch", "0"], + ["--batch", String(SIGNUP_SEAT_BACKFILL_MAX_BATCH + 1)], + ]) { + expect(parseSignupSeatBatchArg(argv), argv.join(" ")).toBeNull() + } + }) +}) diff --git a/services/api/test/unit/broadcast-pipeline-security.test.ts b/services/api/test/unit/broadcast-pipeline-security.test.ts index bca40ace..74d19563 100644 --- a/services/api/test/unit/broadcast-pipeline-security.test.ts +++ b/services/api/test/unit/broadcast-pipeline-security.test.ts @@ -84,6 +84,7 @@ function harness(options: { organizationSuspended: boolean }) { service, notifications: { createNotifications: () => Promise.resolve(), + createNotificationsReportingFailures: () => Promise.resolve({ failed: [] }), } as unknown as NotificationService, mailer, cache, diff --git a/services/api/test/unit/broadcast-repository-security.test.ts b/services/api/test/unit/broadcast-repository-security.test.ts index b001a918..de92747f 100644 --- a/services/api/test/unit/broadcast-repository-security.test.ts +++ b/services/api/test/unit/broadcast-repository-security.test.ts @@ -43,6 +43,22 @@ describe("in-memory host messaging suspension", () => { expect(await repo.hostMessagingState(UNKNOWN_USER)).toBeNull() }) + it("answers not-found for a soft-deleted user, like an unknown one", async () => { + const repo = new InMemoryBroadcastRepository() + repo.seedHost(KNOWN_HOST) + repo.softDeleteHost(KNOWN_HOST) + + const found = await repo.setHostMessagingSuspended(KNOWN_HOST, true, { + action: "host.messaging_suspended", + actorId: OPERATOR, + target: `user:${KNOWN_HOST}`, + }) + + expect(found).toBe(false) + expect(repo.audits).toHaveLength(0) + expect(await repo.hostMessagingState(KNOWN_HOST)).toBeNull() + }) + it("suspends a known host and records the audit row", async () => { const repo = new InMemoryBroadcastRepository() repo.seedHost(KNOWN_HOST) @@ -58,3 +74,25 @@ describe("in-memory host messaging suspension", () => { expect((await repo.hostMessagingState(KNOWN_HOST))?.suspended).toBe(true) }) }) + +describe("host messaging suspension SQL", () => { + it("only upserts the flag for a user that is not soft-deleted", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleBroadcastRepository(fake.sql as unknown as Sql) + + const found = await repo.setHostMessagingSuspended( + "00000000-0000-0000-0000-0000000000aa", + true, + { + action: "host.messaging_suspended", + actorId: "00000000-0000-0000-0000-0000000000cc", + target: "user:00000000-0000-0000-0000-0000000000aa", + }, + ) + + expect(found).toBe(false) + const upsert = fake.statements.find((s) => /INSERT INTO user_moderation/.test(s.sql)) + expect(upsert?.sql).toMatch(/FROM users u WHERE u\.id = \? AND u\.deleted_at IS NULL/) + expect(fake.statements).toEqual([upsert]) + }) +}) diff --git a/services/api/test/unit/certificate-seal-year.test.ts b/services/api/test/unit/certificate-seal-year.test.ts new file mode 100644 index 00000000..6735a4b9 --- /dev/null +++ b/services/api/test/unit/certificate-seal-year.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import PDFDocument from "pdfkit" +import { + buildTranscriptModel, + type CertificateTranslator, +} from "../../src/services/certificate-model.js" +import { buildServiceHoursPdf } from "../../src/services/certificate-pdf.js" + +const t: CertificateTranslator = (key) => key + +const HOLDER = { + userId: "11111111-1111-4111-8111-111111111111", + displayName: "Jane Doe", + handle: "jane", + verified: true, +} + +async function sealTextFor(issuedAt: Date): Promise { + const written: string[] = [] + vi.spyOn(PDFDocument.prototype, "text").mockImplementation(function ( + this: PDFKit.PDFDocument, + text: unknown, + ) { + written.push(String(text)) + return this + }) + const model = buildTranscriptModel({ holder: HOLDER, locale: "en", t, rows: [] }) + await buildServiceHoursPdf({ model, code: "A1B2C3D4E5F6", issuedAt, t }) + return written +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("certificate seal year", () => { + it("prints the year of the Pacific issue date, not the UTC year, on New Year's Eve evening", async () => { + const written = await sealTextFor(new Date("2027-01-01T07:30:00.000Z")) + + expect(written).toContain("2026") + expect(written).not.toContain("2027") + }) + + it("prints the new year once it is New Year's Day in Pacific time", async () => { + const written = await sealTextFor(new Date("2027-01-01T08:30:00.000Z")) + + expect(written).toContain("2027") + }) +}) diff --git a/services/api/test/unit/certificate-verify-url.test.ts b/services/api/test/unit/certificate-verify-url.test.ts new file mode 100644 index 00000000..e46dafe7 --- /dev/null +++ b/services/api/test/unit/certificate-verify-url.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import PDFDocument from "pdfkit" +import { FakeStorage } from "@civfix/shared/fakes" +import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" +import { makeCertificateService } from "../../src/services/certificate-service.js" +import type { VolunteerHoursEntryView } from "../../src/services/volunteer-hours-service.js" + +const qrPayloads = vi.hoisted(() => [] as string[]) + +vi.mock("qrcode-generator", async (importOriginal) => { + const actual = (await importOriginal()) as { + default: (t: number, e: string) => { addData(d: string): void } + } + return { + default: (typeNumber: number, level: string) => { + const qr = actual.default(typeNumber, level) + const addData = qr.addData.bind(qr) + qr.addData = (data: string) => { + qrPayloads.push(data) + addData(data) + } + return qr + }, + } +}) + +const USER = "11111111-1111-4111-8111-111111111111" + +const ENTRY: VolunteerHoursEntryView = { + id: "22222222-2222-4222-8222-222222222222", + source: "event", + hours: 2, + createdAt: new Date("2026-06-01T18:00:00.000Z"), + occurredAt: new Date("2026-06-01T17:00:00.000Z"), + cleanupId: "33333333-3333-4333-8333-333333333333", + cleanupTitle: "Beach cleanup", + cleanupReferenceCode: null, + reportId: null, + jurisdictionGeoid: "0644000", + jurisdictionName: "Los Angeles", + creditedBy: { + id: "44444444-4444-4444-8444-444444444444", + name: "Ada", + handle: null, + organization: null, + }, +} + +function textsWritten(): string[] { + const written: string[] = [] + vi.spyOn(PDFDocument.prototype, "text").mockImplementation(function ( + this: PDFKit.PDFDocument, + text: unknown, + ) { + written.push(String(text)) + return this + }) + return written +} + +afterEach(() => { + vi.restoreAllMocks() + qrPayloads.length = 0 +}) + +describe("certificate verify link follows the deployment's web origin", () => { + it("encodes the staging origin in the QR and prints it in the footer", async () => { + const written = textsWritten() + const certs = new InMemoryCertificateRepository() + certs.setHolder(USER, { displayName: "Jane Doe", handle: "jane" }) + const service = makeCertificateService({ + repo: certs, + hours: { + entriesForCertificate: () => + Promise.resolve({ items: [ENTRY], totalHours: 2, entryCount: 1 }), + }, + storage: new FakeStorage(), + verifyBaseUrl: "https://civfix.dev/service-record", + mintCode: () => "A1B2C3D4E5F6", + }) + + await service.issue(USER, "en") + + expect(qrPayloads).toEqual(["https://civfix.dev/service-record/CFX-A1B2-C3D4-E5F6"]) + expect(written).toContain("civfix.dev/service-record") + expect(written).not.toContain("civfix.org/service-record") + }) + + it("names the staging verify page in the prompt beside the QR", async () => { + const written = textsWritten() + await issueWith("https://civfix.dev/service-record") + + expect(written).toContain("Verify this record at civfix.dev/service-record") + expect(written).not.toContain("Verify this record at civfix.org/service-record") + }) + + it("keeps the production prompt word for word when no base URL is wired", async () => { + const written = textsWritten() + await issueWith(undefined) + + expect(written).toContain("Verify this record at civfix.org/service-record") + }) +}) + +async function issueWith(verifyBaseUrl: string | undefined): Promise { + const certs = new InMemoryCertificateRepository() + certs.setHolder(USER, { displayName: "Jane Doe", handle: "jane" }) + const service = makeCertificateService({ + repo: certs, + hours: { + entriesForCertificate: () => + Promise.resolve({ items: [ENTRY], totalHours: 2, entryCount: 1 }), + }, + storage: new FakeStorage(), + ...(verifyBaseUrl === undefined ? {} : { verifyBaseUrl }), + mintCode: () => "A1B2C3D4E5F6", + }) + await service.issue(USER, "en") +} diff --git a/services/api/test/unit/chat-block-gates.test.ts b/services/api/test/unit/chat-block-gates.test.ts index d019d5c6..e326e6af 100644 --- a/services/api/test/unit/chat-block-gates.test.ts +++ b/services/api/test/unit/chat-block-gates.test.ts @@ -38,14 +38,16 @@ function message(): ChatMessageDTO { } function notificationSpy(): { - createNotifications: ReturnType + createNotificationsReportingFailures: ReturnType recipients: () => string[] } { - const createNotifications = vi.fn(() => Promise.resolve()) + const createNotificationsReportingFailures = vi.fn(() => Promise.resolve({ failed: [] })) return { - createNotifications, + createNotificationsReportingFailures, recipients: () => - createNotifications.mock.calls.flatMap((c) => (c as unknown as [string[]])[0]), + createNotificationsReportingFailures.mock.calls.flatMap( + (c) => (c as unknown as [string[]])[0], + ), } } @@ -53,7 +55,9 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { const build = (isBlockedEitherWay: (a: string, b: string) => Promise) => { const spy = notificationSpy() const notify = makeReportChatNotifier({ - notificationService: { createNotifications: spy.createNotifications } as never, + notificationService: { + createNotificationsReportingFailures: spy.createNotificationsReportingFailures, + } as never, reportChatRepo: { listMemberIds: () => Promise.resolve([ACTOR, BLOCKED, NEUTRAL]) }, isMuted: () => Promise.resolve(false), roomKeyFor: (_k, id) => `report:${id}`, @@ -99,7 +103,9 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { */ it("REQUIRED dep: a construction that omits the blocks seam does not typecheck", () => { const withoutBlocks = { - notificationService: { createNotifications: () => Promise.resolve() } as never, + notificationService: { + createNotificationsReportingFailures: () => Promise.resolve({ failed: [] }), + } as never, reportChatRepo: { listMemberIds: () => Promise.resolve([ACTOR]) }, isMuted: () => Promise.resolve(false), roomKeyFor: (_k: "report", id: string) => `report:${id}`, @@ -108,7 +114,9 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { expect(makeReportChatNotifier(withoutBlocks)).toBeTypeOf("function") const withoutBlocksGroup = { - notificationService: { createNotifications: () => Promise.resolve() } as never, + notificationService: { + createNotificationsReportingFailures: () => Promise.resolve({ failed: [] }), + } as never, groupRepo: { listMemberIds: () => Promise.resolve([ACTOR]) }, isMuted: () => Promise.resolve(false), roomKeyFor: (_k: "group", id: string) => `group:${id}`, @@ -130,7 +138,9 @@ describe("M11 batch seam: blockedIdsFor replaces the per-candidate gate, with th const spy = notificationSpy() const single = vi.fn(() => Promise.resolve(false)) const notify = makeReportChatNotifier({ - notificationService: { createNotifications: spy.createNotifications } as never, + notificationService: { + createNotificationsReportingFailures: spy.createNotificationsReportingFailures, + } as never, reportChatRepo: { listMemberIds: () => Promise.resolve([ACTOR, BLOCKED, NEUTRAL]) }, isMuted: () => Promise.resolve(false), roomKeyFor: (_k, id) => `report:${id}`, @@ -165,7 +175,9 @@ describe("M11: the group-room fan-out skips blocked pairs", () => { it("drops the blocked member and keeps the rest", async () => { const spy = notificationSpy() const notify = makeGroupChatNotifier({ - notificationService: { createNotifications: spy.createNotifications } as never, + notificationService: { + createNotificationsReportingFailures: spy.createNotificationsReportingFailures, + } as never, groupRepo: { listMemberIds: () => Promise.resolve([ACTOR, BLOCKED, NEUTRAL]) }, isMuted: () => Promise.resolve(false), roomKeyFor: (_k, id) => `group:${id}`, diff --git a/services/api/test/unit/chat-edit-mentions.test.ts b/services/api/test/unit/chat-edit-mentions.test.ts new file mode 100644 index 00000000..b08160d5 --- /dev/null +++ b/services/api/test/unit/chat-edit-mentions.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest" +import { randomUUID } from "node:crypto" +import type { UserMentionDTO } from "@civfix/shared" +import { makeChatEditService } from "../../src/services/chat-edit-service.js" +import { InMemoryChatRepository } from "../helpers/chat.js" + +const ALICE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const BOB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +const REPORT = "cccccccc-cccc-cccc-cccc-cccccccccccc" +const BOB_MENTION: UserMentionDTO = { id: BOB, handle: "bob", displayName: "Bob" } + +async function reportMessage(chat: InMemoryChatRepository, body: string): Promise { + const msg = await chat.insertMessage( + { cleanupId: REPORT, roomKind: "report", userId: ALICE, body }, + randomUUID(), + ) + return msg.id +} + +function reportChat(): InMemoryChatRepository { + const chat = new InMemoryChatRepository() + chat.registerSender({ id: ALICE, displayName: "Alice", handle: "alice" }) + return chat +} + +describe("editing a report-chat message re-records its mentions", () => { + it("replaces the recorded set from the edited body, as the send path records it", async () => { + const chat = reportChat() + const messageId = await reportMessage(chat, "hello") + const resolveKinds: string[] = [] + const recorded: { messageId: string; ids: string[] }[] = [] + const service = makeChatEditService({ + chat, + isReportMember: () => Promise.resolve(true), + chatMentions: { + resolveChatMentions: (input) => { + resolveKinds.push(input.kind) + return Promise.resolve([BOB_MENTION]) + }, + recordChatMentions: (id, ids) => { + recorded.push({ messageId: id, ids }) + return Promise.resolve() + }, + }, + }) + + await service.editMessage({ + roomKind: "report", + roomId: REPORT, + messageId, + userId: ALICE, + body: "hello @bob", + }) + + expect(resolveKinds).toEqual(["report"]) + expect(recorded).toEqual([{ messageId, ids: [BOB] }]) + }) + + it("keeps the edit when mention re-recording fails, and logs the failure", async () => { + const chat = reportChat() + const messageId = await reportMessage(chat, "hello") + const warn = vi.fn() + const service = makeChatEditService({ + chat, + isReportMember: () => Promise.resolve(true), + chatMentions: { + resolveChatMentions: () => Promise.resolve([BOB_MENTION]), + recordChatMentions: () => Promise.reject(new Error("mention table unavailable")), + logger: { warn }, + }, + }) + + const updated = await service.editMessage({ + roomKind: "report", + roomId: REPORT, + messageId, + userId: ALICE, + body: "hello @bob", + }) + + expect(updated.body).toBe("hello @bob") + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ messageId, kind: "report", roomId: REPORT }), + expect.stringMatching(/mention/), + ) + }) +}) diff --git a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts index 521391bc..3465036d 100644 --- a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts +++ b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts @@ -56,9 +56,9 @@ function probe(): FanoutProbe { function notifierDeps(p: FanoutProbe) { return { notificationService: { - createNotifications: (recipients: string[]) => { + createNotificationsReportingFailures: (recipients: string[]) => { p.notified.push(recipients) - return Promise.resolve() + return Promise.resolve({ failed: [] }) }, }, isMuted: () => Promise.resolve(false), diff --git a/services/api/test/unit/chat-group-kick-atomic.test.ts b/services/api/test/unit/chat-group-kick-atomic.test.ts new file mode 100644 index 00000000..cf265e27 --- /dev/null +++ b/services/api/test/unit/chat-group-kick-atomic.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest" +import { AppError } from "@civfix/shared" +import type { Sql } from "../../src/db/client.js" +import { makeChatGroupService } from "../../src/services/chat-group-service.js" +import { + makeChatGroupRepository, + type ChatGroupRepository, +} from "../../src/services/chat-group-repository.drizzle.js" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" + +const GROUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const OWNER = "11111111-1111-1111-1111-111111111111" +const MEMBER = "22222222-2222-2222-2222-222222222222" + +function recordingTransactions(fake: FakeSqlControl): Array<[number, number]> { + const ranges: Array<[number, number]> = [] + const begin = fake.sql.begin + fake.sql.begin = async (cb) => { + const start = fake.statements.length + const out = await begin(cb) + ranges.push([start, fake.statements.length]) + return out + } + return ranges +} + +function statementsIn(fake: FakeSqlControl, [start, end]: [number, number]): string[] { + return fake.statements.slice(start, end).map((s) => s.sql.replace(/\s+/g, " ").trim()) +} + +describe("group kick repository writes", () => { + it("removes the member and records the ban in one transaction", async () => { + const fake = makeFakeSql() + const txs = recordingTransactions(fake) + + await makeChatGroupRepository(fake.sql as unknown as Sql).banMember(GROUP, MEMBER, OWNER) + + expect(txs).toHaveLength(1) + const inTx = statementsIn(fake, txs[0]!) + expect(inTx.some((s) => /^DELETE FROM chat_group_members\b/.test(s))).toBe(true) + expect(inTx.some((s) => /^INSERT INTO chat_group_bans\b/.test(s))).toBe(true) + expect(fake.statements).toHaveLength(inTx.length) + }) + + it("self-join inserts only when no ban exists, in the same statement, and never clears a ban", async () => { + const fake = makeFakeSql([ + { match: /INSERT INTO chat_group_members/, rows: [{ user_id: MEMBER }] }, + ]) + + const joined = await makeChatGroupRepository(fake.sql as unknown as Sql).joinUnlessBanned( + GROUP, + MEMBER, + ) + + expect(joined).toBe(true) + expect(fake.statements).toHaveLength(1) + const sql = fake.statements[0]!.sql.replace(/\s+/g, " ") + expect(sql).toMatch(/INSERT INTO chat_group_members/) + expect(sql).toMatch(/NOT EXISTS \( SELECT 1 FROM chat_group_bans/) + expect(sql).not.toMatch(/DELETE/) + }) +}) + +interface GroupState { + members: Map + bans: Set +} + +function statefulRepo(state: GroupState, opts: { banFails?: boolean } = {}): ChatGroupRepository { + const view = { + id: GROUP, + kind: "group" as const, + name: "Room", + description: null, + avatar: null, + visibility: "public" as const, + ownerId: OWNER, + memberCount: 0, + createdAt: new Date("2026-01-01T00:00:00Z"), + } + return { + findById: () => Promise.resolve({ ...view, memberCount: state.members.size }), + roleOf: (_g: string, userId: string) => Promise.resolve(state.members.get(userId) ?? null), + isBanned: (_g: string, userId: string) => Promise.resolve(state.bans.has(userId)), + removeMember: (_g: string, userId: string) => Promise.resolve(state.members.delete(userId)), + banMember: (_g: string, userId: string) => { + if (opts.banFails) return Promise.reject(new Error("db blip")) + state.members.delete(userId) + state.bans.add(userId) + return Promise.resolve() + }, + addMembers: (_g: string, userIds: string[]) => { + for (const u of userIds) { + if (!state.members.has(u)) state.members.set(u, "member") + state.bans.delete(u) + } + return Promise.resolve() + }, + joinUnlessBanned: (_g: string, userId: string) => { + if (state.bans.has(userId) || state.members.has(userId)) return Promise.resolve(false) + state.members.set(userId, "member") + return Promise.resolve(true) + }, + listMembers: () => Promise.resolve({ members: [], nextCursor: null }), + } as unknown as ChatGroupRepository +} + +describe("group kick service", () => { + it("a failed ban write leaves the target a member instead of removed-but-unbanned", async () => { + const state: GroupState = { + members: new Map([ + [OWNER, "owner"], + [MEMBER, "member"], + ]), + bans: new Set(), + } + const svc = makeChatGroupService({ groups: statefulRepo(state, { banFails: true }) }) + + await expect(svc.removeMember(OWNER, GROUP, MEMBER)).rejects.toThrow("db blip") + + expect(state.members.has(MEMBER)).toBe(true) + }) + + it("a join whose checks ran before a kick committed does not re-add or unban the user", async () => { + const state: GroupState = { members: new Map([[OWNER, "owner"]]), bans: new Set() } + const repo = statefulRepo(state) + const roleOf = repo.roleOf.bind(repo) + repo.roleOf = async (groupId, userId) => { + const role = await roleOf(groupId, userId) + if (userId === MEMBER) state.bans.add(MEMBER) + return role + } + const svc = makeChatGroupService({ groups: repo }) + + let status: number | undefined + try { + await svc.joinGroup(MEMBER, GROUP) + } catch (err) { + if (err instanceof AppError) status = err.httpStatus + else throw err + } + + expect(status).toBe(403) + expect(state.members.has(MEMBER)).toBe(false) + expect(state.bans.has(MEMBER)).toBe(true) + }) +}) diff --git a/services/api/test/unit/chat-group-service-gates.test.ts b/services/api/test/unit/chat-group-service-gates.test.ts index 10d9f999..72c2ee37 100644 --- a/services/api/test/unit/chat-group-service-gates.test.ts +++ b/services/api/test/unit/chat-group-service-gates.test.ts @@ -71,6 +71,7 @@ function fakeRepo(opts: FakeOpts = {}): FakeRepo { removeMember: () => Promise.resolve(true), banMember, isBanned: (_g: string, userId: string) => Promise.resolve(bannedSet.has(userId)), + joinUnlessBanned: (_g: string, userId: string) => Promise.resolve(!bannedSet.has(userId)), bannedSet, listMembers: () => Promise.resolve({ members: [], nextCursor: null }), } as unknown as FakeRepo diff --git a/services/api/test/unit/chat-mention-resolver.test.ts b/services/api/test/unit/chat-mention-resolver.test.ts new file mode 100644 index 00000000..fc885a9b --- /dev/null +++ b/services/api/test/unit/chat-mention-resolver.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from "vitest" +import type { UserMentionDTO } from "@civfix/shared" +import { + makeChatMentionResolver, + resolveAndRecordChatMentions, + type ChatMentionResolverDeps, +} from "../../src/services/chat-mention-resolver.js" +import { THREAD_SIGNAL_MEMBER_CAP } from "../../src/services/cleanup-service.js" +import { REPORT_CHAT_MEMBER_SCAN_CAP } from "../../src/services/report-chat-repository.drizzle.js" +import { GROUP_MEMBER_SCAN_CAP } from "../../src/services/chat-group-repository.drizzle.js" + +const AUTHOR = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const ROOM = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +const STRANGER = "cccccccc-cccc-cccc-cccc-cccccccccccc" + +function memberIds(n: number): string[] { + return Array.from( + { length: n }, + (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`, + ) +} + +function mention(id: string): UserMentionDTO { + return { id, handle: `h${id.slice(-4)}`, displayName: "Member" } +} + +/** + * A member lookup shaped like the repositories: a numeric second argument is a LIMIT over the roster + * in join order, an id list is a membership filter over those candidates. + */ +function rosterLookup( + roster: string[], + defaultLimit: number, + seen: unknown[], +): (roomId: string, arg?: number | string[]) => Promise { + return (_roomId, arg) => { + seen.push(arg) + if (Array.isArray(arg)) return Promise.resolve(roster.filter((m) => arg.includes(m))) + return Promise.resolve(roster.slice(0, arg ?? defaultLimit)) + } +} + +function resolverOver( + kind: "cleanup" | "report" | "group", + roster: string[], + resolved: UserMentionDTO[], + seen: unknown[], +): ChatMentionResolverDeps { + const none = (): Promise => Promise.resolve([]) + return { + resolveTargets: () => Promise.resolve(resolved), + dmPeerOf: () => Promise.resolve(null), + listCleanupMemberIds: + kind === "cleanup" ? rosterLookup(roster, THREAD_SIGNAL_MEMBER_CAP, seen) : none, + listReportChatMemberIds: + kind === "report" ? rosterLookup(roster, REPORT_CHAT_MEMBER_SCAN_CAP, seen) : none, + listGroupMemberIds: kind === "group" ? rosterLookup(roster, GROUP_MEMBER_SCAN_CAP, seen) : none, + } as ChatMentionResolverDeps +} + +describe("mention scope checks membership of the mentioned users, not a capped roster", () => { + const cases = [ + { kind: "cleanup" as const, cap: THREAD_SIGNAL_MEMBER_CAP }, + { kind: "report" as const, cap: REPORT_CHAT_MEMBER_SCAN_CAP }, + { kind: "group" as const, cap: GROUP_MEMBER_SCAN_CAP }, + ] + + for (const { kind, cap } of cases) { + it(`${kind}: a member who joined after the first ${cap} can still be mentioned`, async () => { + const roster = memberIds(cap + 50) + const lateJoiner = roster[cap + 10]! + const seen: unknown[] = [] + const resolve = makeChatMentionResolver( + resolverOver(kind, roster, [mention(lateJoiner), mention(STRANGER)], seen), + ) + + const out = await resolve({ + handles: [], + userIds: [lateJoiner, STRANGER], + authorUserId: AUTHOR, + kind, + roomId: ROOM, + }) + + expect(out.map((m) => m.id)).toEqual([lateJoiner]) + expect(seen).toEqual([[lateJoiner, STRANGER]]) + }) + } + + it("keeps the resolver's order and skips the membership lookup when nothing resolved", async () => { + const roster = memberIds(3) + const seen: unknown[] = [] + const ordered = [mention(roster[2]!), mention(roster[0]!)] + const resolve = makeChatMentionResolver(resolverOver("group", roster, ordered, seen)) + + const out = await resolve({ + handles: [], + userIds: [], + authorUserId: AUTHOR, + kind: "group", + roomId: ROOM, + }) + expect(out.map((m) => m.id)).toEqual([roster[2], roster[0]]) + + const none = makeChatMentionResolver(resolverOver("group", roster, [], seen)) + seen.length = 0 + await none({ handles: [], userIds: [], authorUserId: AUTHOR, kind: "group", roomId: ROOM }) + expect(seen).toEqual([]) + }) +}) + +describe("resolveAndRecordChatMentions", () => { + it("still returns no mentions on a lookup failure, and logs it without the message body", async () => { + const warn = vi.fn() + const out = await resolveAndRecordChatMentions( + { + resolveChatMentions: () => Promise.reject(new Error("mention table unavailable")), + recordChatMentions: () => Promise.resolve(), + logger: { warn }, + }, + { + body: "hey @someone secret text", + mentionedUserIds: [], + authorUserId: AUTHOR, + kind: "group", + roomId: ROOM, + messageId: "m-1", + }, + ) + + expect(out).toEqual([]) + expect(warn).toHaveBeenCalledOnce() + const [fields, msg] = warn.mock.calls[0]! + expect(fields).toMatchObject({ messageId: "m-1", kind: "group", roomId: ROOM }) + expect(JSON.stringify(fields)).not.toContain("secret text") + expect(msg).toMatch(/mention/) + }) +}) diff --git a/services/api/test/unit/chat-room-fanout-failures.test.ts b/services/api/test/unit/chat-room-fanout-failures.test.ts new file mode 100644 index 00000000..fa03732d --- /dev/null +++ b/services/api/test/unit/chat-room-fanout-failures.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi } from "vitest" +import { FakePushSender } from "@civfix/shared/fakes" +import type { ChatMessageDTO } from "@civfix/shared" +import { runChatRoomFanout } from "../../src/services/chat-fanout-jobs.js" +import { + makeRoomFanoutNotifier, + runRoomFanout, + ROOM_FANOUT_SPEC, + type RoomFanoutNotifierDeps, +} from "../../src/services/chat-room-fanout-notifier.js" +import { makeNotificationService } from "../../src/services/notification-service.js" +import { InMemoryNotificationRepository } from "../helpers/notifications.js" + +const ACTOR = "dddddddd-dddd-dddd-dddd-dddddddddddd" +const A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +const C = "cccccccc-cccc-cccc-cccc-cccccccccccc" +const ROOM = "11111111-1111-1111-1111-111111111111" +const BODY = "meet at the north gate" + +function message(): ChatMessageDTO { + return { + id: "m-1", + cleanupId: ROOM, + roomKind: "group", + from: { id: ACTOR, name: "Dana", followers: 0, following: 0, isFollowing: false }, + body: BODY, + kind: "text", + reactions: [], + mentions: [], + createdAt: new Date().toISOString(), + } +} + +function failingFor(repo: InMemoryNotificationRepository, broken: Set): void { + const insert = repo.insertNotification.bind(repo) + const upsert = repo.upsertCoalescedNotification.bind(repo) + repo.insertNotification = (args) => + broken.has(args.userId) + ? Promise.reject(new Error("notifications table unavailable")) + : insert(args) + repo.upsertCoalescedNotification = (args) => + broken.has(args.userId) + ? Promise.reject(new Error("notifications table unavailable")) + : upsert(args) +} + +function logger() { + return { warn: vi.fn(), error: vi.fn() } +} + +function harness(opts: { broken?: string[]; members?: string[] } = {}) { + const repo = new InMemoryNotificationRepository() + const broken = new Set(opts.broken ?? []) + failingFor(repo, broken) + const serviceLog = logger() + const fanoutLog = logger() + const deps: RoomFanoutNotifierDeps = { + notificationService: makeNotificationService({ + repo, + pushSender: new FakePushSender(), + logger: serviceLog, + }), + listMemberIds: () => Promise.resolve([ACTOR, ...(opts.members ?? [A, B])]), + isMuted: () => Promise.resolve(false), + roomKey: (id) => `group:${id}`, + isBlockedEitherWay: () => Promise.resolve(false), + logger: fanoutLog, + } + return { repo, broken, deps, serviceLog, fanoutLog } +} + +function bells(repo: InMemoryNotificationRepository, userId: string) { + return repo.notifications.filter((n) => n.userId === userId && n.type === "group_chat") +} + +function runJob(deps: RoomFanoutNotifierDeps): Promise { + return runChatRoomFanout( + { + loadMessage: () => Promise.resolve(message()), + fanoutDeps: { group: deps, report: deps }, + }, + { kind: "group", roomId: ROOM, messageId: "m-1" }, + ) +} + +describe("room fan-out bell write failures, through the real notification service", () => { + it("fail the chat.room.fanout job when no bell could be written, so pg-boss retries it", async () => { + const h = harness({ broken: [A, B] }) + + await expect(runJob(h.deps)).rejects.toThrow(/no bell/) + expect(h.repo.notifications).toHaveLength(0) + }) + + it("let a retry after a total failure write each bell exactly once", async () => { + const h = harness({ broken: [A, B] }) + await expect(runJob(h.deps)).rejects.toThrow() + + h.broken.clear() + await runJob(h.deps) + + expect(bells(h.repo, A)).toHaveLength(1) + expect(bells(h.repo, B)).toHaveLength(1) + }) + + it("never double-bell a recipient whose bell landed when the same fan-out runs again", async () => { + const h = harness({ broken: [B] }) + await runJob(h.deps) + + h.broken.clear() + await runJob(h.deps) + + expect(bells(h.repo, A)).toHaveLength(1) + expect(bells(h.repo, B)).toHaveLength(1) + }) + + it("complete a partial failure with one summary line that carries counts and no message text", async () => { + const h = harness({ broken: [B], members: [A, B, C] }) + + await expect(runJob(h.deps)).resolves.toBeUndefined() + + expect(bells(h.repo, A)).toHaveLength(1) + expect(bells(h.repo, C)).toHaveLength(1) + const summaries = h.fanoutLog.warn.mock.calls.filter(([, msg]) => + /bells were not written/.test(String(msg)), + ) + expect(summaries).toHaveLength(1) + expect(summaries[0]?.[0]).toEqual({ kind: "group", recipients: 3, failed: 1 }) + expect(JSON.stringify(h.fanoutLog.warn.mock.calls)).not.toContain(BODY) + }) + + it("log one line for a whole outage from the notification service, not one per recipient", async () => { + const members = Array.from( + { length: 20 }, + (_, i) => `aaaaaaaa-aaaa-aaaa-aaaa-${String(i).padStart(12, "0")}`, + ) + const h = harness({ broken: members, members }) + + await expect(runJob(h.deps)).rejects.toThrow() + + expect(h.serviceLog.warn.mock.calls.length).toBeLessThanOrEqual(2) + expect(JSON.stringify(h.serviceLog.warn.mock.calls)).not.toContain(BODY) + }) + + it("are logged on the inline path, which stays best-effort for the sender", async () => { + const h = harness({ broken: [A, B] }) + const notify = makeRoomFanoutNotifier(ROOM_FANOUT_SPEC.group, h.deps) + + await expect(notify(ROOM, message())).resolves.toBeUndefined() + + expect(h.fanoutLog.error).toHaveBeenCalledWith( + expect.objectContaining({ kind: "group" }), + expect.stringMatching(/fan-out failed/), + ) + }) +}) + +describe("room fan-out lookup fallbacks", () => { + it("log a failed mute or block batch lookup before falling back", async () => { + const h = harness() + const notify = makeRoomFanoutNotifier(ROOM_FANOUT_SPEC.group, { + ...h.deps, + mutedUserIdsFor: () => Promise.reject(new Error("mutes down")), + blockedIdsFor: () => Promise.resolve(new Set()), + }) + + await notify(ROOM, message()) + + expect(h.fanoutLog.warn).toHaveBeenCalledWith( + expect.objectContaining({ kind: "group" }), + expect.stringMatching(/mute lookup failed/), + ) + }) + + it("summarize per-recipient mute lookup failures in one line and still notify everyone", async () => { + const h = harness({ members: [A, B, C] }) + + await runRoomFanout( + ROOM_FANOUT_SPEC.group, + { + ...h.deps, + isMuted: () => Promise.reject(new Error("mutes down")), + }, + ROOM, + message(), + ) + + const lines = h.fanoutLog.warn.mock.calls.filter(([, msg]) => /mute lookup/.test(String(msg))) + expect(lines).toHaveLength(1) + expect(lines[0]?.[0]).toMatchObject({ kind: "group", failed: 3, candidates: 3 }) + expect(bells(h.repo, A)).toHaveLength(1) + expect(bells(h.repo, C)).toHaveLength(1) + }) + + it("summarize per-recipient block lookup failures in one line and skip those recipients", async () => { + const h = harness({ members: [A, B, C] }) + + await runRoomFanout( + ROOM_FANOUT_SPEC.group, + { + ...h.deps, + isBlockedEitherWay: (_actor, id) => + id === C ? Promise.resolve(false) : Promise.reject(new Error("blocks down")), + }, + ROOM, + message(), + ) + + const lines = h.fanoutLog.warn.mock.calls.filter(([, msg]) => /block lookup/.test(String(msg))) + expect(lines).toHaveLength(1) + expect(lines[0]?.[0]).toMatchObject({ kind: "group", failed: 2, candidates: 3 }) + expect(bells(h.repo, A)).toHaveLength(0) + expect(bells(h.repo, C)).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/chat-room-notifier-wiring.test.ts b/services/api/test/unit/chat-room-notifier-wiring.test.ts index 6e02287a..a3b82976 100644 --- a/services/api/test/unit/chat-room-notifier-wiring.test.ts +++ b/services/api/test/unit/chat-room-notifier-wiring.test.ts @@ -78,7 +78,10 @@ describe("makeContainerRoomFanoutDeps", () => { it("caps the report fan-out member scan at REPORT_CHAT_FANOUT_MEMBER_CAP", async () => { const h = harness() - await makeContainerRoomFanoutDeps(h.container).report.listMemberIds("report-1") + await makeContainerRoomFanoutDeps(h.container).report.listMemberIds( + "report-1", + REPORT_CHAT_FANOUT_MEMBER_CAP, + ) const stmt = h.fake.statements[0]! expect(stmt.sql).toMatch(/FROM report_chat_members/) @@ -99,7 +102,7 @@ describe("makeContainerRoomFanoutDeps", () => { expect(groupMute!.values).toContain("group") }) - it("treats a failed mute lookup as NOT muted so a Redis/DB blip cannot silence the room", async () => { + it("hands the fan-out a mute lookup that surfaces a failure, so the fan-out fails open once per fan-out", async () => { const h = harness() const boom = makeFakeSql() const throwing = { @@ -116,7 +119,7 @@ describe("makeContainerRoomFanoutDeps", () => { const deps = makeContainerRoomFanoutDeps(throwing) - await expect(deps.report.isMuted("u1", "r1")).resolves.toBe(false) + await expect(deps.report.isMuted("u1", "r1")).rejects.toThrow("db down") }) }) diff --git a/services/api/test/unit/city-contact-readers-bounced.test.ts b/services/api/test/unit/city-contact-readers-bounced.test.ts new file mode 100644 index 00000000..a139c3d8 --- /dev/null +++ b/services/api/test/unit/city-contact-readers-bounced.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleDiscussionRepository } from "../../src/services/discussion-repository.drizzle.js" +import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" + +const REPORT_ID = "4c1f7d2e-8b3a-4f5e-9a6b-7c8d9e0f1a2b" +const GEOID = "0644000" +const LEGACY_BOUNCE_GUARD = + /me\.type = 'bounced' AND lower\(me\.meta->>'failedRecipient'\) = lower\([a-z_]+\.v\)/ +const USABLE_ROW_GUARD = + /jc\.email IS NOT NULL AND btrim\(jc\.email\) <> '' AND jc\.bounced_at IS NULL/g + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ") +} + +describe("report chat @city forward skips bounced contacts", () => { + it("findReportForDiscussion filters bounced per-category and legacy contacts", async () => { + const fake = makeFakeSql() + await makeDrizzleDiscussionRepository(fake.sql as unknown as Sql).findReportForDiscussion( + REPORT_ID, + ) + const text = flat(fake.statements[0]?.sql ?? "") + expect(text).not.toMatch(/contact_emails\[1\]/) + expect(text.match(USABLE_ROW_GUARD)).toHaveLength(2) + expect(text).toMatch(LEGACY_BOUNCE_GUARD) + expect(text).toMatch(/WITH ORDINALITY/) + }) + + it("findReportForDiscussion forwards to the usable legacy address the query returns", async () => { + const fake = makeFakeSql([ + { + match: /FROM reports r/, + rows: [ + { + id: REPORT_ID, + reporter_user_id: null, + status: "published", + visibility: "public", + deleted_at: null, + category: "trash", + place: null, + geoid: GEOID, + j_name: "Los Angeles", + j_handle: null, + cat_email: null, + default_email: null, + legacy_email: "second@lacity.gov", + }, + ], + }, + ]) + const view = await makeDrizzleDiscussionRepository( + fake.sql as unknown as Sql, + ).findReportForDiscussion(REPORT_ID) + expect(view?.jurisdiction?.contactEmail).toBe("second@lacity.gov") + }) +}) + +describe("cleanup jurisdiction contact skips bounced contacts", () => { + it("resolveJurisdictionContact filters bounced default and legacy contacts", async () => { + const fake = makeFakeSql() + await makeDrizzleCleanupRepository(fake.sql as unknown as Sql).resolveJurisdictionContact(GEOID) + const text = flat(fake.statements[0]?.sql ?? "") + expect(text).not.toMatch(/contact_emails\[1\]/) + expect(text.match(USABLE_ROW_GUARD)).toHaveLength(1) + expect(text).toMatch(LEGACY_BOUNCE_GUARD) + expect(text).toMatch(/WITH ORDINALITY/) + }) + + it("resolveJurisdictionContact returns null when no contact is usable", async () => { + const fake = makeFakeSql([ + { + match: /FROM jurisdictions j/, + rows: [{ name: "Los Angeles", default_email: null, legacy_email: null }], + }, + ]) + const contact = await makeDrizzleCleanupRepository( + fake.sql as unknown as Sql, + ).resolveJurisdictionContact(GEOID) + expect(contact).toBeNull() + }) + + it("resolveJurisdictionContact uses the usable legacy address the query returns", async () => { + const fake = makeFakeSql([ + { + match: /FROM jurisdictions j/, + rows: [{ name: "Los Angeles", default_email: null, legacy_email: "second@lacity.gov" }], + }, + ]) + const contact = await makeDrizzleCleanupRepository( + fake.sql as unknown as Sql, + ).resolveJurisdictionContact(GEOID) + expect(contact).toEqual({ contact: "second@lacity.gov", name: "Los Angeles" }) + }) +}) diff --git a/services/api/test/unit/city-forward-logger-wiring.test.ts b/services/api/test/unit/city-forward-logger-wiring.test.ts new file mode 100644 index 00000000..003b095e --- /dev/null +++ b/services/api/test/unit/city-forward-logger-wiring.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { FakePushSender } from "@civfix/shared/fakes" +import type { ChatMessageDTO } from "@civfix/shared" +import type { FastifyBaseLogger, FastifyInstance } from "fastify" +import type { Container } from "../../src/di.js" +import type { OnReportMessage } from "../../src/ws/types.js" +import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" +import type { DiscussionReportView } from "../../src/services/discussion-types.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" +import { InMemoryChatReadState } from "../../src/services/threads-service.js" +import { makeNotificationService } from "../../src/services/notification-service.js" +import { InMemoryNotificationRepository } from "../helpers/notifications.js" +import { InMemoryThreadsRepository } from "../helpers/chat.js" +import { + InMemoryBlocksRepository, + InMemoryDmRepository, +} from "../../src/services/dm-repository.memory.js" + +const { captured, mail } = vi.hoisted(() => ({ + captured: {} as { onReportMessage?: OnReportMessage }, + mail: { sendFails: false }, +})) + +vi.mock("../../src/ws/gateway.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + registerChatGateway: (_app: unknown, opts: { onReportMessage?: OnReportMessage }) => { + captured.onReportMessage = opts.onReportMessage + }, + } +}) + +vi.mock("../../src/services/discussion-repository.drizzle.js", () => ({ + makeDrizzleDiscussionRepository: () => ({ + findReportForDiscussion: (reportId: string): Promise => + Promise.resolve({ + id: reportId, + reporterUserId: null, + status: "published", + visibility: "public", + deletedAt: null, + category: "graffiti", + place: "San Francisco", + jurisdiction: { + geoid: "0600001", + name: "City of San Francisco", + handle: "sf", + contactEmail: "fix@sf.gov", + }, + }), + }), +})) + +vi.mock("../../src/services/admin/outbound-mail-service.js", async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + makeOutboundMailService: () => ({ + findReportThread: () => + Promise.resolve({ id: "thread-1", subject: "[civfix] Tag - SF - ABC123" }), + appendOutbound: () => + mail.sendFails ? Promise.reject(new Error("smtp down")) : Promise.resolve({}), + }), + } +}) + +vi.mock("../../src/services/report-forward-audit.drizzle.js", () => ({ + makeReportForwardAudit: () => ({ + recordMention: () => Promise.resolve(), + markForwarded: () => Promise.resolve(), + }), +})) + +vi.mock("../../src/services/notification-repository.drizzle.js", () => ({ + makeDrizzleNotificationRepository: () => new InMemoryNotificationRepository(), +})) + +const { wireChatGateway } = await import("../../src/routes/chat-gateway-wiring.js") +const { makeContainerReportChatSendDeps } = + await import("../../src/services/report-chat-send-wiring.js") + +const REPORT = "00000001-0000-0000-0000-000000000000" +const ACTOR = "11111111-1111-1111-1111-111111111111" + +const MESSAGE = { + id: "00000001-dddd-dddd-dddd-dddddddddddd", + body: "please fix this @sf", + createdAt: new Date("2026-07-09T12:00:00.000Z").toISOString(), +} as unknown as ChatMessageDTO + +type WarnSpy = ReturnType + +function spyLogger(): { warn: WarnSpy; error: WarnSpy; info: WarnSpy; debug: WarnSpy } { + return { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() } +} + +function reportChatStub(): ReportChatRepository { + const notImpl = (name: string) => () => { + throw new Error(`fake reportChat.${name} not implemented`) + } + return { + isMember: () => Promise.resolve(true), + roleOf: notImpl("roleOf") as never, + advanceReadWatermark: () => Promise.resolve(), + markRead: notImpl("markRead") as never, + join: notImpl("join") as never, + leave: notImpl("leave") as never, + insertSystemMessage: notImpl("insertSystemMessage") as never, + listMemberIds: () => Promise.resolve([]), + countMembers: notImpl("countMembers") as never, + listMembers: notImpl("listMembers") as never, + } +} + +function containerWith(counters: InMemoryCounterStore): Container { + return { + env: { + USE_FAKE_CHAT: false, + WEB_ORIGINS: [], + MAIL_FROM_OUTREACH: "a@b", + MAIL_REPLY_DOMAIN: "b", + REPORT_AUTOFORWARD_ENABLED: false, + }, + storage: { presignGet: () => Promise.resolve("") }, + mailer: {}, + chatService: { broadcast: () => Promise.resolve() }, + userChannel: undefined, + pushSender: new FakePushSender(), + getDb: () => ({ sql: {} }), + getCounterStore: () => counters, + getBlocksRepo: () => new InMemoryBlocksRepository(), + } as unknown as Container +} + +function wireGateway(counters: InMemoryCounterStore, log: ReturnType) { + const blocks = new InMemoryBlocksRepository() + const app = { + chatOverrides: { + isMember: () => Promise.resolve(true), + threadsRepo: new InMemoryThreadsRepository(), + readState: new InMemoryChatReadState(), + presence: new InMemoryChatPresence(), + dmRepo: new InMemoryDmRepository((a, b) => blocks.isBlockedEitherWay(a, b)), + blocksRepo: blocks, + notificationService: makeNotificationService({ + repo: new InMemoryNotificationRepository(), + pushSender: new FakePushSender(), + }), + reportChat: reportChatStub(), + conversationMutes: { isMuted: () => Promise.resolve(false) }, + }, + log, + } as unknown as FastifyInstance + + wireChatGateway(app, containerWith(counters)) + expect(captured.onReportMessage).toBeDefined() + return captured.onReportMessage! +} + +function warnedWith(log: ReturnType, msg: string): unknown[] { + return log.warn.mock.calls.filter((call) => call[1] === msg).map((call) => call[0]) +} + +describe("the @city forwarder logs through the logger of the wiring that built it", () => { + beforeEach(() => { + captured.onReportMessage = undefined + mail.sendFails = false + }) + + it("the chat gateway hands the server logger to the forwarder", async () => { + const log = spyLogger() + mail.sendFails = true + const onReportMessage = wireGateway(new InMemoryCounterStore(), log) + + await onReportMessage(REPORT, MESSAGE, ACTOR) + + expect(warnedWith(log, "city forward send failed")).toEqual([ + expect.objectContaining({ reportId: REPORT, geoid: "0600001" }), + ]) + }) + + it("the chat gateway's forward throttle logs a counter outage through the server logger", async () => { + const log = spyLogger() + const broken = new InMemoryCounterStore() + vi.spyOn(broken, "incr").mockRejectedValue(new Error("redis down")) + const onReportMessage = wireGateway(broken, log) + + await onReportMessage(REPORT, MESSAGE, ACTOR) + + expect(warnedWith(log, "city forward throttle unavailable; forward skipped")).toEqual([ + expect.objectContaining({ reportId: REPORT, geoid: "0600001" }), + ]) + }) + + it("the admin report-chat send wiring hands its logger to the forwarder", async () => { + const log = spyLogger() + mail.sendFails = true + const deps = makeContainerReportChatSendDeps(containerWith(new InMemoryCounterStore()), { + chatRepo: () => { + throw new Error("persist is not exercised here") + }, + logger: log as unknown as FastifyBaseLogger, + }) + + await deps.forwardCityMention!(REPORT, MESSAGE, ACTOR) + + expect(warnedWith(log, "city forward send failed")).toEqual([ + expect.objectContaining({ reportId: REPORT, geoid: "0600001" }), + ]) + }) +}) diff --git a/services/api/test/unit/city-forward-logging.test.ts b/services/api/test/unit/city-forward-logging.test.ts new file mode 100644 index 00000000..af77598f --- /dev/null +++ b/services/api/test/unit/city-forward-logging.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest" +import { + forwardReportCityMention, + makeCityForwardThrottle, +} from "../../src/services/report-city-forward.js" +import type { CounterStore } from "../../src/abuse/counter-store.js" +import type { ReportForwardAudit } from "../../src/services/report-forward-audit.drizzle.js" +import type { OutboundMailService } from "../../src/services/admin/outbound-mail-service.js" +import type { MailThreadRecord } from "../../src/services/admin/mail-repository.drizzle.js" + +const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const CREATED = new Date("2026-07-09T12:00:00.000Z") +const SF = { + geoid: "0600001", + name: "City of San Francisco", + handle: "sf", + contactEmail: "fix@sf.gov", +} +const ctx = { + reportId: REPORT, + category: "graffiti", + place: "SF", + jurisdiction: SF, + actorUserId: "actor-1", +} + +const THREAD = { id: "thread-1", subject: "[civfix] Tag - SF - ABC123" } as MailThreadRecord + +function mail(over: Partial): OutboundMailService { + return { + findReportThread: () => Promise.resolve(THREAD), + appendOutbound: () => Promise.resolve(THREAD), + ...over, + } as OutboundMailService +} + +function capture() { + const lines: { obj: unknown; msg?: string }[] = [] + return { lines, logger: { warn: (obj: unknown, msg?: string) => lines.push({ obj, msg }) } } +} + +describe("city forward failures are logged, not swallowed", () => { + it("logs a failed forward send and reports it as not forwarded", async () => { + const { lines, logger } = capture() + const res = await forwardReportCityMention( + mail({ appendOutbound: () => Promise.reject(new Error("smtp down")) }), + ctx, + "@sf please fix", + CREATED, + { logger }, + ) + + expect(res).toMatchObject({ mentioned: true, forwarded: false }) + expect(lines).toHaveLength(1) + expect(lines[0]!.obj).toMatchObject({ reportId: REPORT, geoid: SF.geoid }) + expect(String((lines[0]!.obj as { err: unknown }).err)).toMatch(/smtp down/) + }) + + it("logs a failed report-thread lookup", async () => { + const { lines, logger } = capture() + const res = await forwardReportCityMention( + mail({ findReportThread: () => Promise.reject(new Error("db down")) }), + ctx, + "@sf please fix", + CREATED, + { logger }, + ) + + expect(res).toMatchObject({ mentioned: true, forwarded: false }) + expect(lines).toHaveLength(1) + expect(lines[0]!.obj).toMatchObject({ reportId: REPORT }) + }) + + it("logs a failed audit write without failing the forward", async () => { + const { lines, logger } = capture() + const audit: ReportForwardAudit = { + recordMention: () => Promise.reject(new Error("audit down")), + markForwarded: () => Promise.resolve(), + } as unknown as ReportForwardAudit + const res = await forwardReportCityMention(mail({}), ctx, "@sf please fix", CREATED, { + logger, + audit, + messageId: "msg-1", + }) + + expect(res.forwarded).toBe(true) + expect(lines).toHaveLength(1) + expect(lines[0]!.obj).toMatchObject({ messageId: "msg-1", geoid: SF.geoid }) + }) + + it("the throttle still fails closed on a counter outage, and says so", async () => { + const { lines, logger } = capture() + const broken = { + incr: () => Promise.reject(new Error("redis down")), + } as unknown as CounterStore + + const allowed = await makeCityForwardThrottle(broken, logger)(REPORT, SF.geoid, "actor-1") + + expect(allowed).toBe(false) + expect(lines).toHaveLength(1) + expect(lines[0]!.obj).toMatchObject({ reportId: REPORT, geoid: SF.geoid }) + }) +}) diff --git a/services/api/test/unit/claim-hold-release-log.test.ts b/services/api/test/unit/claim-hold-release-log.test.ts new file mode 100644 index 00000000..8d984972 --- /dev/null +++ b/services/api/test/unit/claim-hold-release-log.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest" +import type { ReportDTO } from "@civfix/shared" +import { sha256Hex } from "../../src/auth/crypto.js" +import { makeClaimService } from "../../src/services/claim-service.js" +import { InMemoryAnonStore } from "../helpers/anon.js" + +describe("claimReport when the hold-release enqueue fails", () => { + it("still returns the claimed report and logs the failure with the report id", async () => { + const store = new InMemoryAnonStore() + const token = store.seedToken({ id: "tok-1" }) + store.seedReport({ + id: "rep-1", + anonSessionId: token.id, + reporterUserId: null, + status: "held", + claimCodeHash: await sha256Hex("claim-xyz"), + }) + const warnings: { obj: unknown; msg: string | undefined }[] = [] + const queueDown = new Error("queue down") + const service = makeClaimService({ + repo: store.claimRepo(), + anonTokenSigningKey: "test-anon-signing-key", + getReportForOwner: (reportId) => Promise.resolve({ id: reportId } as ReportDTO), + enqueueHoldRelease: () => Promise.reject(queueDown), + logger: { warn: (obj, msg) => warnings.push({ obj, msg }) }, + }) + + const result = await service.claimReport("claim-xyz", "user-1") + + expect(result.report.id).toBe("rep-1") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.obj).toEqual({ err: queueDown, reportId: "rep-1" }) + }) +}) diff --git a/services/api/test/unit/cleanup-joined-flag.test.ts b/services/api/test/unit/cleanup-joined-flag.test.ts new file mode 100644 index 00000000..6c04810c --- /dev/null +++ b/services/api/test/unit/cleanup-joined-flag.test.ts @@ -0,0 +1,66 @@ +import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" +import { beforeEach, describe, expect, it } from "vitest" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { makeCleanupService, type CleanupService } from "../../src/services/cleanup-service.js" + +const ORGANIZER = "11111111-1111-4111-8111-111111111111" +const ORG_OWNER = "22222222-2222-4222-8222-222222222222" + +const DAY_MS = 24 * 60 * 60 * 1000 + +let repo: InMemoryCleanupRepository +let service: CleanupService +let eventId: string + +beforeEach(() => { + repo = new InMemoryCleanupRepository() + repo.seedUser({ id: ORGANIZER, displayName: "Olive Organizer", handle: "olive" }) + repo.seedUser({ id: ORG_OWNER, displayName: "Owen Owner", handle: "owen" }) + const org = repo.seedOrganization({ slug: "bct", name: "Ballona Creek Trust" }) + repo.seedOrgMember(org.id, ORGANIZER, "member") + repo.seedOrgMember(org.id, ORG_OWNER, "owner") + eventId = repo.seedCleanup({ + organizerUserId: ORGANIZER, + organizationId: org.id, + title: "Creek sweep", + scheduledAt: new Date(Date.now() + DAY_MS), + }).id + service = makeCleanupService({ + tickets: TEST_TICKET_SIGNER, + repo, + counters: new InMemoryCounterStore(), + }) +}) + +describe("joined means the viewer RSVP'd, never that they can manage the event", () => { + it("is false on the detail for an org owner who never RSVP'd, while they keep host powers", async () => { + const detail = await service.getCleanup(eventId, { userId: ORG_OWNER }) + expect(detail.joined).toBe(false) + expect(detail.myCapabilities).toContain("manage_event") + }) + + it("is false on the map list and the organization list for that org owner", async () => { + const listed = await service.listCleanups({ when: "upcoming" } as never, { userId: ORG_OWNER }) + expect(listed.items.find((i) => i.id === eventId)?.joined).toBe(false) + + const org = await service.listOrganizationEvents( + "bct", + { userId: ORG_OWNER }, + { when: "upcoming", cursor: null, limit: 20 }, + ) + expect(org.items.find((i) => i.id === eventId)?.joined).toBe(false) + }) + + it("is false on the cancel response when an org owner cancels an event they did not RSVP to", async () => { + const cancelled = await service.cancelCleanup(eventId, null, ORG_OWNER) + expect(cancelled.joined).toBe(false) + }) + + it("stays true for the organizer, who is on the roster", async () => { + const detail = await service.getCleanup(eventId, { userId: ORGANIZER }) + expect(detail.joined).toBe(true) + const cancelled = await service.cancelCleanup(eventId, null, ORGANIZER) + expect(cancelled.joined).toBe(true) + }) +}) diff --git a/services/api/test/unit/cleanup-service-security.test.ts b/services/api/test/unit/cleanup-service-security.test.ts index e882a2fe..b5afb599 100644 --- a/services/api/test/unit/cleanup-service-security.test.ts +++ b/services/api/test/unit/cleanup-service-security.test.ts @@ -58,7 +58,11 @@ function harness() { const creator = makeCleanupService({ tickets: TEST_TICKET_SIGNER, repo, - counters: { incr: () => Promise.resolve(1), incrBy: () => Promise.resolve(1) }, + counters: { + incr: () => Promise.resolve(1), + incrBy: () => Promise.resolve(1), + decrBy: () => Promise.resolve(0), + }, }) async function eventHostedBy(hostId: string): Promise { diff --git a/services/api/test/unit/cleanup-service.test.ts b/services/api/test/unit/cleanup-service.test.ts index 84166834..909c0b4c 100644 --- a/services/api/test/unit/cleanup-service.test.ts +++ b/services/api/test/unit/cleanup-service.test.ts @@ -826,7 +826,11 @@ describe("requestResources (D19 event resource request)", () => { const creator = makeCleanupService({ tickets: TEST_TICKET_SIGNER, repo: r, - counters: { incr: () => Promise.resolve(1), incrBy: () => Promise.resolve(1) }, + counters: { + incr: () => Promise.resolve(1), + incrBy: () => Promise.resolve(1), + decrBy: () => Promise.resolve(0), + }, }) return { repo: r, svc, sends, creator, organizationId: organization.id } } diff --git a/services/api/test/unit/cleanup-update-atomic.test.ts b/services/api/test/unit/cleanup-update-atomic.test.ts new file mode 100644 index 00000000..d4830b8f --- /dev/null +++ b/services/api/test/unit/cleanup-update-atomic.test.ts @@ -0,0 +1,243 @@ +import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" +import { beforeEach, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { AppError } from "@civfix/shared" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import type { Sql } from "../../src/db/client.js" +import { + MAX_EVENTS_PER_REPORT, + makeDrizzleCleanupRepository, +} from "../../src/services/cleanup-repository.drizzle.js" +import type { DesiredSlot } from "../../src/services/cleanup-repository.types.js" +import { makeCleanupService, type CleanupService } from "../../src/services/cleanup-service.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" + +const ORG = "11111111-1111-1111-1111-111111111111" +const REPORT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +const DAY_MS = 86_400_000 + +let repo: InMemoryCleanupRepository +let service: CleanupService +let eventId: string + +function saturateReport(reportId: string): void { + for (let i = 0; i < MAX_EVENTS_PER_REPORT; i += 1) repo.seedLink(randomUUID(), reportId) +} + +beforeEach(async () => { + repo = new InMemoryCleanupRepository() + repo.seedUser({ id: ORG, displayName: "Olive Organizer", handle: "olive" }) + repo.seedReport({ id: REPORT, title: "Bin 1" }) + service = makeCleanupService({ + tickets: TEST_TICKET_SIGNER, + repo, + counters: new InMemoryCounterStore(), + }) + const created = await service.createCleanup( + { + title: "Beach cleanup", + type: "site", + eventKind: "cleanup", + lat: 34.0, + lng: -118.49, + scheduledAt: new Date(Date.now() + DAY_MS).toISOString(), + slots: [{ title: "Volunteers" }], + }, + ORG, + ) + eventId = created.id +}) + +describe("an event edit lands whole or not at all", () => { + it("keeps the old title when the link cap refuses the same save", async () => { + saturateReport(REPORT) + + await expect( + service.updateCleanup(eventId, { title: "Renamed", linkedReportIds: [REPORT] }, ORG), + ).rejects.toMatchObject({ httpStatus: 422, fields: { linkedReportIds: expect.any(String) } }) + + expect(repo.cleanups.get(eventId)?.title).toBe("Beach cleanup") + expect(repo.links.filter((l) => l.cleanupId === eventId)).toHaveLength(0) + }) + + it("rolls the title and the new links back when the slot board is refused last", async () => { + repo.reconcileSlots = () => { + throw AppError.validation({ slots: "duplicate slot title" }) + } + + await expect( + service.updateCleanup( + eventId, + { title: "Renamed", linkedReportIds: [REPORT], slots: [{ title: "Grill" }] }, + ORG, + ), + ).rejects.toMatchObject({ httpStatus: 422 }) + + expect(repo.cleanups.get(eventId)?.title).toBe("Beach cleanup") + expect(repo.links.filter((l) => l.cleanupId === eventId)).toHaveLength(0) + expect(repo.timeline.filter((t) => t.kind === "report_linked")).toHaveLength(0) + }) + + it("refuses an edit to an event cancelled after the service read it", async () => { + const read = repo.findCleanupById.bind(repo) + repo.findCleanupById = async (id, near) => { + const record = await read(id, near) + const stored = repo.cleanups.get(id) + if (stored) stored.status = "cancelled" + return record + } + + await expect(service.updateCleanup(eventId, { title: "Renamed" }, ORG)).rejects.toMatchObject({ + httpStatus: 409, + }) + expect(repo.cleanups.get(eventId)?.title).toBe("Beach cleanup") + }) + + it("refuses a frozen field once the event has ended between the read and the write", async () => { + const read = repo.findCleanupById.bind(repo) + repo.findCleanupById = async (id, near) => { + const record = await read(id, near) + const stored = repo.cleanups.get(id) + if (stored) { + stored.scheduledAt = new Date(Date.now() - 2 * DAY_MS) + stored.endsAt = new Date(Date.now() - DAY_MS) + } + return record + } + + await expect(service.updateCleanup(eventId, { title: "Renamed" }, ORG)).rejects.toMatchObject({ + httpStatus: 409, + }) + expect(repo.cleanups.get(eventId)?.title).toBe("Beach cleanup") + }) + + it("still saves a description on an event that ended meanwhile", async () => { + const read = repo.findCleanupById.bind(repo) + let reads = 0 + repo.findCleanupById = async (id, near) => { + const record = await read(id, near) + reads += 1 + const stored = repo.cleanups.get(id) + if (stored && reads === 1) { + stored.scheduledAt = new Date(Date.now() - 2 * DAY_MS) + stored.endsAt = new Date(Date.now() - DAY_MS) + } + return record + } + + const dto = await service.updateCleanup(eventId, { description: "Bring gloves" }, ORG) + + expect(dto.description).toBe("Bring gloves") + }) +}) + +describe("the Postgres edit runs as one transaction under the event row lock", () => { + const CLEANUP_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + const SLOT_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + const LOCK = /FROM cleanups\s+WHERE id = \? LIMIT 1 FOR NO KEY UPDATE/ + const SLOT_STATEMENT = /cleanup_slots/ + + const slot: DesiredSlot = { + id: SLOT_ID, + title: "Volunteers", + description: null, + capacity: null, + startsAt: null, + endsAt: null, + sortOrder: 0, + } + + function editSql(extra: SqlHandler[] = []): { fake: FakeSqlControl; begins: () => number } { + const fake = makeFakeSql([ + ...extra, + { + match: LOCK, + rows: [ + { + status: "upcoming", + scheduled_at: new Date(Date.now() + DAY_MS), + ends_at: new Date(Date.now() + DAY_MS + 3_600_000), + now: new Date(), + }, + ], + }, + { match: /UPDATE cleanups SET/, rows: [{ id: CLEANUP_ID }] }, + { + match: /SELECT id, title, starts_at, ends_at FROM cleanup_slots/, + rows: [{ id: SLOT_ID, title: "Volunteers", starts_at: null, ends_at: null }], + }, + ]) + let opened = 0 + const begin = fake.sql.begin + fake.sql.begin = (cb: (tx: typeof fake.sql) => Promise): Promise => { + opened += 1 + return begin(cb) + } + return { fake, begins: () => opened } + } + + it("locks the event first and runs every statement inside one begin", async () => { + const { fake, begins } = editSql() + + const outcome = await makeDrizzleCleanupRepository( + fake.sql as unknown as Sql, + ).updateCleanupWithEdits( + CLEANUP_ID, + { title: "Renamed" }, + { actorUserId: ORG, links: [REPORT], slots: [slot], refusalOnceEnded: null }, + ) + + expect(outcome.kind).toBe("updated") + expect(begins()).toBe(1) + expect(fake.statements[0]?.sql).toMatch(LOCK) + const texts = fake.statements.map((s) => s.sql) + const firstSlot = texts.findIndex((t) => SLOT_STATEMENT.test(t)) + expect(texts.findIndex((t) => /UPDATE cleanups SET/.test(t))).toBeGreaterThan(0) + expect(texts.findIndex((t) => /INSERT INTO cleanup_reports/.test(t))).toBeLessThan(firstSlot) + }) + + it("stops before the slots when the link cap refuses, so the rollback covers the title", async () => { + const { fake, begins } = editSql([ + { match: /HAVING count\(\*\) >=/, rows: [{ report_id: REPORT }] }, + ]) + + await expect( + makeDrizzleCleanupRepository(fake.sql as unknown as Sql).updateCleanupWithEdits( + CLEANUP_ID, + { title: "Renamed" }, + { actorUserId: ORG, links: [REPORT], slots: [slot], refusalOnceEnded: null }, + ), + ).rejects.toMatchObject({ httpStatus: 422 }) + + expect(begins()).toBe(1) + expect(fake.statements.some((s) => SLOT_STATEMENT.test(s.sql))).toBe(false) + }) + + it("throws the caller's refusal when the locked row shows the event has ended", async () => { + const refusal = AppError.conflict("ended") + const { fake } = editSql([ + { + match: LOCK, + rows: [ + { + status: "upcoming", + scheduled_at: new Date(Date.now() - 2 * DAY_MS), + ends_at: new Date(Date.now() - DAY_MS), + now: new Date(), + }, + ], + }, + ]) + + await expect( + makeDrizzleCleanupRepository(fake.sql as unknown as Sql).updateCleanupWithEdits( + CLEANUP_ID, + { title: "Renamed" }, + { actorUserId: ORG, links: null, slots: null, refusalOnceEnded: refusal }, + ), + ).rejects.toBe(refusal) + + expect(fake.statements.some((s) => /UPDATE cleanups SET/.test(s.sql))).toBe(false) + }) +}) diff --git a/services/api/test/unit/cleanup-update-location.test.ts b/services/api/test/unit/cleanup-update-location.test.ts new file mode 100644 index 00000000..1e5fb9fe --- /dev/null +++ b/services/api/test/unit/cleanup-update-location.test.ts @@ -0,0 +1,59 @@ +import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" +import { beforeEach, describe, expect, it } from "vitest" +import { FakeJobs } from "@civfix/shared/fakes" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { makeCleanupService, type CleanupService } from "../../src/services/cleanup-service.js" +import { CLEANUP_GUEST_UPDATE_FANOUT_JOB } from "../../src/services/guest-rsvp-service.js" + +const ORG = "11111111-1111-1111-1111-111111111111" + +let repo: InMemoryCleanupRepository +let jobs: FakeJobs +let service: CleanupService +let eventId: string + +beforeEach(async () => { + repo = new InMemoryCleanupRepository() + repo.seedUser({ id: ORG, displayName: "Olive Organizer", handle: "olive" }) + jobs = new FakeJobs() + service = makeCleanupService({ + tickets: TEST_TICKET_SIGNER, + repo, + counters: new InMemoryCounterStore(), + jobs, + }) + const created = await service.createCleanup( + { + title: "Beach cleanup", + type: "site", + eventKind: "cleanup", + lat: 34.0, + lng: -118.49, + scheduledAt: new Date(Date.now() + 86_400_000).toISOString(), + slots: [{ title: "Volunteers" }], + }, + ORG, + ) + eventId = created.id +}) + +describe("a PATCH carrying only one coordinate does not move the event", () => { + it("leaves the pin where it was and tells no guest about a move that never happened", async () => { + await service.updateCleanup(eventId, { lat: 35.5 }, ORG) + + const stored = await repo.findCleanupById(eventId, null) + expect(stored?.lat).toBe(34.0) + expect(stored?.lng).toBe(-118.49) + expect(jobs.jobsFor(CLEANUP_GUEST_UPDATE_FANOUT_JOB)).toHaveLength(0) + }) + + it("still moves the event and notifies guests when both coordinates change", async () => { + await service.updateCleanup(eventId, { lat: 35.5, lng: -118.3 }, ORG) + + const stored = await repo.findCleanupById(eventId, null) + expect(stored?.lat).toBe(35.5) + expect(stored?.lng).toBe(-118.3) + expect(jobs.jobsFor(CLEANUP_GUEST_UPDATE_FANOUT_JOB)).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/cli-entry-guards.test.ts b/services/api/test/unit/cli-entry-guards.test.ts new file mode 100644 index 00000000..759d40c1 --- /dev/null +++ b/services/api/test/unit/cli-entry-guards.test.ts @@ -0,0 +1,63 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { dirname, join, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" + +// tsup bundles every CLI entry with splitting off, so an imported module's import.meta.url becomes +// the entry's own URL. A run-as-main guard in any module a CLI imports then fires inside that CLI and +// runs the wrong main. +const API_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..") +const SOURCE_DIRS = ["src", "scripts"] +const RELATIVE_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*["'](\.{1,2}\/[^"']+?)\.js["']/g +const RUN_AS_MAIN_GUARD = + /^runIfMain\(|fileURLToPath\(import\.meta\.url\)\s*===\s*process\.argv\[1\]/m + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + if (statSync(path).isDirectory()) return sourceFiles(path) + return path.endsWith(".ts") && !path.endsWith(".d.ts") ? [path] : [] + }) +} + +function relativeImports(file: string): string[] { + const source = readFileSync(file, "utf8") + return [...source.matchAll(RELATIVE_IMPORT)] + .map((match) => resolve(dirname(file), `${match[1]}.ts`)) + .filter((path) => existsSync(path)) +} + +function reachableFrom(entry: string): Set { + const seen = new Set() + const pending = relativeImports(entry) + while (pending.length > 0) { + const next = pending.pop()! + if (next === entry || seen.has(next)) continue + seen.add(next) + pending.push(...relativeImports(next)) + } + return seen +} + +const allFiles = SOURCE_DIRS.flatMap((dir) => sourceFiles(join(API_ROOT, dir))) +const guardedModules = allFiles.filter((file) => RUN_AS_MAIN_GUARD.test(readFileSync(file, "utf8"))) +const cliEntries = [ + ...new Set([...guardedModules, ...sourceFiles(join(API_ROOT, "scripts"))]), +].sort() + +describe("CLI entries import only guard-free modules", () => { + it("finds the seed-demo-la entry and its guarded siblings", () => { + expect(cliEntries).toContain(join(API_ROOT, "src/db/seed-demo-la.ts")) + expect(guardedModules).toContain(join(API_ROOT, "src/db/demo-join-event.ts")) + }) + + it.each(cliEntries.map((entry) => [relative(API_ROOT, entry), entry]))( + "%s reaches no other module with a run-as-main guard", + (_label, entry) => { + const offenders = [...reachableFrom(entry)] + .filter((file) => guardedModules.includes(file)) + .map((file) => relative(API_ROOT, file)) + expect(offenders).toEqual([]) + }, + ) +}) diff --git a/services/api/test/unit/counter-store-release.test.ts b/services/api/test/unit/counter-store-release.test.ts new file mode 100644 index 00000000..bda4bab8 --- /dev/null +++ b/services/api/test/unit/counter-store-release.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vitest" +import RedisMock from "ioredis-mock" +import type { RedisClient } from "../../src/adapters/redis.js" +import { + InMemoryCounterStore, + RedisCounterStore, + type CounterStore, +} from "../../src/abuse/counter-store.js" + +const WINDOW_SECONDS = 60 +const ELAPSED_WINDOW_MS = 3_000 + +function freshRedis(): RedisClient { + return new RedisMock() as unknown as RedisClient +} + +const stores: Array<[string, () => { store: CounterStore; redis?: RedisClient }]> = [ + ["in-memory", () => ({ store: new InMemoryCounterStore() })], + [ + "redis", + () => { + const redis = freshRedis() + return { store: new RedisCounterStore(redis), redis } + }, + ], +] + +describe.each(stores)("%s counter store gives back a charge", (_name, make) => { + beforeEach(async () => { + await freshRedis().flushall() + }) + + it("lowers a live count by the amount given back", async () => { + const { store } = make() + await store.incrBy("slots", 3, WINDOW_SECONDS) + await expect(store.decrBy("slots", 1)).resolves.toBe(2) + await expect(store.incr("slots", WINDOW_SECONDS)).resolves.toBe(3) + }) + + it("never counts below zero", async () => { + const { store } = make() + await store.incr("slots", WINDOW_SECONDS) + await expect(store.decrBy("slots", 5)).resolves.toBe(0) + await expect(store.incr("slots", WINDOW_SECONDS)).resolves.toBe(1) + }) + + it("leaves a missing key missing, so a give-back cannot open a window of its own", async () => { + const { store, redis } = make() + await expect(store.decrBy("absent", 1)).resolves.toBe(0) + if (redis !== undefined) expect(await redis.exists("absent")).toBe(0) + await expect(store.incr("absent", WINDOW_SECONDS)).resolves.toBe(1) + }) +}) + +describe("redis counter store give-back keeps the window", () => { + it("does not extend or restart the expiry of the key it lowers", async () => { + const redis = freshRedis() + const store = new RedisCounterStore(redis) + await store.incrBy("slots", 2, WINDOW_SECONDS) + await redis.pexpire("slots", ELAPSED_WINDOW_MS) + + await store.decrBy("slots", 1) + + const ttl = await redis.pttl("slots") + expect(ttl).toBeGreaterThan(0) + expect(ttl).toBeLessThanOrEqual(ELAPSED_WINDOW_MS) + }) +}) diff --git a/services/api/test/unit/data-export-correctness.test.ts b/services/api/test/unit/data-export-correctness.test.ts new file mode 100644 index 00000000..acfd283f --- /dev/null +++ b/services/api/test/unit/data-export-correctness.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from "vitest" +import { ErrorCode, MailSendError, type OutboundEmail, type SentMail } from "@civfix/shared" +import { FakeMailer } from "@civfix/shared/fakes" +import type { Sql } from "../../src/db/client.js" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" +import { InMemoryUserStore, type UserRecord } from "../../src/auth/stores.js" +import { + makeDataExportService, + DATA_EXPORT_FREE_TEXT_MAX_ROWS, + DATA_EXPORT_MAX_ROWS, +} from "../../src/services/data-export-service.js" +import type { JobHandler } from "@civfix/shared/interfaces" +import type { Container } from "../../src/di.js" +import { + DATA_EXPORT_JOB, + registerDataExportJobs, + runDataExport, +} from "../../src/services/data-export-jobs.js" + +const FROM = "no-reply@civfix.org" +const SUPPORT = "support@civfix.org" +const USER_ID = "11111111-1111-1111-1111-111111111111" +const EMAIL = "jane@example.com" +const AUDIT_ROW: SqlHandler = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } +const LARGE_BODY_BYTES = 2000 +const CERTIFICATE_ROW = { id: "cert-1", issuer: "y".repeat(LARGE_BODY_BYTES * 2) } + +const OVERSIZE = new MailSendError(ErrorCode.CONFLICT, "too large", { responseCode: 552 }) +const RECIPIENT_REJECTED = new MailSendError(ErrorCode.CONFLICT, "rejected", { + responseCode: 550, + response: "550 5.1.1 recipient rejected: no such user", +}) +const SENDER_REJECTED = new MailSendError(ErrorCode.CONFLICT, "sender not approved", { + responseCode: 550, + response: "550 sender address not approved", +}) +const MESSAGE_REJECTED = new MailSendError(ErrorCode.CONFLICT, "message refused", { + responseCode: 554, + command: "DATA", + code: "EMESSAGE", + response: "554 5.7.1 message content rejected by policy", +}) + +/** Fails every send that carries an attachment with `failure`; plain notices go through. */ +class AttachmentRejectingMailer extends FakeMailer { + constructor(private readonly failure: Error) { + super() + } + + override sendOutbound(email: OutboundEmail): Promise { + if ((email.attachments?.length ?? 0) > 0) return Promise.reject(this.failure) + return super.sendOutbound(email) + } +} + +function userRecord(): UserRecord { + return { + id: USER_ID, + role: "citizen", + displayName: "Jane Neighbor", + handle: "jane", + handleChangedAt: null, + email: EMAIL, + emailVerified: true, + primaryOrganizationId: null, + avatarUrl: null, + profileComplete: true, + allowDirectMessages: true, + showVolunteerHours: null, + locale: "en", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + deletedAt: null, + } +} + +function harness(mailer: FakeMailer, handlers: SqlHandler[] = []) { + const users = new InMemoryUserStore() + users.seed(EMAIL, userRecord()) + const ctl: FakeSqlControl = makeFakeSql([AUDIT_ROW, ...handlers]) + const service = makeDataExportService({ + sql: ctl.sql as unknown as Sql, + mailer, + users, + fromNoReply: FROM, + supportEmail: SUPPORT, + }) + return { ctl, service } +} + +function auditActions(ctl: FakeSqlControl): unknown[] { + return ctl.statements.filter((s) => /INSERT INTO audit_log/.test(s.sql)).map((s) => s.values[1]) +} + +function auditReasons(ctl: FakeSqlControl): unknown[] { + return ctl.statements + .filter((s) => /INSERT INTO audit_log/.test(s.sql)) + .map((s) => (s.values[3] as { reason?: unknown } | null)?.reason) +} + +describe("a data export the mail provider refuses is not dropped silently", () => { + it("oversize: tells the user where to get the export and leaves an operator record", async () => { + const mailer = new AttachmentRejectingMailer(OVERSIZE) + const { ctl, service } = harness(mailer) + await runDataExport(service, USER_ID) + const notice = mailer.lastOutbound() + expect(notice?.to).toBe(EMAIL) + expect(notice?.attachments ?? []).toHaveLength(0) + expect(notice?.text).toContain(SUPPORT) + expect(auditActions(ctl)).toEqual(["data_export.undeliverable"]) + }) + + it("recipient rejected: records it for an operator and sends nothing that would bounce again", async () => { + const mailer = new AttachmentRejectingMailer(RECIPIENT_REJECTED) + const { ctl, service } = harness(mailer) + await runDataExport(service, USER_ID) + expect(mailer.lastOutbound()).toBeUndefined() + expect(auditActions(ctl)).toEqual(["data_export.undeliverable"]) + }) + + it("sender rejected: a platform config fault, so the job retries", async () => { + const mailer = new AttachmentRejectingMailer(SENDER_REJECTED) + const { service } = harness(mailer) + await expect(runDataExport(service, USER_ID)).rejects.toBe(SENDER_REJECTED) + }) + + it("sender rejected on the last attempt: records the request instead of dropping it", async () => { + const mailer = new AttachmentRejectingMailer(SENDER_REJECTED) + const { ctl, service } = harness(mailer) + await expect( + runDataExport(service, USER_ID, undefined, { finalAttempt: true }), + ).resolves.toBeUndefined() + expect(auditActions(ctl)).toEqual(["data_export.undeliverable"]) + expect(auditReasons(ctl)).toEqual(["rejected"]) + expect(mailer.lastOutbound()).toBeUndefined() + }) + + it("a message the provider refuses at DATA is recorded at once, not rebuilt and resent", async () => { + const mailer = new AttachmentRejectingMailer(MESSAGE_REJECTED) + const { ctl, service } = harness(mailer) + await expect(runDataExport(service, USER_ID)).resolves.toBeUndefined() + expect(auditReasons(ctl)).toEqual(["rejected"]) + }) + + it("the job handler treats pg-boss's last retry as the final attempt", async () => { + const mailer = new AttachmentRejectingMailer(SENDER_REJECTED) + const { ctl, service } = harness(mailer) + const handlers = new Map() + const container = { + jobs: { + work: (name: string, handler: JobHandler) => { + handlers.set(name, handler) + return Promise.resolve() + }, + }, + } as unknown as Container + await registerDataExportJobs(container, { makeService: () => service }) + const handler = handlers.get(DATA_EXPORT_JOB)! + + await expect( + handler({ id: "job-1", data: { userId: USER_ID }, retryCount: 1, retryLimit: 10 } as never), + ).rejects.toBe(SENDER_REJECTED) + expect(auditActions(ctl)).toEqual([]) + + await expect( + handler({ id: "job-1", data: { userId: USER_ID }, retryCount: 10, retryLimit: 10 } as never), + ).resolves.toBeUndefined() + expect(auditReasons(ctl)).toEqual(["rejected"]) + }) +}) + +describe("the export byte budget favors the small structured sections", () => { + it("keeps a certificate even when chat messages alone exceed the budget", async () => { + const bigBody = "x".repeat(LARGE_BODY_BYTES) + const chatRows = Array.from({ length: DATA_EXPORT_FREE_TEXT_MAX_ROWS }, (_, i) => ({ + id: `c${i}`, + cleanup_id: null, + report_id: null, + group_id: null, + body: bigBody, + created_at: new Date("2026-01-01T00:00:00.000Z"), + deleted_at: null, + })) + const mailer = new FakeMailer() + const { service } = harness(mailer, [ + { match: /FROM chat_messages/, rows: chatRows }, + { match: /FROM service_hours_certificates/, rows: [CERTIFICATE_ROW] }, + ]) + await service.exportData(USER_ID) + const att = mailer.lastOutbound()!.attachments![0]! + const parsed = JSON.parse(new TextDecoder().decode(att.content)) as Record + expect(parsed.certificates).toEqual([CERTIFICATE_ROW]) + const truncated = parsed.truncated as { + sections: string[] + sectionCaps: Record + } + expect(truncated.sections).toEqual(["chatMessages"]) + expect(Object.keys(parsed).indexOf("chatMessages")).toBeLessThan( + Object.keys(parsed).indexOf("certificates"), + ) + }) + + it("reports each truncated section's own row cap", async () => { + const postRows = Array.from({ length: DATA_EXPORT_FREE_TEXT_MAX_ROWS + 1 }, (_, i) => ({ + id: `p${i}`, + })) + const mailer = new FakeMailer() + const { service } = harness(mailer, [{ match: /FROM posts/, rows: postRows }]) + await service.exportData(USER_ID) + const att = mailer.lastOutbound()!.attachments![0]! + const parsed = JSON.parse(new TextDecoder().decode(att.content)) as { + truncated: { capPerSection: number; sectionCaps: Record } + } + expect(parsed.truncated.sectionCaps).toEqual({ posts: DATA_EXPORT_FREE_TEXT_MAX_ROWS }) + expect(parsed.truncated.capPerSection).toBe(DATA_EXPORT_MAX_ROWS) + }) +}) diff --git a/services/api/test/unit/demo-signup-seats.test.ts b/services/api/test/unit/demo-signup-seats.test.ts new file mode 100644 index 00000000..3e36ae07 --- /dev/null +++ b/services/api/test/unit/demo-signup-seats.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { demoTicketTokenHasher, mintDemoSignupSeats } from "../../src/db/demo-signup-seats.js" +import type { Queryable } from "../../src/db/client.js" +import { DEVELOPMENT_TICKET_TOKEN_SECRET } from "../../src/env/registration-env.js" +import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" + +const EVENT_ID = "33333333-3333-3333-3333-333333333333" +const MEMBERS = [ + { user_id: "44444444-4444-4444-4444-444444444444", joined_at: new Date("2026-09-01T18:00:00Z") }, + { user_id: "55555555-5555-5555-5555-555555555555", joined_at: new Date("2026-09-02T18:00:00Z") }, +] + +describe("demo joins mint the free registration + seat the live join path mints", () => { + it("writes one registration and one hashed seat per joined member", async () => { + let registration = 0 + const fake = makeFakeSql([ + { + match: /INSERT INTO cleanup_registrations/, + rows: () => [{ id: `registration-${++registration}` }], + }, + ]) + const minted = await mintDemoSignupSeats(fake.sql as unknown as Queryable, { + cleanupId: EVENT_ID, + members: MEMBERS, + hashFor: (seatId) => `hash:${seatId}`, + }) + + expect(minted).toBe(2) + const seats = fake.statements.filter((s) => + /INSERT INTO cleanup_registration_seats/.test(s.sql), + ) + expect(seats).toHaveLength(2) + for (const seat of seats) { + const seatId = seat.values[0] as string + expect(seat.values).toContain(`hash:${seatId}`) + } + }) + + it("mints nothing on a ticketed event, exactly like the live join", async () => { + const fake = makeFakeSql([{ match: /FROM cleanup_ticket_types/, rows: [{ one: 1 }] }]) + const minted = await mintDemoSignupSeats(fake.sql as unknown as Queryable, { + cleanupId: EVENT_ID, + members: MEMBERS, + hashFor: (seatId) => `hash:${seatId}`, + }) + expect(minted).toBe(0) + expect(fake.statements.some((s) => /INSERT INTO cleanup_registrations/.test(s.sql))).toBe(false) + }) +}) + +describe("demo seat hashes use the secret the API resolves", () => { + const SEAT_ID = "66666666-6666-6666-6666-666666666666" + const API_SECRET = "an-api-ticket-token-secret-of-at-least-32-chars" + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("falls back to the development secret outside production, as the API does", () => { + vi.stubEnv("NODE_ENV", "development") + vi.stubEnv("TICKET_TOKEN_SECRET", "") + const expected = makeTicketTokenSigner(DEVELOPMENT_TICKET_TOKEN_SECRET).hashFor(SEAT_ID) + expect(demoTicketTokenHasher()(SEAT_ID)).toBe(expected) + }) + + it("hashes with the configured secret", () => { + const hashFor = demoTicketTokenHasher({ + NODE_ENV: "production", + TICKET_TOKEN_SECRET: ` ${API_SECRET} `, + }) + expect(hashFor(SEAT_ID)).toBe(makeTicketTokenSigner(API_SECRET).hashFor(SEAT_ID)) + }) + + it("refuses a production run without the API's secret", () => { + expect(() => demoTicketTokenHasher({ NODE_ENV: "production" })).toThrow( + /TICKET_TOKEN_SECRET: required \[BOOT\] variable is missing/, + ) + }) + + it("refuses the development secret in production, where no real seat would scan", () => { + expect(() => + demoTicketTokenHasher({ + NODE_ENV: "production", + TICKET_TOKEN_SECRET: DEVELOPMENT_TICKET_TOKEN_SECRET, + }), + ).toThrow(/insecure dev default/) + }) +}) diff --git a/services/api/test/unit/di-close.test.ts b/services/api/test/unit/di-close.test.ts new file mode 100644 index 00000000..ea72ddfc --- /dev/null +++ b/services/api/test/unit/di-close.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import { buildContainer } from "../../src/di.js" +import { loadEnv } from "../../src/env.js" + +describe("DI container after close()", () => { + it("refuses to reopen a database pool or hand out repositories bound to the closed one", async () => { + const container = buildContainer( + loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost:5432/civfix" }), + ) + container.getVolunteerHoursRepo() + container.getDmRepo() + + await container.close() + + expect(container.dbHandle).toBeUndefined() + expect(() => container.getDb()).toThrow(/closed/) + expect(() => container.getVolunteerHoursRepo()).toThrow(/closed/) + expect(() => container.getDmRepo()).toThrow(/closed/) + expect(container.dbHandle).toBeUndefined() + }) + + it("refuses to reopen Redis after close()", async () => { + const container = buildContainer( + loadEnv({ NODE_ENV: "test", REDIS_URL: "redis://localhost:6379" }), + ) + + await container.close() + + expect(() => container.getRedis()).toThrow(/closed/) + expect(container.redis).toBeUndefined() + }) + + it("lets a job still in flight during the graceful jobs stop reach the database", async () => { + const container = buildContainer( + loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost:5432/civfix" }), + ) + const poolBeforeStop = container.getDb() + let jobResult: unknown + const runningJob = (async () => { + await Promise.resolve() + jobResult = { + db: container.getDb(), + repo: container.getVolunteerHoursRepo(), + } + })() + Object.assign(container.jobs, { stop: () => runningJob }) + + await container.close() + + expect(jobResult).toEqual({ db: poolBeforeStop, repo: expect.anything() }) + expect(container.dbHandle).toBeUndefined() + expect(() => container.getDb()).toThrow(/closed/) + }) + + it("closes a pool a draining job opened, instead of leaking it", async () => { + const container = buildContainer( + loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost:5432/civfix" }), + ) + Object.assign(container.jobs, { + stop: async () => { + container.getDb() + }, + }) + + await container.close() + + expect(container.dbHandle).toBeUndefined() + expect(() => container.getDb()).toThrow(/closed/) + }) + + it("tolerates a second close()", async () => { + const container = buildContainer(loadEnv({ NODE_ENV: "test" })) + await container.close() + await expect(container.close()).resolves.toBeUndefined() + }) +}) diff --git a/services/api/test/unit/directory-facet-cache.test.ts b/services/api/test/unit/directory-facet-cache.test.ts new file mode 100644 index 00000000..cbd000c8 --- /dev/null +++ b/services/api/test/unit/directory-facet-cache.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { makeDrizzleJurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.drizzle.js" +import { makeDrizzleDiscoveryRepository } from "../../src/services/admin/discovery-repository.drizzle.js" +import type { ListDirectoryArgs } from "../../src/services/admin/jurisdiction-contacts-types.js" +import type { Sql } from "../../src/db/client.js" + +const DEFAULT_VIEW: ListDirectoryArgs = { + q: null, + filter: "all", + layer: null, + sort: "population", + cursor: null, + limit: 25, +} + +const FACET_QUERY = /::text AS routed,/ + +describe("directory facet cache", () => { + it("is dropped when a discovery draft saves contacts, so the routed/unrouted chips move at once", async () => { + const fake = makeFakeSql([ + { match: /SELECT geoid FROM jurisdiction_discovery_tasks/, rows: [{ geoid: "0644000" }] }, + { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, + { match: FACET_QUERY, rows: [{ total: "10", routed: "4", unrouted: "6" }] }, + ]) + const sql = fake.sql as unknown as Sql + const directory = makeDrizzleJurisdictionContactsRepository(sql) + const discovery = makeDrizzleDiscoveryRepository(sql) + const facetQueries = () => fake.statements.filter((s) => FACET_QUERY.test(s.sql)).length + + await directory.listDirectory(DEFAULT_VIEW) + const afterFirst = facetQueries() + await directory.listDirectory(DEFAULT_VIEW) + expect(facetQueries()).toBe(afterFirst) + + await discovery.saveDraft("11111111-1111-1111-1111-111111111111", { + contacts: { trash: "clerk@lacity.gov" }, + defaultEmails: [], + formUrl: null, + actorId: null, + }) + await directory.listDirectory(DEFAULT_VIEW) + expect(facetQueries()).toBe(afterFirst + 1) + }) +}) diff --git a/services/api/test/unit/discovery-flag.test.ts b/services/api/test/unit/discovery-flag.test.ts new file mode 100644 index 00000000..3f938a05 --- /dev/null +++ b/services/api/test/unit/discovery-flag.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { makeDrizzleDiscoveryRepository } from "../../src/services/admin/discovery-repository.drizzle.js" +import { InMemoryDiscoveryRepository } from "../../src/services/admin/discovery-repository.memory.js" +import type { Sql } from "../../src/db/client.js" + +const TASK_ID = "11111111-1111-1111-1111-111111111111" +const REPORT_ID = "22222222-2222-2222-2222-222222222222" + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ").trim() +} + +describe("flagging a discovery task", () => { + it("never re-opens a done task (the open-task unique index would reject it)", async () => { + const repo = new InMemoryDiscoveryRepository() + repo.seedTask({ id: TASK_ID, geoid: "0644000", place: "Los Angeles", status: "done" }) + expect(await repo.flagTask(TASK_ID, { reason: null, actorId: null })).toBe(true) + expect(repo.tasks.get(TASK_ID)?.task.status).toBe("done") + }) + + it("still moves an open task to in_progress", async () => { + const repo = new InMemoryDiscoveryRepository() + repo.seedTask({ id: TASK_ID, geoid: "0644000", place: "Los Angeles", status: "new" }) + await repo.flagTask(TASK_ID, { reason: null, actorId: null }) + expect(repo.tasks.get(TASK_ID)?.task.status).toBe("in_progress") + }) + + it("guards the status update and does not stack a second open manual flag on the report", async () => { + const fake = makeFakeSql([ + { + match: /SELECT sample_report_id FROM jurisdiction_discovery_tasks/, + rows: [{ sample_report_id: REPORT_ID }], + }, + { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, + ]) + const repo = makeDrizzleDiscoveryRepository(fake.sql as unknown as Sql) + expect(await repo.flagTask(TASK_ID, { reason: "spam", actorId: null })).toBe(true) + + const statements = fake.statements.map((s) => flat(s.sql)) + expect(statements[0]).toMatch(/FOR UPDATE$/) + const insert = statements.find((s) => s.startsWith("INSERT INTO abuse_flags")) + expect(insert).toMatch( + /WHERE NOT EXISTS \( SELECT 1 FROM abuse_flags WHERE subject_type = 'report' AND subject_id = \? AND reason = 'manual' AND resolved_at IS NULL \)/, + ) + const update = statements.find((s) => s.startsWith("UPDATE jurisdiction_discovery_tasks")) + expect(update).toMatch(/WHERE id = \? AND status <> 'done'$/) + }) +}) diff --git a/services/api/test/unit/discovery-jobs.test.ts b/services/api/test/unit/discovery-jobs.test.ts index de1144cf..afde7f6b 100644 --- a/services/api/test/unit/discovery-jobs.test.ts +++ b/services/api/test/unit/discovery-jobs.test.ts @@ -83,6 +83,25 @@ describe("registerDiscoveryJobs", () => { ).toBe(false) }) + it("does not count a bounced contact as routable, so a bounce-triggered run materializes the task", async () => { + const { container, db } = harness([ + { match: /SELECT\s+EXISTS[\s\S]*has_contact/i, rows: [{ has_contact: false }] }, + { match: /INSERT\s+INTO\s+jurisdiction_discovery_tasks/i, rows: [{ id: "task-1" }] }, + ]) + await registerDiscoveryJobs(container) + await container.jobs.enqueue(JURISDICTION_DISCOVERY_JOB, { geoid: "0644000" }) + + const probe = db.statements.find((s) => /has_contact/i.test(s.sql))?.sql ?? "" + const flat = probe.replace(/\s+/g, " ") + expect(flat).toMatch( + /FROM jurisdiction_contacts jc WHERE jc\.geoid = \? AND jc\.email IS NOT NULL AND jc\.email <> '' AND jc\.bounced_at IS NULL/, + ) + expect(flat).toMatch(/FROM unnest\(j\.contact_emails\) AS e WHERE e <> '' AND NOT EXISTS/) + expect(flat).toMatch( + /me\.type = 'bounced' AND lower\(me\.meta->>'failedRecipient'\) = lower\(e\)/, + ) + }) + it("is a no-op for a malformed payload with no geoid (no DB touched)", async () => { const { container, db } = harness([]) await registerDiscoveryJobs(container) diff --git a/services/api/test/unit/dm-ack-mark-read.test.ts b/services/api/test/unit/dm-ack-mark-read.test.ts new file mode 100644 index 00000000..ca2e815e --- /dev/null +++ b/services/api/test/unit/dm-ack-mark-read.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest" +import { makeDmAckMarkRead } from "../../src/routes/chat-gateway-wiring.js" + +const THREAD = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const USER = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +const KNOWN = "cccccccc-cccc-cccc-cccc-cccccccccccc" +const UNKNOWN = "dddddddd-dddd-dddd-dddd-dddddddddddd" +const KNOWN_AT = new Date("2026-09-01T12:00:00.000Z") + +function harness(): { + markRead: ReturnType + watermarks: Date[] + cleared: string[] +} { + const watermarks: Date[] = [] + const cleared: string[] = [] + const markRead = makeDmAckMarkRead( + { + resolveMessageCreatedAt: (_threadId, messageId) => + Promise.resolve(messageId === KNOWN ? KNOWN_AT : null), + markRead: (_threadId, _userId, at) => { + watermarks.push(at) + return Promise.resolve() + }, + }, + (threadId) => { + cleared.push(threadId) + return Promise.resolve() + }, + ) + return { markRead, watermarks, cleared } +} + +describe("DM ack over the socket", () => { + it("advances the read watermark to the acked message's own timestamp", async () => { + const h = harness() + + await h.markRead(THREAD, USER, KNOWN) + + expect(h.watermarks).toEqual([KNOWN_AT]) + expect(h.cleared).toEqual([THREAD]) + }) + + it("does not mark the thread read when the acked message does not resolve in it", async () => { + const h = harness() + + await h.markRead(THREAD, USER, UNKNOWN) + + expect(h.watermarks).toEqual([]) + }) +}) diff --git a/services/api/test/unit/dm-edit-mentions.test.ts b/services/api/test/unit/dm-edit-mentions.test.ts new file mode 100644 index 00000000..b3222720 --- /dev/null +++ b/services/api/test/unit/dm-edit-mentions.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, afterEach } from "vitest" +import type { FastifyInstance } from "fastify" +import { FakeMailer } from "@civfix/shared/fakes" +import type { UserMentionDTO } from "@civfix/shared" +import { buildServer } from "../../src/server.js" +import { loadEnv } from "../../src/env.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { makeInMemoryStores } from "../../src/auth/stores.js" +import { buildAuthServices } from "../../src/auth/auth-services.js" +import { StubJwksVerifier } from "../helpers/auth.js" +import { + InMemoryBlocksRepository, + InMemoryDmRepository, +} from "../../src/services/dm-repository.memory.js" +import { InMemoryChatRepository, InMemoryThreadsRepository } from "../helpers/chat.js" +import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" + +const PEER = "44444444-4444-4444-4444-444444444444" + +let current: FastifyInstance | undefined + +afterEach(async () => { + if (current) { + await current.close() + current = undefined + } +}) + +async function harness(chatMentions: ChatGatewayOverrides["chatMentions"]): Promise<{ + app: FastifyInstance + mailer: FakeMailer + dmRepo: InMemoryDmRepository +}> { + const env = loadEnv({ NODE_ENV: "test" }) + const stores = makeInMemoryStores() + const cache = new InMemoryCacheClient(() => Date.now()) + const mailer = new FakeMailer() + const authServices = buildAuthServices({ + stores, + cache, + mailer, + oauthConfig: {}, + verifier: new StubJwksVerifier(), + now: () => Date.now(), + }) + const blocks = new InMemoryBlocksRepository() + const dmRepo = new InMemoryDmRepository((a, b) => blocks.isBlockedEitherWay(a, b)) + dmRepo.registerUser({ id: PEER, displayName: "Peer", handle: "peer" }) + const overrides: ChatGatewayOverrides = { + isMember: () => Promise.resolve(true), + threadsRepo: new InMemoryThreadsRepository(), + dmRepo, + chatRepo: new InMemoryChatRepository(), + blocksRepo: blocks, + chatMentions, + } + const app = await buildServer({ env, authServices, chatOverrides: overrides }) + current = app + return { app, mailer, dmRepo } +} + +async function signIn( + app: FastifyInstance, + mailer: FakeMailer, + email: string, +): Promise<{ token: string; userId: string }> { + await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) + const code = mailer.lastOtpFor(email)! + const verify = await app.inject({ + method: "POST", + url: "/v1/auth/otp/verify", + headers: { "x-client": "mobile" }, + payload: { email, code }, + }) + const body = verify.json() + return { token: body.token, userId: body.user.id } +} + +describe("PATCH /dm/:threadId/messages/:messageId re-records mentions", () => { + it("replaces the edited message's mention set, like the unified edit route", async () => { + const recorded: { messageId: string; ids: string[] }[] = [] + const resolvedKinds: string[] = [] + const peerMention: UserMentionDTO = { id: PEER, handle: "peer", displayName: "Peer" } + const { app, mailer, dmRepo } = await harness({ + resolveChatMentions: (input) => { + resolvedKinds.push(input.kind) + return Promise.resolve([peerMention]) + }, + recordChatMentions: (messageId, ids) => { + recorded.push({ messageId, ids }) + return Promise.resolve() + }, + notifyChatMention: () => Promise.resolve(), + }) + const { token, userId } = await signIn(app, mailer, "me@example.com") + dmRepo.registerUser({ id: userId, displayName: "Me", handle: "me" }) + const thread = await dmRepo.openOrCreateThread(userId, PEER) + const msg = await dmRepo.persist({ threadId: thread.id, senderId: userId, body: "hi" }) + + const res = await app.inject({ + method: "PATCH", + url: `/v1/dm/${thread.id}/messages/${msg.id}`, + headers: { authorization: `Bearer ${token}`, "x-client": "mobile" }, + payload: { body: "hi @peer" }, + }) + + expect(res.statusCode).toBe(200) + expect(resolvedKinds).toEqual(["dm"]) + expect(recorded).toEqual([{ messageId: msg.id, ids: [PEER] }]) + }) +}) diff --git a/services/api/test/unit/email-format.test.ts b/services/api/test/unit/email-format.test.ts index 38d79ac8..480b8cca 100644 --- a/services/api/test/unit/email-format.test.ts +++ b/services/api/test/unit/email-format.test.ts @@ -191,6 +191,24 @@ describe("buildReportPacket", () => { expect(packet.html).toContain("mlat=39.5") }) + it("dates an evening report by the platform's local day, not the server clock's zone", () => { + const serverZone = process.env.TZ + process.env.TZ = "UTC" + try { + const packet = buildReportPacket( + reportRecord({ createdAt: new Date("2026-03-10T03:30:00Z") }), + null, + [], + null, + { subject: null, body: "Filed {submittedDate}." }, + ) + expect(packet.text).toContain("Filed March 9, 2026.") + } finally { + if (serverZone === undefined) delete process.env.TZ + else process.env.TZ = serverZone + } + }) + it("strips CRLF from the subject to block header injection", () => { const packet = buildReportPacket( reportRecord({ title: "Hi\r\nBcc: evil@x" }), diff --git a/services/api/test/unit/env-strict-parsing.test.ts b/services/api/test/unit/env-strict-parsing.test.ts new file mode 100644 index 00000000..2fb54180 --- /dev/null +++ b/services/api/test/unit/env-strict-parsing.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest" +import { loadEnv } from "../../src/env.js" +import { buildContainer } from "../../src/di.js" + +function validProdEnv(): NodeJS.ProcessEnv { + return { + NODE_ENV: "production", + PORT: "8080", + PUBLIC_API_URL: "https://api.civfix.org", + WEB_ORIGINS: "https://civfix.org", + DATABASE_URL: "postgres://user:pass@db:5432/civfix?sslmode=require", + REDIS_URL: "redis://cache:6379", + SESSION_SIGNING_KEY: "prod-session-signing-key-abcdefghijklmnop", + ANON_TOKEN_SIGNING_KEY: "prod-anon-token-signing-key-abcdefghijklmnop", + R2_ACCOUNT_ID: "acct", + R2_ACCESS_KEY_ID: "akid", + R2_SECRET_ACCESS_KEY: "secret", + R2_BUCKET: "civfix-media", + OCI_EMAIL_SMTP_HOST: "smtp.oci.example", + OCI_EMAIL_SMTP_PORT: "587", + OCI_EMAIL_SMTP_USER: "smtp-user", + OCI_EMAIL_SMTP_PASS: "smtp-pass", + UNSUBSCRIBE_SIGNING_KEY: "prod-unsubscribe-signing-key-abcdefghijklmnop", + TICKET_TOKEN_SECRET: "prod-ticket-token-secret-abcdefghijklmnop", + } +} + +const APNS_QUARTET = { + APNS_KEY_ID: "KEY123", + APNS_TEAM_ID: "TEAM123", + APNS_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + APNS_BUNDLE_ID: "org.civfix.app", +} + +describe("loadEnv: APNS_PRODUCTION", () => { + it("requires an explicit APNS_PRODUCTION in production once APNs credentials are set", () => { + expect(() => loadEnv({ ...validProdEnv(), ...APNS_QUARTET })).toThrow(/APNS_PRODUCTION/) + }) + + it("rejects a value that is neither true nor false instead of reading it as sandbox", () => { + expect(() => loadEnv({ ...validProdEnv(), ...APNS_QUARTET, APNS_PRODUCTION: "prod" })).toThrow( + /APNS_PRODUCTION/, + ) + }) + + it("accepts an explicit sandbox choice in production", () => { + const env = loadEnv({ ...validProdEnv(), ...APNS_QUARTET, APNS_PRODUCTION: "false" }) + expect(env.APNS_PRODUCTION).toBe(false) + }) + + it.each([ + ["t", true], + ["F", false], + ["y", true], + ["N", false], + ])("accepts the one-letter form %s", (raw, expected) => { + const env = loadEnv({ ...validProdEnv(), ...APNS_QUARTET, APNS_PRODUCTION: raw }) + expect(env.APNS_PRODUCTION).toBe(expected) + }) + + it("rejects any other one-letter value and names every accepted form", () => { + expect(() => loadEnv({ ...validProdEnv(), ...APNS_QUARTET, APNS_PRODUCTION: "x" })).toThrow( + /APNS_PRODUCTION: must be one of true\/t\/yes\/y\/on\/1 or false\/f\/no\/n\/off\/0/, + ) + }) + + it("does not demand the flag when APNs is not configured", () => { + expect(() => loadEnv(validProdEnv())).not.toThrow() + }) +}) + +describe("push config: APNs gateway default", () => { + it("targets the production gateway when APNS_PRODUCTION is unset", () => { + const container = buildContainer( + loadEnv({ + NODE_ENV: "test", + USE_FAKE_PUSH: "0", + DATABASE_URL: "postgres://u:p@localhost:5432/civfix", + ...APNS_QUARTET, + }), + ) + const config = ( + container.pushSender as unknown as { config: { apns?: { production: boolean } } } + ).config + expect(config.apns?.production).toBe(true) + }) +}) + +describe("loadEnv: SHUTDOWN_DRAIN_MS", () => { + it("reads the leading digits of a value carrying a unit suffix instead of turning the drain off", () => { + expect(loadEnv({ ...validProdEnv(), SHUTDOWN_DRAIN_MS: "8000ms" }).SHUTDOWN_DRAIN_MS).toBe(8000) + }) + + it("still reads a value with no leading digits as no drain", () => { + expect(loadEnv({ ...validProdEnv(), SHUTDOWN_DRAIN_MS: "nope" }).SHUTDOWN_DRAIN_MS).toBe(0) + }) +}) + +describe("loadEnv: integer variables", () => { + it("rejects trailing garbage instead of reading the leading digits", () => { + expect(() => loadEnv({ ...validProdEnv(), OCI_EMAIL_SMTP_TIMEOUT_MS: "15s" })).toThrow( + /OCI_EMAIL_SMTP_TIMEOUT_MS/, + ) + }) + + it("rejects a non-positive value for a positive-only setting instead of using the default", () => { + expect(() => loadEnv({ NODE_ENV: "test", SMS_DAILY_CAP: "-5" })).toThrow(/SMS_DAILY_CAP/) + }) + + it("rejects a non-numeric value instead of silently using the default", () => { + expect(() => loadEnv({ NODE_ENV: "test", TILES_MIN_ZOOM: "abc" })).toThrow(/TILES_MIN_ZOOM/) + expect(() => loadEnv({ NODE_ENV: "test", HOST_EXPORT_TTL_HOURS: "24h" })).toThrow( + /HOST_EXPORT_TTL_HOURS/, + ) + }) + + it("still reads well-formed integers and defaults blank values", () => { + const env = loadEnv({ NODE_ENV: "test", SMS_DAILY_CAP: " 20 ", TILES_MIN_ZOOM: "" }) + expect(env.SMS_DAILY_CAP).toBe(20) + expect(env.TILES_MIN_ZOOM).toBe(1) + }) +}) diff --git a/services/api/test/unit/feed-ranked-service.test.ts b/services/api/test/unit/feed-ranked-service.test.ts index 68b13a4b..5a1a8515 100644 --- a/services/api/test/unit/feed-ranked-service.test.ts +++ b/services/api/test/unit/feed-ranked-service.test.ts @@ -559,11 +559,12 @@ describe("ranked feed: the cold-start leniency pages to exhaustion, it does not function service(): { svc: PostService; presence: ReturnType } { const cache = new InMemoryCacheClient(() => Date.now()) - const presence = makeFeedPresence({ cache, config: DEFAULT_FEED_RANKING }) + const presence = makeFeedPresence({ cache, config: NO_JITTER }) const svc = makePostService({ repo: repoOver({ feedCandidates: () => Promise.resolve(rows) }), sql: throwingSql, feedPresence: presence, + feedRanking: NO_JITTER, now: () => NOW, }) return { svc, presence } @@ -614,8 +615,9 @@ describe("ranked feed: the cold-start leniency pages to exhaustion, it does not sql: throwingSql, feedPresence: makeFeedPresence({ cache: new InMemoryCacheClient(() => Date.now()), - config: DEFAULT_FEED_RANKING, + config: NO_JITTER, }), + feedRanking: NO_JITTER, now: () => NOW, }) const second = await expired.homeFeed(VIEWER, { @@ -624,7 +626,7 @@ describe("ranked feed: the cold-start leniency pages to exhaustion, it does not cursor: first.nextCursor!, }) - expect(second.items.length).toBeGreaterThan(0) + expect(second.items).toHaveLength(20) }) }) diff --git a/services/api/test/unit/feed-repost-count-fanout.test.ts b/services/api/test/unit/feed-repost-count-fanout.test.ts new file mode 100644 index 00000000..dcaa5dcf --- /dev/null +++ b/services/api/test/unit/feed-repost-count-fanout.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest" +import { DEFAULT_FEED_RANKING } from "@civfix/shared" +import type { FeedRankingConfig, PostDTO, UserSignal } from "@civfix/shared" +import type { UserChannel } from "@civfix/shared/interfaces" +import type { Sql } from "../../src/db/client.js" +import { makePostService } from "../../src/services/post-service.js" +import { makeFeedPresence } from "../../src/services/feed-presence.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import type { + FeedCandidateRow, + PostBrief, + PostRepository, +} from "../../src/services/post-repository.drizzle.js" + +const VIEWER = "11111111-1111-1111-1111-111111111111" +const LIKER = "22222222-2222-2222-2222-222222222222" +const AUTHOR = "33333333-3333-3333-3333-333333333333" +const SHELL = "44444444-4444-4444-4444-444444444444" +const TARGET = "55555555-5555-5555-5555-555555555555" +const NOW = Date.UTC(2026, 8, 15, 12, 0, 0) +const NO_JITTER: FeedRankingConfig = { ...DEFAULT_FEED_RANKING, jitterAmount: 0 } + +const throwingSql = (() => { + throw new Error("sql must not be called in these unit paths") +}) as unknown as Sql + +function shellRow(): FeedCandidateRow { + return { + id: SHELL, + author_id: AUTHOR, + created_at: new Date(NOW - 60_000), + like_count: 0, + reply_count: 0, + repost_count: 0, + has_report: false, + has_live_event: false, + has_media: false, + author_followed: true, + author_is_viewer: false, + viewer_mentioned: false, + author_org_verified: false, + distance_km: null, + } +} + +function postDto(id: string, over: Partial = {}): PostDTO { + return { + id, + author: { + id: AUTHOR, + name: "Author", + handle: "author", + bio: null, + avatar: ["#000000", "#111111"], + followers: 0, + following: 0, + isFollowing: false, + }, + kind: "post", + body: "hi", + createdAt: new Date(NOW).toISOString(), + editedAt: null, + counts: { likes: 0, reposts: 0, replies: 0, saves: 0 }, + viewer: { liked: false, reposted: false, saved: false }, + media: [], + mentions: [], + event: null, + report: null, + repostOf: null, + replyToId: null, + threadRootId: null, + ...over, + } +} + +function targetBrief(): PostBrief { + return { + id: TARGET, + authorId: AUTHOR, + kind: "post", + replyToId: null, + repostOfId: null, + deletedAt: null, + visibility: "public", + } +} + +describe("a viewer who saw a post only through a repost card still gets its count updates", () => { + it("signals feed_counts for the ORIGINAL to a viewer served the repost shell", async () => { + const sent: Array<{ userId: string; signal: UserSignal }> = [] + const channel = { + subscribeUser: () => Promise.resolve(() => Promise.resolve()), + publishToUser: (userId: string, signal: UserSignal) => { + sent.push({ userId, signal }) + return Promise.resolve() + }, + publishToUsers: () => Promise.resolve(), + } as unknown as UserChannel + const repo = { + getPostBrief: () => Promise.resolve(targetBrief()), + actorNameOf: () => Promise.resolve("Liker"), + like: () => Promise.resolve(true), + getPostDTO: (id: string) => Promise.resolve(postDto(id)), + feedCandidates: () => Promise.resolve([shellRow()]), + hydrateByIds: () => + Promise.resolve([ + postDto(SHELL, { + kind: "repost", + repostOf: { id: TARGET } as unknown as PostDTO["repostOf"], + }), + ]), + } as unknown as PostRepository + const svc = makePostService({ + repo, + sql: throwingSql, + feedPresence: makeFeedPresence({ + cache: new InMemoryCacheClient(() => Date.now()), + config: NO_JITTER, + }), + feedRanking: NO_JITTER, + userChannel: channel, + now: () => NOW, + }) + + await svc.homeFeed(VIEWER, { filter: "all", limit: 20 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + await svc.likePost(TARGET, LIKER) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(sent).toContainEqual({ userId: VIEWER, signal: { topic: "feed_counts", id: TARGET } }) + }) +}) diff --git a/services/api/test/unit/glitchtip-deep-redact.test.ts b/services/api/test/unit/glitchtip-deep-redact.test.ts new file mode 100644 index 00000000..4cdb0228 --- /dev/null +++ b/services/api/test/unit/glitchtip-deep-redact.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { scrubBreadcrumb, scrubEvent } from "../../src/errors/glitchtip.js" + +function nest(depth: number, leaf: Record): Record { + let value: Record = leaf + for (let i = 0; i < depth; i += 1) value = { level: value } + return value +} + +describe("GlitchTip scrubbing of deeply nested data", () => { + it("never ships a sensitive key nested past the redaction depth", () => { + const out = scrubEvent({ extra: nest(12, { email: "user@example.com", token: "sess_abc" }) }) + + const serialized = JSON.stringify(out) + expect(serialized).not.toContain("user@example.com") + expect(serialized).not.toContain("sess_abc") + }) + + it("applies the same cap to breadcrumb data", () => { + const out = scrubBreadcrumb({ data: nest(12, { password: "hunter2" }) }) + + expect(JSON.stringify(out)).not.toContain("hunter2") + }) + + it("keeps shallow diagnostic values intact", () => { + const out = scrubEvent({ extra: nest(3, { requestId: "req-1" }) }) + + expect(JSON.stringify(out)).toContain("req-1") + }) +}) diff --git a/services/api/test/unit/guest-jobs.test.ts b/services/api/test/unit/guest-jobs.test.ts index aa0af857..f5fb6ca2 100644 --- a/services/api/test/unit/guest-jobs.test.ts +++ b/services/api/test/unit/guest-jobs.test.ts @@ -114,6 +114,7 @@ describe("guest update fan-out", () => { counters: { incr: () => Promise.reject(new Error("redis down")), incrBy: () => Promise.reject(new Error("redis down")), + decrBy: () => Promise.reject(new Error("redis down")), }, perEventPerHour: 3, enqueuePlan: () => Promise.resolve(), diff --git a/services/api/test/unit/guest-roster-keyset-precision.test.ts b/services/api/test/unit/guest-roster-keyset-precision.test.ts new file mode 100644 index 00000000..8fa31890 --- /dev/null +++ b/services/api/test/unit/guest-roster-keyset-precision.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" +import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" + +const CLEANUP = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e00" +const ID_A = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" +const ID_B = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e02" +const AT = new Date("2026-09-01T10:00:00.123Z") +const AT_TEXT = "2026-09-01T10:00:00.123000Z" +const LEGACY_AT_TEXT = "2026-09-01T10:00:00.123Z" +const MATCH = /FROM cleanup_guests\s+WHERE cleanup_id = \?/ + +function guestRow(id: string, cursorAt: string): Record { + return { + id, + name: "Guest", + channel: "email", + email: "guest@example.org", + phone: null, + verified_at: AT, + cancelled_at: null, + created_at: AT, + cursor_at: cursorAt, + } +} + +function lastRosterStatement(ctl: ReturnType): { + sql: string + values: unknown[] +} { + const hit = [...ctl.statements].reverse().find((s) => MATCH.test(s.sql)) + if (hit === undefined) throw new Error("no roster statement") + return hit +} + +// The guest roster keysets on the same exact-text anchor as every other time-ordered list, so its +// cursor cannot drift from the column's stored precision. +describe("guest roster keyset cursor", () => { + it("encodes the next cursor from the column's own text and binds it back as text", async () => { + const ctl = makeFakeSql([ + { match: MATCH, rows: [guestRow(ID_A, AT_TEXT), guestRow(ID_B, AT_TEXT)] }, + ]) + const repo = makeDrizzleGuestRsvpRepository(ctl.sql as unknown as Sql) + + const page1 = await repo.listGuests({ cleanupId: CLEANUP, cursor: null, limit: 1 }) + expect(page1.nextCursor).toBe(`${AT_TEXT}|${ID_A}`) + expect(lastRosterStatement(ctl).sql).toMatch(/to_char\(created_at AT TIME ZONE 'UTC', \?\)/) + + await repo.listGuests({ + cleanupId: CLEANUP, + cursor: parseKeysetCursor(page1.nextCursor, { direction: "desc" }), + limit: 1, + }) + const stmt = lastRosterStatement(ctl) + expect(stmt.sql).toContain("(created_at, id) < (?::timestamptz, ?::uuid)") + expect(stmt.values).toContain(AT_TEXT) + expect(stmt.values.some((v) => v instanceof Date)).toBe(false) + }) + + it("still pages from a legacy millisecond cursor at the instant it always meant", async () => { + const ctl = makeFakeSql([{ match: MATCH, rows: [] }]) + const repo = makeDrizzleGuestRsvpRepository(ctl.sql as unknown as Sql) + await repo.listGuests({ + cleanupId: CLEANUP, + cursor: parseKeysetCursor(`${LEGACY_AT_TEXT}|${ID_A}`, { direction: "desc" }), + limit: 1, + }) + expect(lastRosterStatement(ctl).values).toContain(LEGACY_AT_TEXT) + }) +}) diff --git a/services/api/test/unit/guest-rsvp-notice-optout.test.ts b/services/api/test/unit/guest-rsvp-notice-optout.test.ts new file mode 100644 index 00000000..d07cb21a --- /dev/null +++ b/services/api/test/unit/guest-rsvp-notice-optout.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest" +import type { GuestRsvpRequestRequest, GuestRsvpVerifyRequest } from "@civfix/shared" +import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { smsFailure } from "../../src/errors/sms-failure.js" +import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" +import { makeGuestRsvpService } from "../../src/services/guest-rsvp-service.js" + +const EVENT_ID = "11111111-1111-1111-1111-111111111111" +const PHONE = "+15552223333" +const CODE = "424242" +const ctx = { ip: "203.0.113.10" } + +describe("guest sms notice: a failed opt-out write is not silent", () => { + it("logs the opt-out write failure with the guest id and still completes the notice", async () => { + const now = (): number => Date.parse("2026-08-25T12:00:00.000Z") + const repo = new InMemoryGuestRsvpRepository({ now }) + repo.seedEvent({ id: EVENT_ID, title: "Beach cleanup" }) + const sms = new FakeSmsSender() + const warnings: { obj: unknown; msg: string | undefined }[] = [] + const service = makeGuestRsvpService({ + repo, + mailer: new FakeMailer(), + smsSender: sms, + abuseChecks: new FakeAbuseChecks(), + cache: new InMemoryCacheClient(now), + counters: new InMemoryCounterStore(now), + requireGuestContact: () => Promise.resolve(), + smsGuestEnabled: true, + smsDailyCap: 50, + manageLinkBase: "https://civfix.org", + now, + newCode: () => CODE, + logger: { + warn: (obj, msg) => warnings.push({ obj, msg }), + info: () => undefined, + error: () => undefined, + }, + }) + await service.requestCode( + { + id: EVENT_ID, + name: "Ada", + channel: "sms", + phone: PHONE, + turnstileToken: "ok", + } as GuestRsvpRequestRequest, + ctx, + ) + await service.verifyCode( + { id: EVENT_ID, channel: "sms", phone: PHONE, code: CODE } as GuestRsvpVerifyRequest, + ctx, + ) + const guestId = repo.guests[0]?.id + const writeFailed = new Error("db down") + sms.send = () => Promise.reject(smsFailure("opted_out", "recipient opted out")) + repo.recordPhoneOptOut = () => Promise.reject(writeFailed) + warnings.length = 0 + + await expect(service.notifyGuestsBySms(EVENT_ID, "cancelled")).resolves.toBe(0) + + expect(warnings.map((w) => w.obj)).toContainEqual({ + err: writeFailed, + cleanupId: EVENT_ID, + guestId, + }) + }) +}) diff --git a/services/api/test/unit/guest-rsvp-refusal-subcodes.test.ts b/services/api/test/unit/guest-rsvp-refusal-subcodes.test.ts new file mode 100644 index 00000000..2c792d2b --- /dev/null +++ b/services/api/test/unit/guest-rsvp-refusal-subcodes.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi } from "vitest" +import { randomUUID } from "node:crypto" +import { + AppError, + type GuestRsvpRequestRequest, + type GuestRsvpVerifyRequest, + type RegisterForEventResponse, +} from "@civfix/shared" +import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" +import { + GUEST_REGISTRATION_ERROR_FIELD, + makeGuestRsvpService, + type GuestRegistrationBridge, + type GuestRegistrationGate, +} from "../../src/services/guest-rsvp-service.js" + +const EVENT_ID = "11111111-1111-1111-1111-111111111111" +const PUBLIC_TYPE = "22222222-2222-2222-2222-222222222222" +const CODE_TYPE = "33333333-3333-3333-3333-333333333333" +const CODE = "424242" +const NOW = Date.parse("2026-08-25T12:00:00.000Z") +const ctx = { ip: "203.0.113.10" } + +function refusal( + outcome: RegisterForEventResponse["outcome"], + extra: Partial = {}, +): RegisterForEventResponse { + return { outcome, registration: null, ticketTokens: [], ...extra } +} + +function openGate(overrides: Partial = {}): GuestRegistrationGate { + return { + registrationOpensAt: null, + registrationClosesAt: null, + ticketTypes: [ + { id: PUBLIC_TYPE, visibility: "public", salesOpensAt: null, salesClosesAt: null }, + { id: CODE_TYPE, visibility: "access_code", salesOpensAt: null, salesClosesAt: null }, + ], + ...overrides, + } +} + +interface BuildOptions { + register?: GuestRegistrationBridge["register"] + gate?: GuestRegistrationBridge["registrationGate"] +} + +function build(opts: BuildOptions = {}) { + let clock = NOW + const now = (): number => clock + const repo = new InMemoryGuestRsvpRepository({ now }) + repo.seedEvent({ id: EVENT_ID, title: "Beach cleanup" }) + const mailer = new FakeMailer() + const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn() } + const registrations: GuestRegistrationBridge = { + register: opts.register ?? (() => Promise.resolve(refusal("registered"))), + ...(opts.gate !== undefined ? { registrationGate: opts.gate } : {}), + } + const service = makeGuestRsvpService({ + repo, + mailer, + smsSender: new FakeSmsSender(), + abuseChecks: new FakeAbuseChecks(), + cache: new InMemoryCacheClient(now), + counters: new InMemoryCounterStore(now), + requireGuestContact: () => Promise.resolve(), + registrations, + smsGuestEnabled: false, + smsDailyCap: 50, + manageLinkBase: "https://civfix.org", + now, + newCode: () => CODE, + newToken: () => `manage-token-${randomUUID()}`, + logger, + }) + return { + service, + repo, + mailer, + logger, + advance(ms: number) { + clock += ms + }, + } +} + +function request(extra: Partial = {}): GuestRsvpRequestRequest { + return { + id: EVENT_ID, + name: "Ada Lovelace", + channel: "email", + email: "ada@example.org", + turnstileToken: "ok", + ...extra, + } as GuestRsvpRequestRequest +} + +function verify(extra: Partial = {}): GuestRsvpVerifyRequest { + return { + id: EVENT_ID, + channel: "email", + email: "ada@example.org", + code: CODE, + ...extra, + } as GuestRsvpVerifyRequest +} + +function codesSent(mailer: FakeMailer): number { + return mailer.sent.filter((m) => m.template === "guest_otp").length +} + +describe("guest verify refusals carry a machine subcode the clients can map to their own copy", () => { + it.each([ + ["full", "CONFLICT", "sold_out"], + ["waitlisted", "CONFLICT", "sold_out"], + ["registration_closed", "CONFLICT", "registration_closed"], + ["sales_closed", "CONFLICT", "sales_closed"], + ["closed", "CONFLICT", "event_closed"], + ["party_too_large", "VALIDATION", "party_too_large"], + ["ticket_type_not_found", "VALIDATION", "ticket_type_unavailable"], + ["access_code_required", "VALIDATION", "access_code_required"], + ["access_code_invalid", "VALIDATION", "access_code_invalid"], + ] as const)("a %s refusal answers %s with reason %s", async (outcome, code, reason) => { + const h = build({ register: () => Promise.resolve(refusal(outcome)) }) + await h.service.requestCode(request(), ctx) + + await expect(h.service.verifyCode(verify(), ctx)).rejects.toMatchObject({ + code, + fields: expect.objectContaining({ [GUEST_REGISTRATION_ERROR_FIELD]: reason }), + }) + }) + + it("keeps the per-question hints beside the subcode on an answers refusal", async () => { + const h = build({ + register: () => + Promise.resolve(refusal("answers_invalid", { fields: { "answers.q1": "required" } })), + }) + await h.service.requestCode(request(), ctx) + + await expect(h.service.verifyCode(verify(), ctx)).rejects.toMatchObject({ + code: "VALIDATION", + fields: { "answers.q1": "required", [GUEST_REGISTRATION_ERROR_FIELD]: "answers_invalid" }, + }) + }) + + it("gives a host ban no subcode, so it still reads exactly like an unknown event", async () => { + const h = build({ register: () => Promise.resolve(refusal("banned")) }) + await h.service.requestCode(request(), ctx) + + const err = await h.service.verifyCode(verify(), ctx).catch((e: unknown) => e) + + expect(err).toMatchObject({ code: "NOT_FOUND" }) + expect( + (err as { fields?: Record }).fields?.[GUEST_REGISTRATION_ERROR_FIELD], + ).toBe(undefined) + }) +}) + +describe("guest code request refuses a registration it already knows is doomed", () => { + it("sends no code for an access-code ticket type when the guest supplied no access code", async () => { + const h = build({ gate: () => Promise.resolve(openGate()) }) + + await expect( + h.service.requestCode(request({ ticketTypeId: CODE_TYPE }), ctx), + ).rejects.toMatchObject({ + code: "VALIDATION", + fields: expect.objectContaining({ + accessCode: expect.any(String), + [GUEST_REGISTRATION_ERROR_FIELD]: "access_code_required", + }), + }) + expect(codesSent(h.mailer)).toBe(0) + expect(h.repo.otps).toHaveLength(0) + }) + + it("sends no code once the selected ticket type's sales have closed", async () => { + const h = build({ + gate: () => + Promise.resolve( + openGate({ + ticketTypes: [ + { + id: PUBLIC_TYPE, + visibility: "public", + salesOpensAt: null, + salesClosesAt: new Date(NOW - 1000), + }, + ], + }), + ), + }) + + await expect( + h.service.requestCode(request({ ticketTypeId: PUBLIC_TYPE }), ctx), + ).rejects.toMatchObject({ + code: "CONFLICT", + fields: { [GUEST_REGISTRATION_ERROR_FIELD]: "sales_closed" }, + }) + expect(codesSent(h.mailer)).toBe(0) + }) + + it("applies the lone ticket type when the guest selected none, as registration does", async () => { + const h = build({ + gate: () => + Promise.resolve( + openGate({ + ticketTypes: [ + { id: CODE_TYPE, visibility: "access_code", salesOpensAt: null, salesClosesAt: null }, + ], + }), + ), + }) + + await expect(h.service.requestCode(request(), ctx)).rejects.toMatchObject({ + fields: expect.objectContaining({ + [GUEST_REGISTRATION_ERROR_FIELD]: "access_code_required", + }), + }) + }) + + it("sends no code once the event's registration window has closed", async () => { + const h = build({ + gate: () => Promise.resolve(openGate({ registrationClosesAt: new Date(NOW - 1000) })), + }) + + await expect( + h.service.requestCode(request({ ticketTypeId: PUBLIC_TYPE }), ctx), + ).rejects.toMatchObject({ + code: "CONFLICT", + fields: { [GUEST_REGISTRATION_ERROR_FIELD]: "registration_closed" }, + }) + expect(codesSent(h.mailer)).toBe(0) + }) + + it("still sends a code when the guest supplied an access code, leaving its check to verify", async () => { + const h = build({ gate: () => Promise.resolve(openGate()) }) + + await h.service.requestCode(request({ ticketTypeId: CODE_TYPE, accessCode: "SECRET" }), ctx) + + expect(codesSent(h.mailer)).toBe(1) + }) + + it("still sends a code when several types exist and none was selected, leaving it to verify", async () => { + const h = build({ gate: () => Promise.resolve(openGate()) }) + + await h.service.requestCode(request(), ctx) + + expect(codesSent(h.mailer)).toBe(1) + }) + + it("refuses a guest who already holds the RSVP the same way, so it never reveals who is listed", async () => { + let gate = openGate() + const h = build({ gate: () => Promise.resolve(gate) }) + await h.service.requestCode(request({ ticketTypeId: PUBLIC_TYPE }), ctx) + await h.service.verifyCode(verify({ ticketTypeId: PUBLIC_TYPE }), ctx) + + gate = openGate({ registrationClosesAt: new Date(NOW) }) + h.advance(61_000) + const returning = await h.service + .requestCode(request({ ticketTypeId: PUBLIC_TYPE }), ctx) + .catch((e: unknown) => e) + const stranger = await h.service + .requestCode(request({ ticketTypeId: PUBLIC_TYPE, email: "grace@example.org" }), ctx) + .catch((e: unknown) => e) + + expect(returning).toMatchObject({ code: "CONFLICT" }) + const shape = (e: unknown) => { + const { code, message, fields } = e as { code: string; message: string; fields?: unknown } + return { code, message, fields } + } + expect(shape(returning)).toEqual(shape(stranger)) + expect(codesSent(h.mailer)).toBe(1) + }) + + it("sends the code anyway when the gate lookup fails, since verify stays the authority", async () => { + const h = build({ gate: () => Promise.reject(new Error("db down")) }) + + await h.service.requestCode(request({ ticketTypeId: CODE_TYPE }), ctx) + + expect(codesSent(h.mailer)).toBe(1) + expect(h.logger.warn).toHaveBeenCalledTimes(1) + }) +}) + +describe("a failed rollback never hides the registration refusal", () => { + it("answers the mapped refusal and logs the rollback failure when cancelGuest throws", async () => { + const h = build({ register: () => Promise.resolve(refusal("full")) }) + h.repo.cancelGuest = () => Promise.reject(new Error("cancel failed")) + await h.service.requestCode(request(), ctx) + + await expect(h.service.verifyCode(verify(), ctx)).rejects.toMatchObject({ + code: "CONFLICT", + fields: { [GUEST_REGISTRATION_ERROR_FIELD]: "sold_out" }, + }) + expect(h.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ cleanupId: EVENT_ID }), + expect.stringMatching(/roll/), + ) + }) + + it("rethrows the bridge's own validation error, not the rollback's, when both fail", async () => { + const h = build({ + register: () => Promise.reject(AppError.validation({ partySize: "too large" })), + }) + h.repo.cancelGuest = () => Promise.reject(new Error("cancel failed")) + await h.service.requestCode(request(), ctx) + + await expect(h.service.verifyCode(verify(), ctx)).rejects.toMatchObject({ + code: "VALIDATION", + fields: { partySize: "too large" }, + }) + expect(h.logger.error).toHaveBeenCalledOnce() + }) +}) diff --git a/services/api/test/unit/guest-rsvp-refusal.test.ts b/services/api/test/unit/guest-rsvp-refusal.test.ts new file mode 100644 index 00000000..2de7cde9 --- /dev/null +++ b/services/api/test/unit/guest-rsvp-refusal.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { + AppError, + type GuestRsvpRequestRequest, + type GuestRsvpVerifyRequest, + type RegisterForEventResponse, +} from "@civfix/shared" +import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" +import { + makeGuestRsvpService, + type GuestRegistrationBridge, + type UpsertGuestArgs, +} from "../../src/services/guest-rsvp-service.js" + +const EVENT_ID = "11111111-1111-1111-1111-111111111111" +const CODE = "424242" +const ctx = { ip: "203.0.113.10" } + +function refusal( + outcome: RegisterForEventResponse["outcome"], + extra: Partial = {}, +): RegisterForEventResponse { + return { outcome, registration: null, ticketTokens: [], ...extra } +} + +function build(register: GuestRegistrationBridge["register"]) { + let clock = Date.parse("2026-08-25T12:00:00.000Z") + const now = (): number => clock + const repo = new InMemoryGuestRsvpRepository({ now }) + repo.seedEvent({ id: EVENT_ID, title: "Beach cleanup" }) + repo.memberCounts.set(EVENT_ID, 3) + const mailer = new FakeMailer() + const service = makeGuestRsvpService({ + repo, + mailer, + smsSender: new FakeSmsSender(), + abuseChecks: new FakeAbuseChecks(), + cache: new InMemoryCacheClient(now), + counters: new InMemoryCounterStore(now), + requireGuestContact: () => Promise.resolve(), + registrations: { register }, + smsGuestEnabled: false, + smsDailyCap: 50, + manageLinkBase: "https://civfix.org", + now, + newCode: () => CODE, + newToken: () => `manage-token-${randomUUID()}`, + }) + return { + service, + repo, + mailer, + advance(ms: number) { + clock += ms + }, + } +} + +const request: GuestRsvpRequestRequest = { + id: EVENT_ID, + name: "Ada Lovelace", + channel: "email", + email: "ada@example.org", + turnstileToken: "ok", +} as GuestRsvpRequestRequest + +const verify: GuestRsvpVerifyRequest = { + id: EVENT_ID, + channel: "email", + email: "ada@example.org", + code: CODE, +} as GuestRsvpVerifyRequest + +function confirmations(mailer: FakeMailer): number { + return mailer.sent.filter((m) => m.template === "guest_confirmed").length +} + +describe("guest rsvp: a refused registration does not leave the guest on the list", () => { + it("refuses a sold-out event with CONFLICT, counts nobody and confirms nothing", async () => { + const h = build(() => Promise.resolve(refusal("full"))) + await h.service.requestCode(request, ctx) + + await expect(h.service.verifyCode(verify, ctx)).rejects.toMatchObject({ code: "CONFLICT" }) + await expect(h.repo.countActiveGuests(EVENT_ID)).resolves.toBe(0) + await expect(h.repo.goingCount(EVENT_ID)).resolves.toBe(3) + expect(confirmations(h.mailer)).toBe(0) + }) + + it.each([ + ["registration_closed", "CONFLICT"], + ["sales_closed", "CONFLICT"], + ["closed", "CONFLICT"], + ["waitlisted", "CONFLICT"], + ["party_too_large", "VALIDATION"], + ["ticket_type_not_found", "VALIDATION"], + ["access_code_required", "VALIDATION"], + ["access_code_invalid", "VALIDATION"], + ["banned", "NOT_FOUND"], + ] as const)("maps a %s refusal to %s and rolls the new guest back", async (outcome, code) => { + const h = build(() => Promise.resolve(refusal(outcome))) + await h.service.requestCode(request, ctx) + + await expect(h.service.verifyCode(verify, ctx)).rejects.toMatchObject({ code }) + await expect(h.repo.countActiveGuests(EVENT_ID)).resolves.toBe(0) + expect(confirmations(h.mailer)).toBe(0) + }) + + it("carries the bridge's per-question fields on an answers_invalid refusal", async () => { + const h = build(() => + Promise.resolve(refusal("answers_invalid", { fields: { "answers.q1": "required" } })), + ) + await h.service.requestCode(request, ctx) + + await expect(h.service.verifyCode(verify, ctx)).rejects.toMatchObject({ + code: "VALIDATION", + fields: { "answers.q1": "required" }, + }) + await expect(h.repo.countActiveGuests(EVENT_ID)).resolves.toBe(0) + }) + + it("rolls the new guest back when the bridge throws VALIDATION, so no uncancellable RSVP is left", async () => { + const h = build(() => Promise.reject(AppError.validation({ partySize: "too large" }))) + await h.service.requestCode(request, ctx) + + await expect(h.service.verifyCode(verify, ctx)).rejects.toMatchObject({ code: "VALIDATION" }) + await expect(h.repo.countActiveGuests(EVENT_ID)).resolves.toBe(0) + expect(confirmations(h.mailer)).toBe(0) + }) + + it("still joins when the guest is already registered under this guest row", async () => { + const h = build(() => Promise.resolve(refusal("already_registered"))) + await h.service.requestCode(request, ctx) + + const result = await h.service.verifyCode(verify, ctx) + expect(result.joined).toBe(true) + await expect(h.repo.countActiveGuests(EVENT_ID)).resolves.toBe(1) + expect(confirmations(h.mailer)).toBe(1) + }) + + it("never cancels an RSVP that existed before this verify, even when the bridge now refuses", async () => { + let answer: RegisterForEventResponse = refusal("registered") + const h = build(() => Promise.resolve(answer)) + await h.service.requestCode(request, ctx) + await h.service.verifyCode(verify, ctx) + + answer = refusal("registration_closed") + h.advance(61_000) + await h.service.requestCode(request, ctx) + const again = await h.service.verifyCode(verify, ctx) + + expect(again.joined).toBe(true) + expect(again.registrationOutcome).toBe("registration_closed") + await expect(h.repo.countActiveGuests(EVENT_ID)).resolves.toBe(1) + }) +}) + +describe("guest rsvp: the stored channel is the one the code was delivered on", () => { + it("records the OTP's channel even when the verify request names another", async () => { + const h = build(() => Promise.resolve(refusal("registered"))) + await h.service.requestCode(request, ctx) + + await h.service.verifyCode( + { + id: EVENT_ID, + channel: "sms", + phone: "ada@example.org", + code: CODE, + } as GuestRsvpVerifyRequest, + ctx, + ) + + expect(h.repo.guests[0]).toMatchObject({ + channel: "email", + email: "ada@example.org", + phone: null, + }) + }) +}) + +describe("the in-memory guest repository reports an insert as the Postgres upsert does", () => { + const upsert: UpsertGuestArgs = { + cleanupId: EVENT_ID, + name: "Ada Lovelace", + channel: "email", + contactKey: "ada@example.org", + email: "ada@example.org", + phone: null, + manageTokenHash: "a".repeat(64), + now: new Date("2026-08-25T12:00:00.000Z"), + } + + it("marks the first verify as created and a re-verify of the active guest as not", async () => { + const repo = new InMemoryGuestRsvpRepository() + + const first = await repo.upsertVerifiedGuest(upsert) + const again = await repo.upsertVerifiedGuest({ ...upsert, manageTokenHash: "b".repeat(64) }) + + expect(first.created).toBe(true) + expect(again).toEqual({ id: first.id, created: false }) + }) + + it("creates a fresh row once the earlier guest was cancelled", async () => { + const repo = new InMemoryGuestRsvpRepository() + const first = await repo.upsertVerifiedGuest(upsert) + await repo.cancelGuest(first.id, upsert.now) + + const fresh = await repo.upsertVerifiedGuest(upsert) + + expect(fresh.created).toBe(true) + expect(fresh.id).not.toBe(first.id) + }) +}) diff --git a/services/api/test/unit/guest-rsvp-service.test.ts b/services/api/test/unit/guest-rsvp-service.test.ts index 1e9232e8..241134e8 100644 --- a/services/api/test/unit/guest-rsvp-service.test.ts +++ b/services/api/test/unit/guest-rsvp-service.test.ts @@ -413,6 +413,7 @@ describe("guest rsvp: the SMS channel is gated and cost-capped", () => { const broken: CounterStore = { incr: () => Promise.reject(new Error("redis is down")), incrBy: () => Promise.reject(new Error("redis is down")), + decrBy: () => Promise.reject(new Error("redis is down")), } const h = build({ smsGuestEnabled: true, counters: broken }) @@ -805,7 +806,7 @@ describe("guest rsvp: cancelling", () => { expect(h.repo.guests).toHaveLength(1) }) - it("reports the registration outcome when the event has no seat left", async () => { + it("refuses the verify when the event has no seat left, taking the new guest back off the list", async () => { const h = build({ registrations: { register: () => @@ -813,11 +814,12 @@ describe("guest rsvp: cancelling", () => { }, }) await h.service.requestCode(emailRequest(), ctx) - const verified = await h.service.verifyCode(emailVerify(), ctx) - expect(verified.joined).toBe(true) - expect(verified.registration).toBeNull() - expect(verified.registrationOutcome).toBe("full") + await expect(h.service.verifyCode(emailVerify(), ctx)).rejects.toMatchObject({ + code: "CONFLICT", + }) + expect(h.repo.guests).toHaveLength(1) + expect(h.repo.guests[0]?.cancelledAt).not.toBeNull() }) it("refuses a stale consent version before the code is ever sent", async () => { diff --git a/services/api/test/unit/held-media-moderation-reporter.test.ts b/services/api/test/unit/held-media-moderation-reporter.test.ts new file mode 100644 index 00000000..c061d6a9 --- /dev/null +++ b/services/api/test/unit/held-media-moderation-reporter.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest" +import { drizzle } from "drizzle-orm/postgres-js" +import type { Db, Sql } from "../../src/db/client.js" +import { makeDrizzleMediaWorkerRepo } from "../../src/services/media-worker-repo.js" + +const REPORT_ID = "22222222-2222-2222-2222-222222222222" +const REPORTER_ID = "33333333-3333-3333-3333-333333333333" + +interface Call { + query: string + params: unknown[] +} + +function stubDb(contextRow: unknown[]) { + const calls: Call[] = [] + const client = { + options: { parsers: {}, serializers: {} }, + unsafe(query: string, params: unknown[]) { + calls.push({ query, params }) + const rows = /from "reports"/.test(query) ? [contextRow] : [] + return Object.assign(Promise.resolve([]), { values: () => Promise.resolve(rows) }) + }, + } + const db = drizzle(client as never) as unknown as Db + return { db, calls } +} + +function insertedMeta(calls: Call[]): Record { + const insert = calls.find((c) => /insert into "moderation_items"/.test(c.query)) + if (!insert) throw new Error("no moderation_items insert was sent") + const json = insert.params.find((p) => typeof p === "string" && p.includes('"reporter"')) + return JSON.parse(json as string) as Record +} + +describe("held-media moderation item names its reporter", () => { + it("shows a signed-in reporter's display name and links their account", async () => { + const { db, calls } = stubDb(["trash", "Pile of bags", "Los Angeles", "Dana R", REPORTER_ID]) + const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + + await repo.enqueueHeldModerationItem!({ + reportId: REPORT_ID, + reason: "NSFW model over threshold", + }) + + expect(insertedMeta(calls)).toMatchObject({ reporter: "Dana R", reporterUserId: REPORTER_ID }) + }) + + it("keeps 'Anonymous' for a report with no reporter account", async () => { + const { db, calls } = stubDb(["trash", null, null, null, null]) + const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + + await repo.enqueueHeldModerationItem!({ + reportId: REPORT_ID, + reason: "NSFW model over threshold", + }) + + expect(insertedMeta(calls)).toMatchObject({ reporter: "Anonymous", reporterUserId: null }) + }) +}) + +describe("held media folding into an open moderation item", () => { + it("clears an owner's consent marker so removing the item still strikes the author", async () => { + const calls: Call[] = [] + const client = { + options: { parsers: {}, serializers: {} }, + unsafe(query: string, params: unknown[]) { + calls.push({ query, params }) + const rows = /^update "moderation_items"/.test(query) ? [["item-1"]] : [] + return Object.assign(Promise.resolve([]), { values: () => Promise.resolve(rows) }) + }, + } + const db = drizzle(client as never) as unknown as Db + const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + + await repo.enqueueHeldModerationItem!({ + reportId: REPORT_ID, + reason: "NSFW model over threshold", + }) + + const fold = calls.find((c) => /^update "moderation_items"/.test(c.query)) + expect(fold?.query).toMatch(/- 'ownerTakedown'/) + expect(fold?.query).toMatch(/status = 'open'|"status" = \$/) + expect(calls.some((c) => /insert into "moderation_items"/.test(c.query))).toBe(false) + }) +}) diff --git a/services/api/test/unit/home-turf-routes.test.ts b/services/api/test/unit/home-turf-routes.test.ts index b2068d19..60e3d00b 100644 --- a/services/api/test/unit/home-turf-routes.test.ts +++ b/services/api/test/unit/home-turf-routes.test.ts @@ -8,7 +8,7 @@ import { loadEnv } from "../../src/env.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { enforceHomeTurfIpCap, - enforceHomeTurfRecipientCap, + claimHomeTurfConfirmation, HOME_TURF_EMAIL_LIMIT_PER_DAY, HOME_TURF_IP_LIMIT_PER_HOUR, HOME_TURF_RATE_LIMIT, @@ -264,7 +264,7 @@ describe("POST /forms/home-turf", () => { expect(sent[1]!.to).toBe("coach@example.org") }) - it(`still charges the budget on SUCCESS: a same-address resubmit 429s (limit ${HOME_TURF_EMAIL_LIMIT_PER_DAY}/day)`, async () => { + it(`still charges the budget on SUCCESS: a same-address resubmit reaches staff but gets no second confirmation (limit ${HOME_TURF_EMAIL_LIMIT_PER_DAY}/day)`, async () => { const { app, mailer } = await makeHarness() const first = await app.inject({ method: "POST", @@ -278,8 +278,8 @@ describe("POST /forms/home-turf", () => { url: "/forms/home-turf", payload: formPayload(), }) - expect(second.statusCode).toBe(429) - expect(second.json().code).toBe("RATE_LIMITED") + expect(second.statusCode).toBe(200) + expect(second.json()).toEqual({ ok: true }) const sent = outbounds(mailer) expect(sent).toHaveLength(3) @@ -338,58 +338,38 @@ describe("Turnstile action (F128)", () => { }) }) -describe("enforceHomeTurfRecipientCap (M8)", () => { - it(`allows ${HOME_TURF_EMAIL_LIMIT_PER_DAY} confirmation per address per day and 429s the next`, async () => { +describe("claimHomeTurfConfirmation (M8)", () => { + it(`allows ${HOME_TURF_EMAIL_LIMIT_PER_DAY} confirmation per address per day and refuses the next`, async () => { const counters = new InMemoryCounterStore(() => 0) for (let i = 0; i < HOME_TURF_EMAIL_LIMIT_PER_DAY; i++) { - await expect( - enforceHomeTurfRecipientCap("victim@example.org", counters), - ).resolves.toBeUndefined() + await expect(claimHomeTurfConfirmation("victim@example.org", counters)).resolves.toBe(true) } - await expect(enforceHomeTurfRecipientCap("victim@example.org", counters)).rejects.toMatchObject( - { - code: "RATE_LIMITED", - }, + await expect(claimHomeTurfConfirmation("victim@example.org", counters)).resolves.toBe(false) + await expect(claimHomeTurfConfirmation("someone-else@example.org", counters)).resolves.toBe( + true, ) - await expect( - enforceHomeTurfRecipientCap("someone-else@example.org", counters), - ).resolves.toBeUndefined() }) it("normalizes case and surrounding whitespace so the bucket cannot be trivially varied", async () => { const counters = new InMemoryCounterStore(() => 0) - await enforceHomeTurfRecipientCap("Victim@Example.org", counters) - await expect( - enforceHomeTurfRecipientCap(" victim@example.ORG ", counters), - ).rejects.toMatchObject({ - code: "RATE_LIMITED", - }) + await claimHomeTurfConfirmation("Victim@Example.org", counters) + await expect(claimHomeTurfConfirmation(" victim@example.ORG ", counters)).resolves.toBe(false) }) it("folds gmail +tags, dots and googlemail into one recipient bucket (F138)", async () => { const counters = new InMemoryCounterStore(() => 0) - await enforceHomeTurfRecipientCap("victim@gmail.com", counters) - await expect( - enforceHomeTurfRecipientCap("victim+abc@gmail.com", counters), - ).rejects.toMatchObject({ - code: "RATE_LIMITED", - }) - await expect( - enforceHomeTurfRecipientCap("v.i.c.t.i.m@googlemail.com", counters), - ).rejects.toMatchObject({ code: "RATE_LIMITED" }) + await claimHomeTurfConfirmation("victim@gmail.com", counters) + await expect(claimHomeTurfConfirmation("victim+abc@gmail.com", counters)).resolves.toBe(false) + await expect(claimHomeTurfConfirmation("v.i.c.t.i.m@googlemail.com", counters)).resolves.toBe( + false, + ) }) it("strips +tags for non-gmail providers, but keeps dots significant (F138)", async () => { const counters = new InMemoryCounterStore(() => 0) - await enforceHomeTurfRecipientCap("victim@example.org", counters) - await expect( - enforceHomeTurfRecipientCap("victim+1@example.org", counters), - ).rejects.toMatchObject({ - code: "RATE_LIMITED", - }) - await expect( - enforceHomeTurfRecipientCap("v.ictim@example.org", counters), - ).resolves.toBeUndefined() + await claimHomeTurfConfirmation("victim@example.org", counters) + await expect(claimHomeTurfConfirmation("victim+1@example.org", counters)).resolves.toBe(false) + await expect(claimHomeTurfConfirmation("v.ictim@example.org", counters)).resolves.toBe(true) }) }) diff --git a/services/api/test/unit/host-broadcast-caps.test.ts b/services/api/test/unit/host-broadcast-caps.test.ts index 00a40b6b..6192aba2 100644 --- a/services/api/test/unit/host-broadcast-caps.test.ts +++ b/services/api/test/unit/host-broadcast-caps.test.ts @@ -111,10 +111,76 @@ describe("broadcast caps", () => { ) }) + it("does not spend the host's cooldown on a send the per-event limit refused", async () => { + const counters = new InMemoryCounterStore() + const first = build({ config: { perEventPerDay: 1 }, counters }) + const secondHost = "00000000-0000-0000-0000-0000000000bb" + first.repo.seedHost(secondHost, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + await first.service.reserveSendSlot(EVENT, HOST) + expect(await capKind(() => first.service.reserveSendSlot(EVENT, secondHost))).toBe( + "per_event_per_day", + ) + + const roomier = build({ config: { perEventPerDay: 2 }, counters }) + roomier.repo.seedHost(secondHost, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + expect(await capKind(() => roomier.service.reserveSendSlot(EVENT, secondHost))).toBe("none") + }) + + it("does not charge the per-event limit for a send it refused", async () => { + const counters = new InMemoryCounterStore() + const first = build({ config: { perEventPerDay: 1 }, counters }) + const hosts = ["00000000-0000-0000-0000-0000000000bb", "00000000-0000-0000-0000-0000000000cc"] + for (const host of hosts) { + first.repo.seedHost(host, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + } + await first.service.reserveSendSlot(EVENT, HOST) + expect(await capKind(() => first.service.reserveSendSlot(EVENT, hosts[0]!))).toBe( + "per_event_per_day", + ) + + const roomier = build({ config: { perEventPerDay: 2 }, counters }) + roomier.repo.seedHost(hosts[1]!, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + expect(await capKind(() => roomier.service.reserveSendSlot(EVENT, hosts[1]!))).toBe("none") + }) + + it("leaves no cooldown behind when a double click is refused on both counters", async () => { + const counters = new InMemoryCounterStore() + const first = build({ config: { perEventPerDay: 1 }, counters }) + const secondHost = "00000000-0000-0000-0000-0000000000bb" + first.repo.seedHost(secondHost, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + await first.service.reserveSendSlot(EVENT, HOST) + const kinds = await Promise.all([ + capKind(() => first.service.reserveSendSlot(EVENT, secondHost)), + capKind(() => first.service.reserveSendSlot(EVENT, secondHost)), + ]) + expect(kinds.sort()).toEqual(["cooldown", "per_event_per_day"]) + + const roomier = build({ config: { perEventPerDay: 2 }, counters }) + roomier.repo.seedHost(secondHost, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + expect(await capKind(() => roomier.service.reserveSendSlot(EVENT, secondHost))).toBe("none") + }) + + it("keeps the per-event refusal when the cooldown cannot be given back", async () => { + const memory = new InMemoryCounterStore() + const counters: CounterStore = { + incr: (key, ttl) => memory.incr(key, ttl), + incrBy: (key, by, ttl) => memory.incrBy(key, by, ttl), + decrBy: () => Promise.reject(new Error("redis down")), + } + const first = build({ config: { perEventPerDay: 1 }, counters }) + const secondHost = "00000000-0000-0000-0000-0000000000bb" + first.repo.seedHost(secondHost, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + await first.service.reserveSendSlot(EVENT, HOST) + expect(await capKind(() => first.service.reserveSendSlot(EVENT, secondHost))).toBe( + "per_event_per_day", + ) + }) + it("fails CLOSED when the counter store is unavailable", async () => { const broken: CounterStore = { incr: () => Promise.reject(new Error("redis down")), incrBy: () => Promise.reject(new Error("redis down")), + decrBy: () => Promise.reject(new Error("redis down")), } const { service } = build({ counters: broken }) expect(await capKind(() => service.reserveSendSlot(EVENT, HOST))).toBe("counter_unavailable") @@ -124,6 +190,7 @@ describe("broadcast caps", () => { const broken: CounterStore = { incr: () => Promise.reject(new Error("redis down")), incrBy: () => Promise.reject(new Error("redis down")), + decrBy: () => Promise.reject(new Error("redis down")), } const { service } = build({ counters: broken }) expect(await service.reserveRecipientBudget(HOST, 10)).toBe(false) diff --git a/services/api/test/unit/host-broadcast-pipeline.test.ts b/services/api/test/unit/host-broadcast-pipeline.test.ts index 8014dce8..b38b1686 100644 --- a/services/api/test/unit/host-broadcast-pipeline.test.ts +++ b/services/api/test/unit/host-broadcast-pipeline.test.ts @@ -60,15 +60,22 @@ const EVENT_CONTEXT: EventBroadcastContext = { function notificationsStub( fail: boolean | ((userIds: string[]) => boolean) = false, + failedRecipients: (userIds: string[]) => string[] = () => [], ): NotificationService & { calls: unknown[] } { const calls: unknown[] = [] const fails = typeof fail === "function" ? fail : () => fail + const fanOut = (userIds: string[], input: unknown): Promise<{ failed: string[] }> => { + calls.push({ userIds, input }) + return fails(userIds) + ? Promise.reject(new Error("fan-out down")) + : Promise.resolve({ failed: failedRecipients(userIds) }) + } return { calls, - createNotifications: (userIds: string[], input: unknown) => { - calls.push({ userIds, input }) - return fails(userIds) ? Promise.reject(new Error("fan-out down")) : Promise.resolve() + createNotifications: async (userIds: string[], input: unknown) => { + await fanOut(userIds, input) }, + createNotificationsReportingFailures: fanOut, } as unknown as NotificationService & { calls: unknown[] } } @@ -90,6 +97,7 @@ function harness( mailer?: Mailer members?: number notificationsFail?: boolean | ((userIds: string[]) => boolean) + notificationsFailedRecipients?: (userIds: string[]) => string[] clock?: () => number } = {}, ): Harness { @@ -105,7 +113,10 @@ function harness( const clock = overrides.clock ?? (() => Date.now()) const cache = new InMemoryCacheClient(clock) const counters = new InMemoryCounterStore(clock) - const notifications = notificationsStub(overrides.notificationsFail ?? false) + const notifications = notificationsStub( + overrides.notificationsFail ?? false, + overrides.notificationsFailedRecipients, + ) const chunks: Array<{ broadcastId: string; chunkNo: number; startAfterSec?: number }> = [] const audits: Array<{ action: string; meta: Record }> = [] const config = { ...CONFIG, ...overrides.config } @@ -757,6 +768,32 @@ describe("broadcast in-app partial failure", () => { }) }) +describe("broadcast in-app per-recipient failure", () => { + it("re-pends a recipient whose row the fan-out reported unwritten, and only that one", async () => { + const h = harness({ + members: 0, + notificationsFailedRecipients: (userIds) => userIds.filter((id) => id === u(2)), + }) + h.repo.seedMembers(EVENT, [ + { userId: u(1), contact: { firstName: "Alex" } }, + { userId: u(2), contact: { firstName: "Bo" } }, + ]) + const id = await draftSending(h, ["inapp"]) + await h.pipeline.plan(id) + await h.pipeline.runChunk(id, 0) + + const byUser = new Map( + h.repo + .allDeliveries() + .filter((d) => d.broadcastId === id) + .map((d) => [d.userId, d]), + ) + expect(byUser.get(u(1))?.status).toBe("sent") + expect(byUser.get(u(2))?.status).toBe("pending") + expect((await h.repo.findById(id))?.status).toBe("sending") + }) +}) + describe("broadcast terminalization", () => { it("fails the broadcast after the attempt cap instead of churning forever", async () => { const h = harness({ members: 2, notificationsFail: true }) diff --git a/services/api/test/unit/host-broadcast-send-correctness.test.ts b/services/api/test/unit/host-broadcast-send-correctness.test.ts new file mode 100644 index 00000000..03166f87 --- /dev/null +++ b/services/api/test/unit/host-broadcast-send-correctness.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it, vi } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import type { Mailer } from "@civfix/shared/interfaces" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" +import { + BroadcastCapError, + capError, + makeBroadcastService, + type BroadcastConfig, +} from "../../src/services/host/broadcast-service.js" +import { makeBroadcastPipeline } from "../../src/services/host/broadcast-pipeline.js" +import { makeBroadcastLanes } from "../../src/services/host/broadcast-lanes.js" +import type { NotificationService } from "../../src/services/notification-service.js" +import type { Sql } from "../../src/db/client.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const EVENT = "00000000-0000-0000-0000-0000000000ee" +const HOST = "00000000-0000-0000-0000-0000000000aa" +const MEMBER = "00000000-0000-0000-0000-000000000001" + +const CONFIG: BroadcastConfig = { + killSwitch: false, + perEventPerDay: 5, + recipientsPerDay: 1000, + cooldownSec: 900, + minAccountAgeHours: 24, + maxRecipients: 5000, + chunkSize: 200, + emailConcurrency: 1, + emailRatePerSec: 1000, + linkAllowedHosts: [], + mailFromEvents: "events@civfix.org", + unsubscribeSigningKey: "unsubscribe-signing-key-for-tests-0123456789", + webBaseUrl: "https://civfix.org", + apiBaseUrl: "https://api.civfix.org", + eventUpdatePerEventPerHour: 3, +} + +function logSpy() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() } +} + +function build( + overrides: { + config?: Partial + enqueuePlan?: (broadcastId: string) => Promise + } = {}, +) { + const repo = new InMemoryBroadcastRepository() + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "UTC", + address: null, + status: "upcoming", + organizerUserId: HOST, + replyTo: null, + replyToVerified: false, + }) + repo.seedHost(HOST, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + const counters = new InMemoryCounterStore() + const logger = logSpy() + const service = makeBroadcastService({ + repo, + counters, + config: { ...CONFIG, ...overrides.config }, + mailer: new FakeMailer(), + enqueuePlan: overrides.enqueuePlan ?? (() => Promise.resolve()), + logger, + }) + return { repo, counters, service, logger } +} + +async function draft(repo: InMemoryBroadcastRepository): Promise { + const record = await repo.create({ + cleanupId: EVENT, + createdBy: HOST, + kind: "host_broadcast", + subject: "Bring gloves", + bodyMd: "See you at the meeting point.", + segment: { kind: "all_registered" }, + channels: ["email"], + status: "draft", + }) + return record.id +} + +describe("host daily recipient budget", () => { + it("does not charge the budget for a send it refused", async () => { + const { service } = build({ config: { recipientsPerDay: 1000 } }) + expect(await service.reserveRecipientBudget(HOST, 900)).toBe(true) + expect(await service.reserveRecipientBudget(HOST, 200)).toBe(false) + expect(await service.reserveRecipientBudget(HOST, 50)).toBe(true) + }) + + it("still refuses a send that would cross the budget after a refund", async () => { + const { service } = build({ config: { recipientsPerDay: 1000 } }) + expect(await service.reserveRecipientBudget(HOST, 900)).toBe(true) + expect(await service.reserveRecipientBudget(HOST, 200)).toBe(false) + expect(await service.reserveRecipientBudget(HOST, 101)).toBe(false) + expect(await service.reserveRecipientBudget(HOST, 100)).toBe(true) + expect(await service.reserveRecipientBudget(HOST, 1)).toBe(false) + }) + + it("keeps the refusal when the refund cannot be recorded, and logs it", async () => { + const { service, counters, logger } = build({ config: { recipientsPerDay: 10 } }) + counters.decrBy = () => Promise.reject(new Error("redis down")) + expect(await service.reserveRecipientBudget(HOST, 11)).toBe(false) + expect(logger.warn).toHaveBeenCalled() + }) + + it("gives a refused charge back by decrementing the charged counter", async () => { + const { service, counters } = build({ config: { recipientsPerDay: 1000 } }) + const incremented: string[] = [] + const decremented: Array<[string, number]> = [] + const incrBy = counters.incrBy.bind(counters) + const decrBy = counters.decrBy.bind(counters) + counters.incrBy = (key, by, ttl) => { + incremented.push(key) + return incrBy(key, by, ttl) + } + counters.decrBy = (key, by) => { + decremented.push([key, by]) + return decrBy(key, by) + } + + expect(await service.reserveRecipientBudget(HOST, 900)).toBe(true) + expect(await service.reserveRecipientBudget(HOST, 200)).toBe(false) + + const charged = incremented[0]! + expect(new Set(incremented)).toEqual(new Set([charged])) + expect(decremented).toEqual([[charged, 200]]) + expect(counters.peek(charged)).toBe(900) + }) + + it("refuses, fail closed, when the charge cannot be counted", async () => { + const { service, counters, logger } = build({ config: { recipientsPerDay: 1000 } }) + counters.incrBy = () => Promise.reject(new Error("redis down")) + expect(await service.reserveRecipientBudget(HOST, 1)).toBe(false) + expect(logger.warn).toHaveBeenCalled() + }) +}) + +describe("send when the plan job cannot be enqueued", () => { + it("returns the broadcast to draft and rethrows", async () => { + const { repo, service } = build({ enqueuePlan: () => Promise.reject(new Error("queue down")) }) + const id = await draft(repo) + await expect(service.send(EVENT, HOST, id)).rejects.toThrow("queue down") + const after = await repo.findById(id) + expect(after?.status).toBe("draft") + expect(after?.startedAt).toBeNull() + }) + + it("returns a scheduled broadcast to scheduled", async () => { + const { repo, service } = build({ enqueuePlan: () => Promise.reject(new Error("queue down")) }) + const id = await draft(repo) + await repo.transition(id, ["draft"], "scheduled", { scheduledAt: new Date(Date.now() + 1e6) }) + await expect(service.send(EVENT, HOST, id)).rejects.toThrow("queue down") + expect((await repo.findById(id))?.status).toBe("scheduled") + }) +}) + +describe("empty draft patch", () => { + it("returns the unchanged draft without writing", async () => { + const { repo, service } = build() + const id = await draft(repo) + const updateDraft = vi.spyOn(repo, "updateDraft") + const dto = await service.update(EVENT, HOST, { id: EVENT, broadcastId: id }) + expect(dto.id).toBe(id) + expect(dto.subject).toBe("Bring gloves") + expect(updateDraft).not.toHaveBeenCalled() + }) + + it("still refuses an empty patch on a message that already left draft", async () => { + const { repo, service } = build() + const id = await draft(repo) + await repo.transition(id, ["draft"], "sending", { startedAt: new Date() }) + await expect(service.update(EVENT, HOST, { id: EVENT, broadcastId: id })).rejects.toMatchObject( + { code: "CONFLICT" }, + ) + }) + + it("names the broken invariant instead of a reduce TypeError in the SQL repository", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleBroadcastRepository(fake.sql as unknown as Sql) + await expect(repo.updateDraft(EVENT, MEMBER, {})).rejects.toThrow(/at least one column/) + expect(fake.statements).toHaveLength(0) + }) +}) + +describe("test-send cap", () => { + it("reports its own cap kind and copy, not the per-event daily limit", async () => { + const { repo, service } = build() + const id = await draft(repo) + repo.seedMembers(EVENT, [{ userId: HOST }]) + let kind = "none" + for (let i = 0; i < 10 && kind === "none"; i += 1) { + try { + await service.testSend(EVENT, HOST, id) + } catch (err) { + if (!(err instanceof BroadcastCapError)) throw err + kind = err.kind + } + } + expect(kind).toBe("test_sends") + expect(capError("test_sends").code).toBe("RATE_LIMITED") + expect(capError("test_sends").message).not.toMatch(/daily message limit/) + }) +}) + +describe("hard-bounce suppression write failure", () => { + it("is logged, and the delivery is still recorded as a permanent failure", async () => { + const repo = new InMemoryBroadcastRepository() + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "UTC", + address: null, + status: "upcoming", + organizerUserId: HOST, + replyTo: null, + replyToVerified: false, + }) + repo.seedHost(HOST, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + repo.seedMembers(EVENT, [{ userId: MEMBER }]) + repo.seedGuests(EVENT, []) + repo.suppressEmail = () => Promise.reject(new Error("db down")) + const bounces: Mailer = { + sendOtp: () => Promise.resolve(), + sendTransactional: () => Promise.resolve(), + sendOutbound: () => Promise.reject({ responseCode: 550, response: "550 no such user" }), + } + const logger = logSpy() + const service = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config: CONFIG, + mailer: bounces, + enqueuePlan: () => Promise.resolve(), + }) + const pipeline = makeBroadcastPipeline({ + repo, + service, + notifications: { + createNotifications: () => Promise.resolve(), + createNotificationsReportingFailures: () => Promise.resolve({ failed: [] }), + } as unknown as NotificationService, + mailer: bounces, + cache: new InMemoryCacheClient(), + config: CONFIG, + mailDomain: "civfix.org", + enqueueChunk: () => Promise.resolve(), + audit: () => Promise.resolve(), + logger, + }) + const id = await draft(repo) + await repo.transition(id, ["draft"], "sending", { startedAt: new Date() }) + await pipeline.plan(id) + await pipeline.runChunk(id, 0) + + expect(repo.allDeliveries()[0]?.failureKind).toBe("permanent") + const messages = logger.warn.mock.calls.map((call) => String(call[1])) + expect(messages.some((m) => /suppression/.test(m))).toBe(true) + const logged = JSON.stringify(logger.warn.mock.calls) + expect(logged).not.toContain("@") + }) +}) + +describe("automated lanes stamp startedAt on insert", () => { + it("gives a reminder broadcast a start time", async () => { + const repo = new InMemoryBroadcastRepository() + const at = new Date("2026-01-31T17:00:00Z") + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "UTC", + address: null, + status: "upcoming", + organizerUserId: HOST, + replyTo: null, + replyToVerified: false, + }) + repo.listDueReminders = () => Promise.resolve([{ cleanupId: EVENT, offsetMin: 1440 }]) + const plans: string[] = [] + const lanes = makeBroadcastLanes({ + repo, + counters: new InMemoryCounterStore(), + perEventPerHour: 3, + enqueuePlan: (broadcastId) => { + plans.push(broadcastId) + return Promise.resolve() + }, + now: () => at, + }) + await lanes.runReminderSweep() + expect(plans).toHaveLength(1) + expect((await repo.findById(plans[0]!))?.startedAt?.toISOString()).toBe(at.toISOString()) + }) + + it("stamps the cancellation and event-update lanes without a second status write", async () => { + const repo = new InMemoryBroadcastRepository() + const at = new Date("2026-01-31T17:00:00Z") + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "UTC", + address: null, + status: "upcoming", + organizerUserId: HOST, + replyTo: null, + replyToVerified: false, + }) + const transition = vi.spyOn(repo, "transition") + const lanes = makeBroadcastLanes({ + repo, + counters: new InMemoryCounterStore(), + perEventPerHour: 3, + enqueuePlan: () => Promise.resolve(), + now: () => at, + }) + const updated = await lanes.eventUpdated(EVENT) + const cancelled = await lanes.eventCancelled(EVENT, null) + expect(updated.status).toBe("started") + const updatedId = updated.status === "started" ? updated.broadcastId : "" + expect((await repo.findById(updatedId))?.startedAt?.toISOString()).toBe(at.toISOString()) + expect((await repo.findById(cancelled!))?.startedAt?.toISOString()).toBe(at.toISOString()) + expect(transition).not.toHaveBeenCalled() + }) +}) + +describe("in-memory broadcast repository matches the SQL semantics", () => { + it("only dedupes the kinds a unique index covers", async () => { + const repo = new InMemoryBroadcastRepository() + const input = { + cleanupId: EVENT, + createdBy: null, + kind: "event_updated" as const, + subject: "s", + bodyMd: "b", + segment: { kind: "all_registered" as const }, + channels: ["email" as const], + status: "sending" as const, + } + expect(await repo.createIfAbsent(input)).not.toBeNull() + expect(await repo.createIfAbsent(input)).not.toBeNull() + const cancelled = { ...input, kind: "event_cancelled" as const } + expect(await repo.createIfAbsent(cancelled)).not.toBeNull() + expect(await repo.createIfAbsent(cancelled)).toBeNull() + const reminder = { ...input, kind: "reminder" as const, reminderOffsetMin: 180 } + expect(await repo.createIfAbsent(reminder)).not.toBeNull() + expect(await repo.createIfAbsent(reminder)).toBeNull() + }) + + it("pages the broadcast list by its cursor", async () => { + const { repo, service } = build() + const ids: string[] = [] + for (let i = 0; i < 3; i += 1) ids.push(await draft(repo)) + const first = await service.list(EVENT, { id: EVENT, limit: 2 }) + expect(first.items).toHaveLength(2) + expect(first.nextCursor).not.toBeNull() + const second = await service.list(EVENT, { + id: EVENT, + limit: 2, + cursor: first.nextCursor ?? undefined, + }) + const seen = [...first.items, ...second.items].map((item) => item.id) + expect(new Set(seen).size).toBe(3) + expect(new Set(seen)).toEqual(new Set(ids)) + }) + + it("pages the delivery list by its cursor", async () => { + const repo = new InMemoryBroadcastRepository() + const id = await draft(repo) + await repo.insertDeliveries( + Array.from({ length: 3 }, (_, i) => ({ + broadcastId: id, + chunkNo: 0, + recipientKind: "member" as const, + userId: `00000000-0000-0000-0000-00000000010${i}`, + guestId: null, + channel: "email" as const, + })), + ) + const first = await repo.listDeliveries({ broadcastId: id, cursor: null, limit: 2 }) + const last = first.at(-1)! + const second = await repo.listDeliveries({ + broadcastId: id, + cursor: { at: last.createdAt, atText: last.cursorAt, id: last.id }, + limit: 2, + }) + const seen = [...first, ...second].map((row) => row.id) + expect(new Set(seen).size).toBe(3) + }) +}) diff --git a/services/api/test/unit/host-export-csv.test.ts b/services/api/test/unit/host-export-csv.test.ts index 1bd62ae8..091cfd04 100644 --- a/services/api/test/unit/host-export-csv.test.ts +++ b/services/api/test/unit/host-export-csv.test.ts @@ -110,6 +110,13 @@ function harness( } return Promise.resolve(current) }, + recordObjectKey: (_id, args) => { + if (current.status !== "running" || args.runToken !== current.runToken) { + return Promise.resolve(false) + } + current = { ...current, r2Key: args.r2Key } + return Promise.resolve(true) + }, markReady: (_id, args) => { if (args.runToken !== current.runToken) return Promise.resolve(null) const { runToken: _ignored, ...fields } = args @@ -118,13 +125,15 @@ function harness( }, markFailed: (_id, errorCode) => { current = { ...current, status: "failed", errorCode } - return Promise.resolve() + return Promise.resolve(true) }, listExpired: () => Promise.resolve(current.status === "ready" ? [current] : []), markExpired: () => { current = { ...current, status: "expired", r2Key: null } return Promise.resolve() }, + listOrphaned: () => Promise.resolve([]), + releaseObject: () => Promise.resolve(false), deleteOlderThan: () => Promise.resolve(0), } const service = makeHostExportService({ @@ -165,7 +174,7 @@ describe("host export build", () => { ]) expect(await h.service.run(EXPORT_ID)).toEqual({ status: "ready" }) const put = h.puts[0]! - expect(put.key).toBe("exports/host/2026/02/00000000-0000-0000-0000-0000000000e1.csv") + expect(put.key).toBe("exports/host/2026/02/run-1/00000000-0000-0000-0000-0000000000e1.csv") expect(put.meta).toMatchObject({ contentType: "text/csv; charset=utf-8" }) expect( String(put.meta && (put.meta as { contentDisposition: string }).contentDisposition), @@ -218,6 +227,19 @@ describe("host export build", () => { expect(h.puts).toHaveLength(1) }) + it("gives every run of one export its own object, so a superseded run cannot discard the winner's", async () => { + const h = harness([["1", "x"]]) + await h.service.run(EXPORT_ID) + h.setCurrent({ status: "queued" }) + await h.service.run(EXPORT_ID) + + const [first, second] = h.puts.map((p) => p.key) + expect(first).not.toBe(second) + expect(first).toMatch(/^exports\/host\/2026\/02\/run-1\//) + expect(second).toMatch(/^exports\/host\/2026\/02\/run-2\//) + expect(h.current().r2Key).toBe(second) + }) + it("mints a SHORT forceSigned download url", async () => { const h = harness([["1", "x"]]) await h.service.run(EXPORT_ID) @@ -250,6 +272,7 @@ describe("host export build", () => { ...({} as HostExportRepository), listExpired: () => Promise.resolve([h.current()]), markExpired: () => Promise.reject(new Error("should not be called")), + listOrphaned: () => Promise.resolve([]), } as HostExportRepository, storage: { put: () => Promise.resolve(), @@ -371,6 +394,7 @@ describe("host export claim token", () => { expect(await h.service.run(EXPORT_ID)).toEqual({ status: "skipped" }) expect(h.current().status).toBe("running") expect(h.current().r2Key).toBeNull() - expect(h.deletes).toEqual([h.puts[0]!.key]) + expect(h.puts).toHaveLength(0) + expect(h.deletes).toEqual([]) }) }) diff --git a/services/api/test/unit/host-export-lifecycle.test.ts b/services/api/test/unit/host-export-lifecycle.test.ts new file mode 100644 index 00000000..b945a35e --- /dev/null +++ b/services/api/test/unit/host-export-lifecycle.test.ts @@ -0,0 +1,396 @@ +import { afterEach, describe, expect, it } from "vitest" +import { + EXPORT_ABANDON_AFTER_MS, + EXPORT_RUN_STALE_MS, + makeHostExportService, +} from "../../src/services/host/export-service.js" +import { + registerHostExportBuilder, + resetHostExportBuildersForTests, +} from "../../src/services/host/export-builders.js" +import { + makeDrizzleHostExportRepository, + type HostExportRecord, + type HostExportRepository, +} from "../../src/services/host/export-repository.drizzle.js" +import type { Sql } from "../../src/db/client.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const EXPORT_ID = "00000000-0000-0000-0000-0000000000e1" +const EVENT = "00000000-0000-0000-0000-0000000000ee" +const HOST = "00000000-0000-0000-0000-0000000000aa" +const NOW = new Date("2026-02-05T12:00:00Z") + +function record(overrides: Partial = {}): HostExportRecord { + return { + id: EXPORT_ID, + cleanupId: EVENT, + organizationId: null, + requestedBy: HOST, + kind: "roster", + filters: {}, + status: "queued", + r2Key: null, + rowCount: null, + byteSize: null, + truncated: false, + errorCode: null, + runToken: null, + requestedAt: new Date("2026-02-05T11:59:00Z"), + startedAt: null, + completedAt: null, + expiresAt: null, + ...overrides, + } +} + +function harness( + opts: { + markReady?: HostExportRepository["markReady"] + deleteFails?: boolean + initial?: Partial + onDelete?: (key: string) => Promise + } = {}, +) { + registerHostExportBuilder("roster", { + filename: () => "civfix-roster-test.csv", + header: () => Promise.resolve(["a"]), + provenance: () => Promise.resolve([]), + rows: async function* () { + yield ["1"] + }, + }) + let current = record(opts.initial) + const objects = new Set() + const repo: HostExportRepository = { + create: () => Promise.resolve(current), + findById: () => Promise.resolve(current), + listForEvent: () => Promise.resolve([current]), + listForOrganization: () => Promise.resolve([]), + claimForRun: (_id, staleBefore) => { + const reclaimable = + current.status === "running" && + current.startedAt !== null && + current.startedAt < staleBefore + if (current.status !== "queued" && !reclaimable) return Promise.resolve(null) + current = { ...current, status: "running", startedAt: NOW, runToken: "run-1" } + return Promise.resolve(current) + }, + recordObjectKey: ( + _id, + args: { r2Key: string; runToken: string | null; replaces?: string | null }, + ) => { + if (current.status !== "running" || current.runToken !== args.runToken) { + return Promise.resolve(false) + } + if (args.replaces !== undefined && current.r2Key !== args.replaces) { + return Promise.resolve(false) + } + current = { ...current, r2Key: args.r2Key } + return Promise.resolve(true) + }, + markReady: + opts.markReady ?? + ((_id, args) => { + if (current.status !== "running" || current.runToken !== args.runToken) { + return Promise.resolve(null) + } + const { runToken: _ignored, ...fields } = args + current = { ...current, status: "ready", ...fields } + return Promise.resolve(current) + }), + markFailed: (_id: string, errorCode: string, runToken?: string | null) => { + const open = current.status === "queued" || current.status === "running" + if (!open || (runToken !== undefined && current.runToken !== runToken)) { + return Promise.resolve(false) + } + current = { ...current, status: "failed", errorCode } + return Promise.resolve(true) + }, + listExpired: () => Promise.resolve([]), + markExpired: () => Promise.resolve(), + listOrphaned: ({ staleBefore }) => { + const orphaned = + (current.status === "failed" && current.r2Key !== null) || + (current.status === "running" && + current.startedAt !== null && + current.startedAt < staleBefore) || + (current.status === "queued" && current.requestedAt < staleBefore) + return Promise.resolve(orphaned ? [current] : []) + }, + releaseObject: (target, errorCode) => { + if (current.status !== target.status || current.runToken !== target.runToken) { + return Promise.resolve(false) + } + current = { + ...current, + status: "failed", + errorCode: current.errorCode ?? errorCode, + r2Key: null, + } + return Promise.resolve(true) + }, + deleteOlderThan: () => Promise.resolve(0), + } + let deleteFails = opts.deleteFails ?? false + const service = makeHostExportService({ + repo, + storage: { + put: (key) => { + objects.add(key) + return Promise.resolve() + }, + presignGet: (key) => Promise.resolve(`https://signed.example/${key}`), + delete: async (key) => { + if (deleteFails) throw new Error("r2 down") + objects.delete(key) + await opts.onDelete?.(key) + }, + }, + config: { maxRows: 100, maxBytes: 1_000_000, ttlHours: 24 }, + now: () => NOW, + }) + return { + service, + repo, + objects, + current: () => current, + setCurrent: (patch: Partial) => { + current = { ...current, ...patch } + }, + failDeletes: () => { + deleteFails = true + }, + storageRecovers: () => { + deleteFails = false + }, + } +} + +afterEach(() => { + resetHostExportBuildersForTests() +}) + +describe("host export objects never outlive a failed run", () => { + it("deletes the object it wrote when the run fails after the upload", async () => { + const h = harness({ markReady: () => Promise.reject(new Error("db blip")) }) + expect(await h.service.run(EXPORT_ID)).toEqual({ status: "failed" }) + expect(h.objects.size).toBe(0) + expect(h.current().status).toBe("failed") + expect(h.current().r2Key).toBeNull() + }) + + it("keeps the key on the failed row when that delete fails, so the reaper finishes it", async () => { + const h = harness({ + markReady: () => Promise.reject(new Error("db blip")), + deleteFails: true, + }) + await h.service.run(EXPORT_ID) + expect(h.objects.size).toBe(1) + expect(h.current().r2Key).not.toBeNull() + + h.storageRecovers() + await h.service.reap(10) + expect(h.objects.size).toBe(0) + expect(h.current().r2Key).toBeNull() + expect(h.current().status).toBe("failed") + expect(h.current().errorCode).toBe("build_failed") + }) + + it("reaps the object of a run that crashed and was never reclaimed", async () => { + const key = "exports/host/2026/02/00000000-0000-0000-0000-0000000000e1.csv" + const h = harness({ + initial: { + status: "running", + runToken: "run-crashed", + r2Key: key, + startedAt: new Date(NOW.getTime() - EXPORT_ABANDON_AFTER_MS - 1), + }, + }) + h.objects.add(key) + await h.service.reap(10) + expect(h.objects.has(key)).toBe(false) + expect(h.current().status).toBe("failed") + expect(h.current().r2Key).toBeNull() + }) + + it("only lets the retention lane delete rows that no longer point at an object", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleHostExportRepository(fake.sql as unknown as Sql) + await repo.deleteOlderThan(new Date("2025-11-01T00:00:00Z"), 100) + expect(fake.statements[0]!.sql).toMatch(/r2_key IS NULL/) + }) +}) + +describe("host export run superseded after its upload", () => { + it("discards the object it uploaded and reports the run skipped", async () => { + const h = harness({ markReady: () => Promise.resolve(null) }) + expect(await h.service.run(EXPORT_ID)).toEqual({ status: "skipped" }) + expect(h.objects.size).toBe(0) + }) +}) + +describe("host export requests that never started", () => { + it("fails a queued export whose job never ran, instead of showing it queued forever", async () => { + const h = harness({ + initial: { requestedAt: new Date(NOW.getTime() - EXPORT_ABANDON_AFTER_MS - 1) }, + }) + await h.service.reap(10) + expect(h.current().status).toBe("failed") + expect(h.current().errorCode).toBe("not_started") + }) + + it("leaves a freshly queued export alone", async () => { + const h = harness() + await h.service.reap(10) + expect(h.current().status).toBe("queued") + }) +}) + +describe("host export download", () => { + it("refuses a ready export whose object has expired but was not reaped yet", async () => { + const h = harness() + await expect( + h.service.downloadUrl( + record({ + status: "ready", + r2Key: "exports/host/2026/02/x.csv", + expiresAt: new Date(NOW.getTime() - 1000), + }), + ), + ).rejects.toMatchObject({ code: "CONFLICT", message: "That export has expired." }) + }) +}) + +describe("the reaper fences a stale run before deleting its object", () => { + it("never leaves a ready row pointing at an object it deleted", async () => { + const key = "exports/host/2026/02/run-live/00000000-0000-0000-0000-0000000000e1.csv" + let lateReady: HostExportRecord | null | undefined + const h: ReturnType = harness({ + initial: { + status: "running", + runToken: "run-live", + r2Key: key, + startedAt: new Date(NOW.getTime() - EXPORT_ABANDON_AFTER_MS - 1), + }, + onDelete: async () => { + lateReady = await h.repo.markReady(EXPORT_ID, { + r2Key: key, + rowCount: 1, + byteSize: 2, + truncated: false, + expiresAt: new Date(NOW.getTime() + 3_600_000), + runToken: "run-live", + }) + }, + }) + h.objects.add(key) + + await h.service.reap(10) + + expect(lateReady).toBeNull() + expect(h.objects.has(key)).toBe(false) + expect(h.current().status).toBe("failed") + expect(h.current().r2Key).toBeNull() + }) + + it("leaves the key on the fenced row when the delete fails, for the next pass", async () => { + const key = "exports/host/2026/02/run-crashed/00000000-0000-0000-0000-0000000000e1.csv" + const h = harness({ + initial: { + status: "running", + runToken: "run-crashed", + r2Key: key, + startedAt: new Date(NOW.getTime() - EXPORT_ABANDON_AFTER_MS - 1), + }, + deleteFails: true, + }) + h.objects.add(key) + + await h.service.reap(10) + expect(h.current().status).toBe("failed") + expect(h.current().r2Key).toBe(key) + + h.storageRecovers() + await h.service.reap(10) + expect(h.objects.has(key)).toBe(false) + expect(h.current().r2Key).toBeNull() + }) +}) + +describe("a re-claimed run never loses the previous run's object", () => { + const OLD_KEY = "exports/host/2026/02/run-crashed/00000000-0000-0000-0000-0000000000e1.csv" + const staleRun = { + status: "running" as const, + runToken: "run-crashed", + r2Key: OLD_KEY, + startedAt: new Date(NOW.getTime() - EXPORT_RUN_STALE_MS - 1), + } + + it("deletes the object a crashed run uploaded before recording its own", async () => { + const h = harness({ initial: staleRun }) + h.objects.add(OLD_KEY) + + expect(await h.service.run(EXPORT_ID)).toEqual({ status: "ready" }) + + expect(h.objects.has(OLD_KEY)).toBe(false) + expect(h.objects.size).toBe(1) + expect(h.current().r2Key).not.toBe(OLD_KEY) + expect(h.objects.has(h.current().r2Key ?? "")).toBe(true) + }) + + it("fails the run and keeps the old key on the row when that delete fails", async () => { + const h = harness({ initial: staleRun, deleteFails: true }) + h.objects.add(OLD_KEY) + + expect(await h.service.run(EXPORT_ID)).toEqual({ status: "failed" }) + + expect(h.current().status).toBe("failed") + expect(h.current().r2Key).toBe(OLD_KEY) + expect([...h.objects]).toEqual([OLD_KEY]) + }) + + it("refuses to record a key over a different one it was not told to replace", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleHostExportRepository(fake.sql as unknown as Sql) + await repo.recordObjectKey(EXPORT_ID, { + r2Key: "exports/host/new.csv", + runToken: "run-1", + replaces: OLD_KEY, + }) + const stmt = fake.statements[0]! + expect(stmt.sql).toMatch(/r2_key IS NOT DISTINCT FROM \?/) + expect(stmt.values).toContain(OLD_KEY) + }) +}) + +describe("a superseded run cannot fail the live run", () => { + it("guards the failure write with the run token", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleHostExportRepository(fake.sql as unknown as Sql) + await repo.markFailed(EXPORT_ID, "build_failed", "run-1") + const stmt = fake.statements[0]! + expect(stmt.sql).toMatch(/run_token IS NOT DISTINCT FROM \?/) + expect(stmt.values).toContain("run-1") + }) + + it("leaves the newer claim running when the older run's build throws", async () => { + const h = harness() + resetHostExportBuildersForTests() + registerHostExportBuilder("roster", { + filename: () => "civfix-roster-test.csv", + header: () => Promise.resolve(["a"]), + provenance: () => Promise.resolve([]), + // eslint-disable-next-line require-yield + rows: async function* () { + h.setCurrent({ runToken: "run-newer" }) + throw new Error("builder blew up") + }, + }) + + expect(await h.service.run(EXPORT_ID)).toEqual({ status: "failed" }) + + expect(h.current().status).toBe("running") + expect(h.current().runToken).toBe("run-newer") + }) +}) diff --git a/services/api/test/unit/host-keyset-cursor-precision.test.ts b/services/api/test/unit/host-keyset-cursor-precision.test.ts new file mode 100644 index 00000000..77cfe81e --- /dev/null +++ b/services/api/test/unit/host-keyset-cursor-precision.test.ts @@ -0,0 +1,314 @@ +/** + * Host broadcast lists page on now()-stamped timestamps, and plan() bulk-inserts every delivery of a + * broadcast in one transaction, so many rows share one instant. A cursor built from the millisecond Date + * postgres.js returns skips every row in the anchor's millisecond; these lists must carry the column's + * microsecond text instead. + */ + +import { describe, expect, it } from "vitest" +import Fastify, { type FastifyInstance } from "fastify" +import { FakeMailer } from "@civfix/shared/fakes" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import type { Container } from "../../src/di.js" +import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" +import { + makeBroadcastService, + type BroadcastConfig, +} from "../../src/services/host/broadcast-service.js" +import { + makeAnnouncementService, + type AnnouncementServiceDeps, +} from "../../src/services/host/announcement-service.js" +import { makeDrizzleAdminEventPageRepository } from "../../src/services/host/admin-pages-repository.drizzle.js" +import { registerAdminBroadcastRoutes } from "../../src/routes/admin/broadcasts.routes.js" +import { registerAdminEventPageRoutes } from "../../src/routes/admin/pages.routes.js" + +const AT = new Date("2026-09-01T10:00:00.123Z") +const AT_TEXT = "2026-09-01T10:00:00.123456Z" +const LEGACY_AT_TEXT = "2026-09-01T10:00:00.123Z" +const ID_A = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" +const ID_B = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e02" +const EVENT = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e0e" +const OPERATOR = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e0f" +const NEXT = `${AT_TEXT}|${ID_A}` + +const CONFIG: BroadcastConfig = { + killSwitch: false, + perEventPerDay: 3, + recipientsPerDay: 2000, + cooldownSec: 900, + minAccountAgeHours: 24, + maxRecipients: 5000, + chunkSize: 2, + emailConcurrency: 2, + emailRatePerSec: 1000, + linkAllowedHosts: [], + mailFromEvents: "events@civfix.org", + unsubscribeSigningKey: "unsubscribe-signing-key-for-tests-0123456789", + webBaseUrl: "https://civfix.org", + apiBaseUrl: "https://api.civfix.org", + eventUpdatePerEventPerHour: 3, +} + +/** Two rows sharing one millisecond, so a limit-1 page has a next cursor anchored on the first. */ +function twoRows(extra: Record, idKey = "id"): Record[] { + return [ + { ...extra, [idKey]: ID_A, cursor_at: AT_TEXT }, + { ...extra, [idKey]: ID_B, cursor_at: "2026-09-01T10:00:00.123001Z" }, + ] +} + +function broadcastRow(extra: Record = {}): Record { + return { + cleanup_id: EVENT, + created_by: OPERATOR, + kind: "host_broadcast", + reminder_offset_min: null, + status: "sent", + subject: "Bring gloves", + body_md: "See you there.", + cta_label: null, + cta_url: null, + segment: { kind: "all_registered" }, + channels: ["email"], + reply_to: null, + scheduled_at: null, + planned_at: AT, + started_at: AT, + finished_at: AT, + chunk_size: 200, + chunk_count: 1, + recipient_count: 2, + sent_count: 2, + failed_count: 0, + suppressed_count: 0, + content_scrubbed_at: null, + created_at: AT, + updated_at: AT, + ...extra, + } +} + +function lastStatement(ctl: FakeSqlControl, match: RegExp): { sql: string; values: unknown[] } { + const hit = [...ctl.statements].reverse().find((s) => match.test(s.sql)) + if (hit === undefined) throw new Error(`no statement matched ${String(match)}`) + return hit +} + +function expectExactAnchor(ctl: FakeSqlControl, match: RegExp, atText = AT_TEXT): void { + const stmt = lastStatement(ctl, match) + expect(stmt.sql).toMatch(/to_char\([\s\S]* AT TIME ZONE 'UTC', \?\) AS cursor_at/) + expect(stmt.values).toContain(atText) + expect(stmt.values.some((v) => v instanceof Date && v.getTime() === AT.getTime())).toBe(false) + expect(stmt.sql).toContain("?::timestamptz") +} + +function broadcastService(handlers: SqlHandler[]) { + const ctl = makeFakeSql(handlers) + const repo = makeDrizzleBroadcastRepository(ctl.sql as unknown as Sql) + const service = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config: CONFIG, + mailer: new FakeMailer(), + enqueuePlan: () => Promise.resolve(), + }) + return { ctl, repo, service } +} + +describe("host broadcast list cursors", () => { + const LIST = /FROM broadcasts\s+WHERE cleanup_id = \?/ + + it("encodes the microsecond instant and binds it back as text", async () => { + const { ctl, service } = broadcastService([{ match: LIST, rows: twoRows(broadcastRow()) }]) + const first = await service.list(EVENT, { id: EVENT, limit: 1 }) + expect(first.nextCursor).toBe(NEXT) + + await service.list(EVENT, { id: EVENT, limit: 1, cursor: NEXT }) + expectExactAnchor(ctl, LIST) + }) + + it("still accepts a legacy millisecond cursor as the same instant", async () => { + const { ctl, service } = broadcastService([{ match: LIST, rows: [] }]) + await service.list(EVENT, { id: EVENT, limit: 1, cursor: `${LEGACY_AT_TEXT}|${ID_A}` }) + expectExactAnchor(ctl, LIST, LEGACY_AT_TEXT) + }) +}) + +describe("host broadcast delivery cursors", () => { + const DELIVERIES = /FROM broadcast_deliveries/ + const delivery = { + channel: "email", + recipient_kind: "member", + status: "sent", + suppression_reason: null, + failure_kind: null, + attempts: 1, + sent_at: AT, + created_at: AT, + } + + it("encodes the microsecond instant of deliveries one plan inserted together", async () => { + const { ctl, service } = broadcastService([ + { match: DELIVERIES, rows: twoRows(delivery) }, + { match: /FROM broadcasts/, rows: [broadcastRow({ id: ID_A })] }, + ]) + const first = await service.listDeliveries(EVENT, { id: EVENT, broadcastId: ID_A, limit: 1 }) + expect(first.nextCursor).toBe(NEXT) + + await service.listDeliveries(EVENT, { id: EVENT, broadcastId: ID_A, limit: 1, cursor: NEXT }) + expectExactAnchor(ctl, DELIVERIES) + }) +}) + +describe("event announcement cursors", () => { + const ANNOUNCEMENTS = /AND kind = \?/ + + it("encodes the microsecond instant and binds it back as text", async () => { + const ctl = makeFakeSql([ + { match: ANNOUNCEMENTS, rows: twoRows(broadcastRow({ kind: "announcement" })) }, + ]) + const service = makeAnnouncementService({ + repo: makeDrizzleBroadcastRepository(ctl.sql as unknown as Sql), + identities: { + authorsFor: () => Promise.resolve(new Map()), + organizationFor: () => Promise.resolve(null), + }, + broadcasts: {} as AnnouncementServiceDeps["broadcasts"], + config: CONFIG, + }) + const first = await service.list(EVENT, { id: EVENT, limit: 1 }, { host: true }) + expect(first.nextCursor).toBe(NEXT) + + await service.list(EVENT, { id: EVENT, limit: 1, cursor: NEXT }, { host: true }) + expectExactAnchor(ctl, ANNOUNCEMENTS) + }) +}) + +async function adminApp(handlers: SqlHandler[]): Promise<{ + app: FastifyInstance + ctl: FakeSqlControl +}> { + const ctl = makeFakeSql(handlers) + const sql = ctl.sql as unknown as Sql + const container = { + env: { NODE_ENV: "test", WEB_ORIGINS: ["https://civfix.org"] }, + csrf: { protect: (_req: unknown, _reply: unknown, done: () => void) => done() }, + getDb: () => ({ sql }), + } as unknown as Container + const app = Fastify({ logger: false }) + app.setErrorHandler(makeErrorHandler()) + app.setNotFoundHandler(makeNotFoundHandler()) + const alwaysAllowed = () => () => + Promise.resolve({ isAllowed: true, isExceeded: false, max: 1, remaining: 1, ttlInSeconds: 0 }) + ;(app.decorate as (name: string, value: unknown) => void)("createRateLimit", alwaysAllowed) + ;(app.decorateRequest as (name: string, value: unknown) => void)("auth", null) + app.addHook("onRequest", (request, _reply, done) => { + ;(request as { auth?: unknown }).auth = { userId: OPERATOR, roles: ["operator"] } + done() + }) + app.decorate("adminBroadcastOverrides", { repo: makeDrizzleBroadcastRepository(sql) }) + app.decorate("adminEventPageOverrides", { repo: makeDrizzleAdminEventPageRepository(sql) }) + await registerAdminBroadcastRoutes(app, container) + await registerAdminEventPageRoutes(app, container) + await app.ready() + return { app, ctl } +} + +async function nextCursorOf(app: FastifyInstance, url: string): Promise { + const res = await app.inject({ method: "GET", url }) + expect(res.statusCode).toBe(200) + return (res.json() as { nextCursor: string | null }).nextCursor +} + +describe("operator broadcast list cursors", () => { + const ADMIN_LIST = /FROM broadcasts b\s+LEFT JOIN cleanups c/ + + it("encodes the microsecond instant and binds it back as text", async () => { + const row = broadcastRow({ + event_title: "Beach Cleanup", + created_by_name: "Ada", + created_by_handle: "ada", + created_by_joined: AT, + }) + const { app, ctl } = await adminApp([{ match: ADMIN_LIST, rows: twoRows(row) }]) + expect(await nextCursorOf(app, "/v1/admin/broadcasts?limit=1")).toBe(NEXT) + + await nextCursorOf(app, `/v1/admin/broadcasts?limit=1&cursor=${encodeURIComponent(NEXT)}`) + expectExactAnchor(ctl, ADMIN_LIST) + }) +}) + +describe("operator host list cursors", () => { + const HOSTS = /WITH agg AS/ + + it("encodes the microsecond instant of the last broadcast and binds it back as text", async () => { + const host = { + display_name: "Ada", + handle: "ada", + joined_at: AT, + messaging_suspended: false, + suspended_at: null, + suspended_by_id: null, + suspended_by_name: null, + suspended_by_handle: null, + suspended_by_joined: null, + broadcast_count: 1, + recipient_count: 2, + sent_count: 2, + failed_count: 0, + suppressed_count: 0, + events_messaged: 1, + last_broadcast_at: AT, + sort_at: AT, + } + const { app, ctl } = await adminApp([{ match: HOSTS, rows: twoRows(host, "user_id") }]) + expect(await nextCursorOf(app, "/v1/admin/hosts?limit=1")).toBe(NEXT) + + await nextCursorOf(app, `/v1/admin/hosts?limit=1&cursor=${encodeURIComponent(NEXT)}`) + const stmt = lastStatement(ctl, HOSTS) + expectExactAnchor(ctl, HOSTS) + expect(stmt.sql).toMatch( + /\(COALESCE\(agg\.last_broadcast_at, 'epoch'::timestamptz\), u\.id\) < \(\?::timestamptz, \?::uuid\)/, + ) + }) +}) + +describe("operator event page list cursors", () => { + const PAGES = /FROM cleanup_pages p/ + + it("encodes the microsecond instant and binds it back as text", async () => { + const page = { + cleanup_id: EVENT, + slug: "beach-cleanup", + title: "Beach Cleanup", + status: "published", + visibility: "public", + organizer_id: OPERATOR, + organizer_name: "Ada", + organizer_handle: "ada", + organizer_joined: AT, + org_name: null, + view_count: 3, + published_at: AT, + flagged_at: null, + flag_reason: null, + flagged_by_id: null, + flagged_by_name: null, + flagged_by_handle: null, + flagged_by_joined: null, + sort_at: AT, + } + const { app, ctl } = await adminApp([{ match: PAGES, rows: twoRows(page, "page_id") }]) + expect(await nextCursorOf(app, "/v1/admin/pages?limit=1")).toBe(NEXT) + + await nextCursorOf(app, `/v1/admin/pages?limit=1&cursor=${encodeURIComponent(NEXT)}`) + expectExactAnchor(ctl, PAGES) + expect(lastStatement(ctl, PAGES).sql).toMatch( + /\(COALESCE\(p\.published_at, p\.updated_at\), p\.id\) < \(\?::timestamptz, \?::uuid\)/, + ) + }) +}) diff --git a/services/api/test/unit/host-metrics-rollup-correctness.test.ts b/services/api/test/unit/host-metrics-rollup-correctness.test.ts new file mode 100644 index 00000000..a45fab42 --- /dev/null +++ b/services/api/test/unit/host-metrics-rollup-correctness.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { makeMetricsService } from "../../src/services/host/metrics-service.js" +import { + makeDrizzleMetricsRepository, + type MetricUpsert, + type MetricsRepository, +} from "../../src/services/host/metrics-repository.drizzle.js" +import { makeInsightsService } from "../../src/services/host/insights-service.js" +import { makeHostAnalyticsCache } from "../../src/services/host/host-analytics-cache.js" +import { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" +import type { AnalyticsRepository } from "../../src/services/host/analytics-repository.drizzle.js" +import { DEFAULT_EVENT_TIME_ZONE } from "../../src/services/host/event-fields.js" +import type { Sql } from "../../src/db/client.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const EVENT = "00000000-0000-0000-0000-0000000000ee" + +function eventId(n: number): string { + return `00000000-0000-0000-0000-${String(n).padStart(12, "0")}` +} + +function repoStub(overrides: Partial = {}): MetricsRepository & { + recomputed: Array<{ cleanupId: string; timezone: string }> + greatest: MetricUpsert[] +} { + const recomputed: Array<{ cleanupId: string; timezone: string }> = [] + const greatest: MetricUpsert[] = [] + return { + recomputed, + greatest, + resolveSlug: () => Promise.resolve({ cleanupId: EVENT, timezone: null }), + eventTimezone: () => Promise.resolve(null), + listRollupEvents: () => Promise.resolve([EVENT]), + recomputeFromSource: (cleanupId, timezone) => { + recomputed.push({ cleanupId, timezone }) + return Promise.resolve([]) + }, + upsertExact: () => Promise.resolve(), + upsertGreatest: (rows) => { + greatest.push(...rows) + return Promise.resolve() + }, + read: () => Promise.resolve([]), + readMany: () => Promise.resolve([]), + ...overrides, + } +} + +describe("metrics rollup covers every active event", () => { + it("pages past the first batch instead of recomputing the same lowest ids every run", async () => { + const ids = Array.from({ length: 1201 }, (_, i) => eventId(i + 1)) + const repo = repoStub({ + listRollupEvents: (_since, after, limit) => + Promise.resolve(ids.filter((id) => after === null || id > after).slice(0, limit)), + eventTimezone: () => Promise.resolve("UTC"), + }) + const service = makeMetricsService({ + repo, + cache: new InMemoryCacheClient(), + selfHosts: [], + lookbackDays: 3, + now: () => new Date("2026-02-05T12:00:00Z"), + }) + const result = await service.rollup() + expect(result.events).toBe(1201) + expect(new Set(repo.recomputed.map((r) => r.cleanupId)).size).toBe(1201) + }) + + it("keysets the SQL page by id so the next page starts after the last one", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleMetricsRepository(fake.sql as unknown as Sql) + await repo.listRollupEvents(new Date("2026-02-01T00:00:00Z"), EVENT, 500) + const statement = fake.statements[0]! + expect(statement.sql).toMatch(/c\.id > \$?\??/) + expect(statement.values).toContain(EVENT) + }) + + it("recomputes from the start of the event-local day, not from a mid-day instant", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleMetricsRepository(fake.sql as unknown as Sql) + await repo.recomputeFromSource(EVENT, "America/Los_Angeles", new Date("2026-02-02T20:00:00Z")) + const statement = fake.statements[0]! + expect(statement.sql).toMatch(/date_trunc\('day'/) + expect(statement.sql).toMatch(/registered_at >= \(SELECT since FROM bound\)/) + }) +}) + +describe("events without a timezone use the platform default everywhere", () => { + it("buckets a live page view by the default zone's day", async () => { + const cache = new InMemoryCacheClient() + const service = makeMetricsService({ + repo: repoStub(), + cache, + selfHosts: [], + lookbackDays: 3, + now: () => new Date("2026-02-02T04:30:00Z"), + }) + await service.recordPageView({ slug: "beach", userAgent: "Mozilla/5.0 Safari" }) + expect(await cache.smembers("evm:v1:dirty:2026-02-01")).toHaveLength(2) + expect(await cache.smembers("evm:v1:dirty:2026-02-02")).toHaveLength(0) + }) + + it("rolls up in the default zone", async () => { + const repo = repoStub() + const service = makeMetricsService({ + repo, + cache: new InMemoryCacheClient(), + selfHosts: [], + lookbackDays: 3, + }) + await service.rollup() + expect(repo.recomputed).toEqual([{ cleanupId: EVENT, timezone: DEFAULT_EVENT_TIME_ZONE }]) + }) + + it("reports and trends insights in the default zone", async () => { + const trendZones: string[] = [] + const analytics = { + eventClock: () => + Promise.resolve({ + status: "upcoming", + createdAt: new Date("2026-03-01T00:00:00Z"), + scheduledAt: new Date("2026-03-07T17:00:00Z"), + endsAt: null, + completedAt: null, + registrationClosesAt: null, + timezone: null, + }), + seatTrend: (_id: string, timezone: string) => { + trendZones.push(timezone) + return Promise.resolve([]) + }, + registrationsBySource: () => Promise.resolve([]), + broadcastsForEvent: () => Promise.resolve([]), + eventHoursTotals: () => + Promise.resolve({ credited: 0, attendeesCredited: 0, attendeesCheckedIn: 0 }), + returningAttendees: () => Promise.resolve({ seats: 0, ofRegistered: 0 }), + hostedEventIds: () => Promise.resolve([]), + topVolunteers: () => Promise.resolve([]), + } as unknown as AnalyticsRepository + const registrations = new InMemoryHostRegistrationRepository() + registrations.seedEvent({ cleanupId: EVENT, scheduledAt: new Date("2026-03-07T17:00:00Z") }) + const service = makeInsightsService({ + analytics, + registrations, + cache: makeHostAnalyticsCache({ cache: new InMemoryCacheClient(), ttlSeconds: 60 }), + now: () => new Date("2026-03-05T12:00:00Z"), + }) + const payload = await service.insights(EVENT, { userId: EVENT, viewerScope: "organizer:none" }) + expect(payload.clock.timezone).toBe(DEFAULT_EVENT_TIME_ZONE) + expect(trendZones).toEqual([DEFAULT_EVENT_TIME_ZONE]) + }) +}) + +describe("counter flush reads every local day a bump can write", () => { + it("flushes a view counted on an event-local day ahead of UTC", async () => { + const at = new Date("2026-02-02T20:00:00Z") + const repo = repoStub({ + resolveSlug: () => Promise.resolve({ cleanupId: EVENT, timezone: "Asia/Tokyo" }), + }) + const service = makeMetricsService({ + repo, + cache: new InMemoryCacheClient(), + selfHosts: [], + lookbackDays: 3, + now: () => at, + }) + await service.recordPageView({ slug: "beach", userAgent: "Mozilla/5.0 Safari" }) + await service.flushCounters() + expect(repo.greatest.map((row) => row.day)).toContain("2026-02-03") + }) +}) diff --git a/services/api/test/unit/host-reminders.test.ts b/services/api/test/unit/host-reminders.test.ts index 5a449721..702982ad 100644 --- a/services/api/test/unit/host-reminders.test.ts +++ b/services/api/test/unit/host-reminders.test.ts @@ -99,12 +99,65 @@ describe("critical lanes", () => { const second = await lanes.eventCancelled(EVENT, "storm warning") expect(first).not.toBeNull() expect(second).toBeNull() - expect(planned).toEqual([first]) + expect(planned).toEqual([first, first]) const rows = await repo.list({ cleanupId: EVENT, cursor: null, limit: 50 }) expect(rows.filter((b) => b.kind === "event_cancelled")).toHaveLength(1) }) }) +describe("event_cancelled lane after a failed plan enqueue", () => { + function buildFailingFirstEnqueue() { + const repo = new InMemoryBroadcastRepository() + repo.seedEvent(CONTEXT) + const planned: string[] = [] + let failNext = true + const lanes = makeBroadcastLanes({ + repo, + counters: new InMemoryCounterStore(), + perEventPerHour: 3, + enqueuePlan: (id) => { + if (failNext) { + failNext = false + return Promise.reject(new Error("queue unavailable")) + } + planned.push(id) + return Promise.resolve() + }, + }) + return { repo, lanes, planned } + } + + it("re-enqueues the plan on the retry instead of leaving the notice unplanned", async () => { + const { repo, lanes, planned } = buildFailingFirstEnqueue() + await expect(lanes.eventCancelled(EVENT, "storm warning")).rejects.toThrow("queue unavailable") + await lanes.eventCancelled(EVENT, "storm warning") + + const rows = await repo.list({ cleanupId: EVENT, cursor: null, limit: 50 }) + const cancellations = rows.filter((b) => b.kind === "event_cancelled") + expect(cancellations).toHaveLength(1) + expect(planned).toEqual([cancellations[0]!.id]) + }) + + it("looks the cancellation up by event and kind, the key its unique index enforces", async () => { + const fake = makeFakeSql([{ match: /FROM broadcasts/, rows: [] }]) + const repo = makeDrizzleBroadcastRepository(fake.sql as unknown as Sql) + + expect(await repo.findEventCancellation(EVENT)).toBeNull() + + const statement = fake.statements.at(-1) + expect(statement?.sql).toMatch(/WHERE cleanup_id = \? AND kind = 'event_cancelled'/) + expect(statement?.values).toEqual([EVENT]) + }) + + it("does not re-enqueue a cancellation that was already planned", async () => { + const { repo, lanes, planned } = build() + const first = await lanes.eventCancelled(EVENT, "storm warning") + await repo.markPlanned(first!, { recipientCount: 2, plannedAt: new Date() }) + await lanes.eventCancelled(EVENT, "storm warning") + expect(planned).toEqual([first]) + }) +}) + describe("event_updated lane", () => { it("throttles repeat announcements for the same event inside the hour", async () => { const { lanes, repo } = build(2) @@ -133,6 +186,7 @@ describe("event_updated lane", () => { counters: { incr: () => Promise.reject(new Error("redis down")), incrBy: () => Promise.reject(new Error("redis down")), + decrBy: () => Promise.reject(new Error("redis down")), }, perEventPerHour: 3, enqueuePlan: () => Promise.resolve(), diff --git a/services/api/test/unit/host/announcement-pipeline.test.ts b/services/api/test/unit/host/announcement-pipeline.test.ts index 35975d04..fe72e513 100644 --- a/services/api/test/unit/host/announcement-pipeline.test.ts +++ b/services/api/test/unit/host/announcement-pipeline.test.ts @@ -126,12 +126,12 @@ describe("announcement listing SQL", () => { await makeDrizzleBroadcastRepository(fake.sql as unknown as Sql).listAnnouncements({ cleanupId: EVENT, - cursor: { createdAt: at, id: ANNOUNCEMENT }, + cursor: { at, atText: at.toISOString(), id: ANNOUNCEMENT }, limit: 21, }) const statement = fake.statements.at(-1)! - expect(statement.sql).toMatch(/\(created_at, id\) < \(\?, \?\)/) + expect(statement.sql).toMatch(/\(created_at, id\) < \(\?::timestamptz, \?::uuid\)/) expect(statement.values).toContain(ANNOUNCEMENT) }) diff --git a/services/api/test/unit/host/announcement-send-failure.test.ts b/services/api/test/unit/host/announcement-send-failure.test.ts new file mode 100644 index 00000000..d4c3b301 --- /dev/null +++ b/services/api/test/unit/host/announcement-send-failure.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" +import { InMemoryBroadcastRepository } from "../../../src/services/host/broadcast-repository.memory.js" +import { + makeBroadcastService, + type BroadcastConfig, +} from "../../../src/services/host/broadcast-service.js" +import { makeAnnouncementService } from "../../../src/services/host/announcement-service.js" +import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-repository.drizzle.js" + +const EVENT = "00000000-0000-0000-0000-0000000000ee" +const HOST = "00000000-0000-0000-0000-0000000000aa" + +const CONFIG: BroadcastConfig = { + killSwitch: false, + perEventPerDay: 3, + recipientsPerDay: 2000, + cooldownSec: 900, + minAccountAgeHours: 24, + maxRecipients: 5000, + chunkSize: 200, + emailConcurrency: 2, + emailRatePerSec: 1000, + linkAllowedHosts: [], + mailFromEvents: "events@civfix.org", + unsubscribeSigningKey: "unsubscribe-signing-key-for-tests-0123456789", + webBaseUrl: "https://civfix.org", + apiBaseUrl: "https://api.civfix.org", + eventUpdatePerEventPerHour: 3, +} + +const identities: AnnouncementIdentityRepository = { + authorsFor: () => Promise.resolve(new Map()), + organizationFor: () => Promise.resolve(null), +} + +function harness(enqueuePlan: () => Promise) { + const repo = new InMemoryBroadcastRepository() + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "America/Los_Angeles", + address: null, + status: "upcoming", + organizerUserId: HOST, + replyTo: null, + replyToVerified: false, + }) + repo.seedHost(HOST, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } + const broadcasts = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config: CONFIG, + mailer: new FakeMailer(), + enqueuePlan, + }) + const service = makeAnnouncementService({ repo, identities, broadcasts, config: CONFIG, logger }) + return { repo, service, logger } +} + +const body = { id: EVENT, bodyMd: "Meet at the pavilion.", audience: { kind: "all_registered" } } + +describe("announcement create when the plan job cannot be enqueued", () => { + it("rethrows and leaves no announcement behind that would still be sent", async () => { + const h = harness(() => Promise.reject(new Error("queue down"))) + await expect(h.service.create(EVENT, HOST, body as never)).rejects.toThrow("queue down") + expect(await h.repo.countAnnouncementsSince(EVENT, new Date(0))).toBe(0) + const { items } = await h.service.list(EVENT, { id: EVENT }, { host: true }) + expect(items).toHaveLength(0) + }) + + it("logs a draft it could not clean up and still surfaces the original failure", async () => { + const h = harness(() => Promise.reject(new Error("queue down"))) + h.repo.deleteDraft = () => Promise.reject(new Error("db down")) + await expect(h.service.create(EVENT, HOST, body as never)).rejects.toThrow("queue down") + expect(h.logger.warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/services/api/test/unit/host/cleanup-duplicate.test.ts b/services/api/test/unit/host/cleanup-duplicate.test.ts index 0e9a7c5a..dc49060b 100644 --- a/services/api/test/unit/host/cleanup-duplicate.test.ts +++ b/services/api/test/unit/host/cleanup-duplicate.test.ts @@ -35,14 +35,16 @@ function request(over: Partial & { id: string }): Dupli } function seedSource(over: Parameters[0] = {}) { + // One clock read for both ends: two reads can straddle a millisecond and stretch the duration. + const startsAtMs = Date.now() + DAY_MS return repo.seedCleanup({ organizerUserId: ORG, title: "Ballona sweep", description: "Bring boots", address: "North gate", bring: ["gloves", "bags"], - scheduledAt: new Date(Date.now() + DAY_MS), - endsAt: new Date(Date.now() + DAY_MS + 3 * HOUR_MS), + scheduledAt: new Date(startsAtMs), + endsAt: new Date(startsAtMs + 3 * HOUR_MS), timezone: "America/Los_Angeles", visibility: "public", reminderOffsetsMin: [1440], diff --git a/services/api/test/unit/host/host-list-keyset-precision.test.ts b/services/api/test/unit/host/host-list-keyset-precision.test.ts new file mode 100644 index 00000000..ed5d8871 --- /dev/null +++ b/services/api/test/unit/host/host-list-keyset-precision.test.ts @@ -0,0 +1,289 @@ +/** + * Host-plane lists page on timestamps Postgres stamps with now(), which carry microseconds. A cursor + * built from the millisecond Date postgres.js returns, and bound back as a Date, skips (DESC) or repeats + * (ASC) every row sharing the anchor's millisecond. These lists must carry the column's microsecond text. + */ + +import { describe, expect, it } from "vitest" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" +import { makeDrizzleHostTeamRepository } from "../../../src/services/host/host-team-repository.drizzle.js" +import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" +import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" +import type { RosterQuery } from "../../../src/services/host/registration-repository.types.js" + +const AT = new Date("2026-09-01T10:00:00.123Z") +const AT_TEXT = "2026-09-01T10:00:00.123456Z" +const LEGACY_AT_TEXT = "2026-09-01T10:00:00.123Z" +const CHECKED_IN_TEXT = "2026-09-02T08:30:00.654321Z" +const ID_A = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" +const ID_B = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e02" +const EVENT = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e0e" +const ORG = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e0a" +const USER = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e0b" +const NEXT = `${AT_TEXT}|${ID_A}` +const LEGACY = `${LEGACY_AT_TEXT}|${ID_A}` + +function twoRows(extra: Record, idKey = "id"): Record[] { + return [ + { ...extra, [idKey]: ID_A, cursor_at: AT_TEXT }, + { ...extra, [idKey]: ID_B, cursor_at: "2026-09-01T10:00:00.123001Z" }, + ] +} + +function lastStatement(ctl: FakeSqlControl, match: RegExp): { sql: string; values: unknown[] } { + const hit = [...ctl.statements].reverse().find((s) => match.test(s.sql)) + if (hit === undefined) throw new Error(`no statement matched ${String(match)}`) + return hit +} + +function expectExactAnchor(ctl: FakeSqlControl, match: RegExp, atText = AT_TEXT): void { + const stmt = lastStatement(ctl, match) + expect(stmt.sql).toMatch(/to_char\([\s\S]* AT TIME ZONE 'UTC', \?\) AS cursor_at/) + expect(stmt.values).toContain(atText) + expect(stmt.values.some((v) => v instanceof Date && v.getTime() === AT.getTime())).toBe(false) + expect(stmt.sql).toContain("?::timestamptz") +} + +function fake(match: RegExp, rows: Record[]): FakeSqlControl { + const handlers: SqlHandler[] = [{ match, rows }] + return makeFakeSql(handlers) +} + +describe("event team invites for the invitee", () => { + const LIST = /FROM cleanup_team_invites i/ + const invite = { + role: "cohost", + created_at: AT, + expires_at: new Date("2026-09-08T10:00:00Z"), + event_id: EVENT, + title: "Beach Cleanup", + scheduled_at: new Date("2026-09-10T10:00:00Z"), + ends_at: null, + event_status: "upcoming", + visibility: "public", + address: null, + cover_key: null, + inviter_id: null, + inviter_name: null, + inviter_handle: null, + inviter_avatar_url: null, + } + + async function list(ctl: FakeSqlControl, cursor: string | null) { + return makeDrizzleHostTeamRepository(ctl.sql as unknown as Sql).listInvitesForUser({ + userId: USER, + now: new Date("2026-09-01T12:00:00Z"), + cursor, + limit: 1, + }) + } + + it("encodes the microsecond instant and binds it back as text", async () => { + const ctl = fake(LIST, twoRows(invite)) + expect((await list(ctl, null)).nextCursor).toBe(NEXT) + await list(ctl, NEXT) + expectExactAnchor(ctl, LIST) + }) + + it("still accepts a legacy millisecond cursor as the same instant", async () => { + const ctl = fake(LIST, []) + await list(ctl, LEGACY) + expectExactAnchor(ctl, LIST, LEGACY_AT_TEXT) + }) +}) + +describe("organization member lists", () => { + const MEMBERS = /FROM organization_members m/ + const member = { + display_name: "Ada", + handle: "ada", + bio: null, + avatar_url: null, + created_at: AT, + role: "member", + joined_at: AT, + } + + function repo(ctl: FakeSqlControl) { + return makeDrizzleOrganizationRepository(ctl.sql as unknown as Sql) + } + + it("encodes the member list's microsecond join instant and binds it back as text", async () => { + const ctl = fake(MEMBERS, twoRows(member, "user_id")) + const first = await repo(ctl).listMembers({ organizationId: ORG, cursor: null, limit: 1 }) + expect(first.nextCursor).toBe(NEXT) + await repo(ctl).listMembers({ organizationId: ORG, cursor: NEXT, limit: 1 }) + expectExactAnchor(ctl, MEMBERS) + expect(lastStatement(ctl, MEMBERS).sql).toMatch( + /\(m\.joined_at, m\.user_id\) > \(\?::timestamptz, \?::uuid\)/, + ) + }) + + it("encodes the operator member list's microsecond join instant and binds it back as text", async () => { + const ctl = fake(MEMBERS, twoRows(member, "user_id")) + const first = await repo(ctl).adminListMembers({ organizationId: ORG, cursor: null, limit: 1 }) + expect(first.nextCursor).toBe(NEXT) + await repo(ctl).adminListMembers({ organizationId: ORG, cursor: NEXT, limit: 1 }) + expectExactAnchor(ctl, MEMBERS) + }) + + it("still accepts a legacy millisecond member cursor as the same instant", async () => { + const ctl = fake(MEMBERS, []) + await repo(ctl).listMembers({ organizationId: ORG, cursor: LEGACY, limit: 1 }) + expectExactAnchor(ctl, MEMBERS, LEGACY_AT_TEXT) + }) +}) + +describe("operator organization list", () => { + const LIST = /ORDER BY o\.created_at DESC, o\.id DESC/ + const org = { + slug: "beach-friends", + name: "Beach Friends", + description: null, + website_url: null, + donation_url: null, + logo_media_id: null, + logo_key: null, + social_links: {}, + verified_status: "unverified", + verified_kind: null, + verified_at: null, + created_by: USER, + created_at: AT, + updated_at: AT, + deleted_at: null, + suspended_at: null, + suspended_reason: null, + member_count: 1, + event_count: 0, + my_role: null, + owner_id: null, + owner_name: null, + owner_handle: null, + owner_joined: null, + } + + it("encodes the microsecond instant and binds it back as text", async () => { + const ctl = fake(LIST, twoRows(org)) + const repo = makeDrizzleOrganizationRepository(ctl.sql as unknown as Sql) + const first = await repo.adminListOrganizations({ cursor: null, limit: 1 }) + expect(first.nextCursor).toBe(NEXT) + await repo.adminListOrganizations({ cursor: NEXT, limit: 1 }) + expectExactAnchor(ctl, LIST) + }) +}) + +describe("event waitlist", () => { + const LIST = /FROM cleanup_waitlist w/ + const entry = { + cleanup_id: EVENT, + ticket_type_id: ID_B, + ticket_type_name: "General", + user_id: null, + guest_id: ID_B, + guest_name: "Pat", + party_size: 1, + status: "waiting", + position: 1, + created_at: AT, + offered_at: null, + claim_expires_at: null, + person_display_name: null, + person_handle: null, + person_bio: null, + person_avatar_url: null, + person_deleted_at: null, + } + + async function list(ctl: FakeSqlControl, cursor: string | null) { + return makeDrizzleHostRegistrationRepository(ctl.sql as unknown as Sql).listWaitlist({ + cleanupId: EVENT, + ticketTypeId: null, + status: null, + cursor, + limit: 1, + }) + } + + it("encodes the microsecond instant and binds it back as text", async () => { + const ctl = fake(LIST, twoRows(entry)) + expect((await list(ctl, null)).nextCursor).toBe(NEXT) + await list(ctl, NEXT) + expectExactAnchor(ctl, LIST) + expect(lastStatement(ctl, LIST).sql).toMatch( + /\(w\.created_at, w\.id\) > \(\?::timestamptz, \?::uuid\)/, + ) + }) +}) + +describe("host roster", () => { + const ROSTER = /FROM cleanup_registrations r\b[\s\S]*ORDER BY/ + + function rosterRow(id: string, cursorAt: string): Record { + return { + id, + cleanup_id: EVENT, + user_id: null, + guest_id: ID_B, + registered_at: AT, + checked_in_at: null, + cursor_at: cursorAt, + checked_in_cursor_at: CHECKED_IN_TEXT, + } + } + + function query(sort: RosterQuery["sort"], cursor: string | null): RosterQuery { + return { + cleanupId: EVENT, + filter: "all", + ticketTypeId: null, + slotId: null, + sort, + q: null, + cursor, + limit: 1, + withTotal: false, + } + } + + function roster(ctl: FakeSqlControl) { + return makeDrizzleHostRegistrationRepository(ctl.sql as unknown as Sql) + } + + const rows = [rosterRow(ID_A, AT_TEXT), rosterRow(ID_B, "2026-09-01T10:00:00.123001Z")] + + it("pages the registered_at sorts on the microsecond instant", async () => { + for (const sort of ["registered_at_desc", "registered_at_asc"] as const) { + const ctl = fake(ROSTER, rows) + expect((await roster(ctl).listRoster(query(sort, null))).nextCursor).toBe(NEXT) + await roster(ctl).listRoster(query(sort, NEXT)) + expectExactAnchor(ctl, ROSTER) + } + }) + + it("carries both microsecond instants in the checked-in cursor and binds them as text", async () => { + const ctl = fake(ROSTER, rows) + const first = await roster(ctl).listRoster(query("checked_in_at_desc", null)) + const next = `${CHECKED_IN_TEXT}|${AT_TEXT}|${ID_A}` + expect(first.nextCursor).toBe(next) + + await roster(ctl).listRoster(query("checked_in_at_desc", next)) + const stmt = lastStatement(ctl, ROSTER) + expect(stmt.sql).toMatch( + /'epoch'::timestamptz\), r\.registered_at, r\.id\) < \(\?::timestamptz, \?::timestamptz, \?::uuid\)/, + ) + expect(stmt.values).toEqual(expect.arrayContaining([CHECKED_IN_TEXT, AT_TEXT, ID_A])) + expect(stmt.values.some((v) => v instanceof Date)).toBe(false) + }) + + it("still decodes a checked-in cursor minted with millisecond instants", async () => { + const ctl = fake(ROSTER, []) + const legacy = `2026-09-02T08:30:00.654Z|${LEGACY_AT_TEXT}|${ID_A}` + await roster(ctl).listRoster(query("checked_in_at_desc", legacy)) + const stmt = lastStatement(ctl, ROSTER) + expect(stmt.values).toEqual( + expect.arrayContaining(["2026-09-02T08:30:00.654Z", LEGACY_AT_TEXT, ID_A]), + ) + }) +}) diff --git a/services/api/test/unit/host/host-portfolio-correctness.test.ts b/services/api/test/unit/host/host-portfolio-correctness.test.ts new file mode 100644 index 00000000..e9367446 --- /dev/null +++ b/services/api/test/unit/host/host-portfolio-correctness.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest" +import { makeHostPortfolioService } from "../../../src/services/host/host-portfolio-service.js" +import { + hostedRegistrationTotals, + makeDrizzleHostPortfolioRepository, + type HostedEventRecord, +} from "../../../src/services/host/host-portfolio-repository.drizzle.js" +import { makeFakeSql } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const HOST = "11111111-1111-4111-8111-111111111111" +const SHOWN = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const ORG = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const NOW = new Date("2026-09-12T12:00:00.000Z") + +function record(id: string): HostedEventRecord { + return { + id, + referenceCode: null, + title: "Lincoln Park cleanup", + startsAt: new Date("2026-09-29T16:00:00.000Z"), + endsAt: null, + timezone: null, + status: "upcoming", + visibility: "public", + coverKey: null, + capacity: null, + eventRole: "organizer", + orgRole: null, + orgId: null, + orgName: null, + pageSlug: null, + pageStatus: null, + } +} + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ").trim() +} + +describe("portfolio registration totals", () => { + it("come from the whole hosted portfolio, not from the page being shown", async () => { + const totalsAsked: (string | null)[] = [] + const service = makeHostPortfolioService({ + repo: { + listHostedEvents: () => Promise.resolve({ items: [record(SHOWN)], nextCursor: "next" }), + kpisFor: () => Promise.resolve({ eventsHosted: 3, upcomingEvents: 1 }), + }, + counts: () => + Promise.resolve( + new Map([[SHOWN, { registered: 5, checkedIn: 2, waitlisted: 0, hoursCredited: 0 }]]), + ), + totals: (args) => { + totalsAsked.push(args.organizationId) + return Promise.resolve({ totalRegistrations: 12, totalCheckedIn: 7 }) + }, + now: () => NOW, + }) + + const page = await service.listMyHostedEvents(HOST, { when: "upcoming", orgId: ORG, limit: 1 }) + + expect(page.items.map((item) => item.registeredCount)).toEqual([5]) + expect(page.kpis).toEqual({ + eventsHosted: 3, + upcomingEvents: 1, + totalRegistrations: 12, + totalCheckedIn: 7, + }) + expect(totalsAsked).toEqual([ORG]) + }) + + it("sum registered parties and checked-in seats over the hosted set in one statement", async () => { + const fake = makeFakeSql([ + { match: /total_registrations/, rows: [{ total_registrations: 12, total_checked_in: 7 }] }, + ]) + + const totals = await hostedRegistrationTotals(fake.sql as unknown as Sql, { + userId: HOST, + organizationId: ORG, + }) + + expect(totals).toEqual({ totalRegistrations: 12, totalCheckedIn: 7 }) + expect(fake.statements).toHaveLength(1) + const text = flat(fake.statements[0]?.sql ?? "") + expect(text).toContain("sum(r.party_size)") + expect(text).toContain("r.status = 'registered'") + expect(text).toContain("s.status = 'active' AND s.checked_in_at IS NOT NULL") + expect(text).toContain("AND c.organization_id = ?") + expect(fake.statements[0]?.values).toContain(ORG) + }) +}) + +describe("hosted event rows", () => { + it("carry the event page's status and ignore roles in a deleted organization", async () => { + const fake = makeFakeSql([ + { + match: /WITH hosted AS/, + rows: [ + { + id: SHOWN, + reference_code: null, + title: "Lincoln Park cleanup", + scheduled_at: new Date("2026-09-29T16:00:00.000Z"), + ends_at: null, + timezone: null, + status: "upcoming", + visibility: "public", + cover_key: null, + capacity: null, + event_role: "organizer", + org_role: null, + org_id: null, + org_name: null, + page_slug: "park-day", + page_status: "published", + }, + ], + }, + ]) + + const page = await makeDrizzleHostPortfolioRepository( + fake.sql as unknown as Sql, + ).listHostedEvents({ userId: HOST, when: "all", organizationId: null, cursor: null, limit: 20 }) + + expect(page.items[0]?.pageStatus).toBe("published") + const text = flat(fake.statements[0]?.sql ?? "") + expect(text).toContain("LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id") + expect(text).toMatch( + /SELECT om\.role FROM organization_members om JOIN organizations oo ON oo\.id = om\.organization_id AND oo\.deleted_at IS NULL/, + ) + }) + + it("reach the DTO with their page status", async () => { + const service = makeHostPortfolioService({ + repo: { + listHostedEvents: () => + Promise.resolve({ + items: [{ ...record(SHOWN), pageSlug: "park-day", pageStatus: "draft" as const }], + nextCursor: null, + }), + kpisFor: () => Promise.resolve({ eventsHosted: 1, upcomingEvents: 1 }), + }, + counts: () => Promise.resolve(new Map()), + totals: () => Promise.resolve({ totalRegistrations: 0, totalCheckedIn: 0 }), + now: () => NOW, + }) + + const page = await service.listMyHostedEvents(HOST, { when: "all" }) + + expect(page.items[0]?.pageStatus).toBe("draft") + }) +}) diff --git a/services/api/test/unit/host/host-portfolio-service.test.ts b/services/api/test/unit/host/host-portfolio-service.test.ts index bcae19a5..4d87383c 100644 --- a/services/api/test/unit/host/host-portfolio-service.test.ts +++ b/services/api/test/unit/host/host-portfolio-service.test.ts @@ -30,6 +30,7 @@ function record(over: Partial & { id: string }): HostedEventR orgId: null, orgName: null, pageSlug: null, + pageStatus: null, ...over, } } @@ -45,6 +46,7 @@ function build(counts: Map) { kpisFor: () => Promise.resolve({ eventsHosted: 2, upcomingEvents: 0 }), }, counts: () => Promise.resolve(counts), + totals: () => Promise.resolve({ totalRegistrations: 0, totalCheckedIn: 0 }), now: () => NOW, }) } @@ -139,8 +141,19 @@ function scopedRepo(records: readonly HostedEventRecord[]): HostPortfolioReposit } function portfolioService(counts: Map) { + const inScope = (organizationId: string | null) => + organizationId === null ? PORTFOLIO : PORTFOLIO.filter((row) => row.orgId === organizationId) return makeHostPortfolioService({ repo: scopedRepo(PORTFOLIO), + totals: (args) => { + const rows = inScope(args.organizationId).map( + (row) => counts.get(row.id) ?? { registered: 0, checkedIn: 0 }, + ) + return Promise.resolve({ + totalRegistrations: rows.reduce((sum, row) => sum + row.registered, 0), + totalCheckedIn: rows.reduce((sum, row) => sum + row.checkedIn, 0), + }) + }, counts: (ids) => Promise.resolve( new Map( @@ -215,6 +228,7 @@ describe("F4: the portfolio KPIs obey the same org scope as the items", () => { }, }, counts: () => Promise.resolve(new Map()), + totals: () => Promise.resolve({ totalRegistrations: 0, totalCheckedIn: 0 }), now: () => NOW, }) diff --git a/services/api/test/unit/host/host-service-correctness.test.ts b/services/api/test/unit/host/host-service-correctness.test.ts new file mode 100644 index 00000000..5888b801 --- /dev/null +++ b/services/api/test/unit/host/host-service-correctness.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import type { Jobs } from "@civfix/shared/interfaces" +import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" +import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { makeRegistrationService } from "../../../src/services/host/registration-service.js" +import { makeTicketTypeService } from "../../../src/services/host/ticket-type-service.js" +import { makeQuestionService } from "../../../src/services/host/question-service.js" +import { makeWaitlistService } from "../../../src/services/host/waitlist-service.js" +import { WAITLIST_PROMOTE_JOB } from "../../../src/services/host/registration-queues.js" +import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" +import type { SeatDraft } from "../../../src/services/host/registration-repository.types.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const OTHER_EVENT = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +const USER = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +const WAITER = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const HOST = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +const NOW = new Date("2026-01-01T12:00:00.000Z") +const HOUR_MS = 60 * 60 * 1000 + +const tokens = makeTicketTokenSigner("host-service-correctness-secret-long-enough") + +function seats(n: number): SeatDraft[] { + return Array.from({ length: n }, () => { + const id = randomUUID() + return { id, attendeeName: null, tokenHash: tokens.hashFor(id) } + }) +} + +function recordingJobs(): { jobs: Jobs; enqueued: { name: string; payload: unknown }[] } { + const enqueued: { name: string; payload: unknown }[] = [] + const jobs: Jobs = { + enqueue: (name, payload) => { + enqueued.push({ name, payload }) + return Promise.resolve("job") + }, + schedule: () => Promise.resolve(), + work: () => Promise.resolve(), + complete: () => Promise.resolve(), + fail: () => Promise.resolve(), + } + return { jobs, enqueued } +} + +function seededRepo(): InMemoryHostRegistrationRepository { + const repo = new InMemoryHostRegistrationRepository() + repo.tokenHashResolver = (seatId) => tokens.hashFor(seatId) + repo.seedEvent({ + cleanupId: EVENT, + scheduledAt: new Date(NOW.getTime() + 24 * HOUR_MS), + endsAt: new Date(NOW.getTime() + 28 * HOUR_MS), + }) + repo.seedEvent({ cleanupId: OTHER_EVENT }) + return repo +} + +describe("a transfer frees seats on the old ticket type", () => { + it("wakes the old type's waitlist", async () => { + const repo = seededRepo() + const full = repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + const roomy = repo.seedTicketType({ cleanupId: EVENT, name: "Roomy", capacity: null }) + const { jobs, enqueued } = recordingJobs() + const service = makeRegistrationService({ + repo, + tokens, + jobs, + counters: new InMemoryCounterStore(() => NOW.getTime()), + now: () => NOW, + }) + const registered = await repo.registerTx({ + cleanupId: EVENT, + subject: { kind: "user", userId: USER }, + ticketTypeId: full.id, + seats: seats(1), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: "transfer-me", + waitlistId: null, + now: NOW, + }) + if (registered.kind !== "registered") throw new Error(`setup: ${registered.kind}`) + await repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: full.id, + subject: { kind: "user", userId: WAITER }, + partySize: 1, + accessCodeHash: null, + now: NOW, + }) + + await service.transfer( + { id: EVENT, registrationId: registered.registration.id, ticketTypeId: roomy.id }, + HOST, + ) + + expect(enqueued).toEqual([{ name: WAITLIST_PROMOTE_JOB, payload: { ticketTypeId: full.id } }]) + }) +}) + +describe("raising a ticket type's capacity", () => { + function build() { + const repo = seededRepo() + const { jobs, enqueued } = recordingJobs() + const service = makeTicketTypeService({ + repo, + jobs, + counters: new InMemoryCounterStore(() => NOW.getTime()), + now: () => NOW, + }) + return { repo, service, enqueued } + } + + it("wakes the waitlist when the cap goes up or is lifted", async () => { + for (const capacity of [5, null]) { + const { repo, service, enqueued } = build() + const type = repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + + await service.update({ id: EVENT, ticketTypeId: type.id, capacity }, HOST) + + expect(enqueued).toEqual([{ name: WAITLIST_PROMOTE_JOB, payload: { ticketTypeId: type.id } }]) + } + }) + + it("leaves the waitlist alone when the edit frees no seat", async () => { + const { repo, service, enqueued } = build() + const type = repo.seedTicketType({ cleanupId: EVENT, capacity: 5, waitlistEnabled: true }) + + await service.update({ id: EVENT, ticketTypeId: type.id, name: "Renamed" }, HOST) + await service.update({ id: EVENT, ticketTypeId: type.id, capacity: 3 }, HOST) + + expect(enqueued).toEqual([]) + }) +}) + +describe("saving questions", () => { + it("refuses a ticket type from another event as a validation error", async () => { + const repo = seededRepo() + const foreign = repo.seedTicketType({ cleanupId: OTHER_EVENT }) + const service = makeQuestionService({ repo, now: () => NOW }) + + await expect( + service.save({ + id: EVENT, + questions: [ + { + kind: "short_text", + prompt: "T-shirt size?", + required: false, + ticketTypeId: foreign.id, + }, + ], + }), + ).rejects.toMatchObject({ + code: "VALIDATION", + fields: { ticketTypeId: "not a ticket type on this event" }, + }) + }) + + it("accepts this event's own ticket type", async () => { + const repo = seededRepo() + const own = repo.seedTicketType({ cleanupId: EVENT }) + const service = makeQuestionService({ repo, now: () => NOW }) + + const saved = await service.save({ + id: EVENT, + questions: [ + { kind: "short_text", prompt: "T-shirt size?", required: false, ticketTypeId: own.id }, + ], + }) + + expect(saved.items.map((q) => q.ticketTypeId)).toEqual([own.id]) + }) +}) + +describe("a walk-up checked in on arrival", () => { + it("is checked in by the registration write, not seat by seat afterwards", async () => { + const repo = seededRepo() + let seatCheckIns = 0 + const checkInSeat = repo.checkInSeat.bind(repo) + repo.checkInSeat = (args) => { + seatCheckIns += 1 + return checkInSeat(args) + } + const service = makeRegistrationService({ + repo, + tokens, + counters: new InMemoryCounterStore(() => NOW.getTime()), + now: () => NOW, + }) + + const result = await service.walkup( + { id: EVENT, name: "Pat Walker", partySize: 2, checkInNow: true }, + HOST, + ) + + expect(seatCheckIns).toBe(0) + expect(result.registration?.seats.map((seat) => seat.checkedInAt)).toEqual([ + NOW.toISOString(), + NOW.toISOString(), + ]) + }) +}) + +describe("waitlist promotion on an event that can no longer happen", () => { + async function waitingOn(event: { status?: "cancelled"; endsAt?: Date; scheduledAt?: Date }) { + const repo = seededRepo() + const type = repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + await repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: type.id, + subject: { kind: "user", userId: WAITER }, + partySize: 1, + accessCodeHash: null, + now: NOW, + }) + repo.seedEvent({ ...repo.events.get(EVENT)!, ...event, cleanupId: EVENT }) + const notified: string[] = [] + const service = makeWaitlistService({ + repo, + registrations: { buildSeatDrafts: seats, eventChanged: () => Promise.resolve() }, + notifier: { + createNotification: (userId) => { + notified.push(userId) + return Promise.resolve() + }, + }, + now: () => NOW, + }) + return { repo, service, type, notified } + } + + it("offers nobody a place on a cancelled event", async () => { + const { repo, service, type, notified } = await waitingOn({ status: "cancelled" }) + + await expect(service.runPromote({ ticketTypeId: type.id })).resolves.toBe(0) + expect(notified).toEqual([]) + expect(repo.ticketTypes.get(type.id)?.reservedSeats).toBe(0) + }) + + it("offers nobody a place on an event that has already ended", async () => { + const { service, type, notified } = await waitingOn({ + scheduledAt: new Date(NOW.getTime() - 6 * HOUR_MS), + endsAt: new Date(NOW.getTime() - HOUR_MS), + }) + + await expect(service.runPromote({ ticketTypeId: type.id })).resolves.toBe(0) + expect(notified).toEqual([]) + }) +}) diff --git a/services/api/test/unit/host/invite-cap-correctness.test.ts b/services/api/test/unit/host/invite-cap-correctness.test.ts new file mode 100644 index 00000000..2e75e2a7 --- /dev/null +++ b/services/api/test/unit/host/invite-cap-correctness.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import { MAX_ORG_INVITES_PER_ORG, MAX_TEAM_INVITES_PER_EVENT } from "@civfix/shared" +import { makeDrizzleHostTeamRepository } from "../../../src/services/host/host-team-repository.drizzle.js" +import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" +import { makeFakeSql, type FakeSqlControl } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const ORG = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +const HOST = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const INVITE = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +const NOW = new Date("2026-01-01T12:00:00.000Z") +const LATER = new Date("2026-01-15T12:00:00.000Z") + +function indexOf(fake: FakeSqlControl, pattern: RegExp): number { + return fake.statements.findIndex((s) => pattern.test(s.sql)) +} + +describe("the open-invite cap is enforced inside the invite transaction", () => { + it("refuses an event team invite once the cap is reached, counted under a per-event lock", async () => { + const fake = makeFakeSql([ + { match: /AS closed\s+FROM cleanups/, rows: [{ closed: false }] }, + { + match: /SELECT count\(\*\)::int AS count FROM cleanup_team_invites/, + rows: [{ count: MAX_TEAM_INVITES_PER_EVENT }], + }, + ]) + + const invite = makeDrizzleHostTeamRepository(fake.sql as unknown as Sql).createInviteTx({ + inviteId: INVITE, + cleanupId: EVENT, + invitedUserId: null, + invitedEmail: "new@example.org", + role: "staff", + tokenHash: "hash", + invitedBy: HOST, + expiresAt: LATER, + now: NOW, + }) + + await expect(invite).rejects.toMatchObject({ + code: "CONFLICT", + message: "This event already has the maximum number of open invitations.", + }) + const lock = indexOf(fake, /pg_advisory_xact_lock/) + const count = indexOf(fake, /FROM cleanup_team_invites\s+WHERE cleanup_id = \? AND status/) + expect(lock).toBeGreaterThanOrEqual(0) + expect(lock).toBeLessThan(count) + expect(indexOf(fake, /INSERT INTO cleanup_team_invites/)).toBe(-1) + }) + + it("refuses an organization invite once the cap is reached, counted under a per-org lock", async () => { + const fake = makeFakeSql([ + { match: /SELECT role FROM organization_members/, rows: [{ role: "owner" }] }, + { + match: /SELECT count\(\*\)::int AS count FROM organization_invites/, + rows: [{ count: MAX_ORG_INVITES_PER_ORG }], + }, + ]) + + const invite = makeDrizzleOrganizationRepository(fake.sql as unknown as Sql).createInviteTx({ + inviteId: INVITE, + organizationId: ORG, + email: "new@example.org", + userId: null, + role: "member", + tokenHash: "hash", + invitedBy: HOST, + expiresAt: LATER, + now: NOW, + }) + + await expect(invite).rejects.toMatchObject({ + code: "CONFLICT", + message: "This organization already has the maximum number of open invitations.", + }) + const lock = indexOf(fake, /FROM organizations WHERE id = \? LIMIT 1 FOR UPDATE/) + const count = indexOf(fake, /count\(\*\)::int AS count FROM organization_invites/) + expect(lock).toBeGreaterThanOrEqual(0) + expect(lock).toBeLessThan(count) + expect(indexOf(fake, /INSERT INTO organization_invites/)).toBe(-1) + }) +}) diff --git a/services/api/test/unit/host/organization-repository-correctness.test.ts b/services/api/test/unit/host/organization-repository-correctness.test.ts new file mode 100644 index 00000000..3f915df1 --- /dev/null +++ b/services/api/test/unit/host/organization-repository-correctness.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest" +import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" +import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { makeFakeSql } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const ORG = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const OWNER = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +const MEMBER = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const NOW = new Date("2026-01-01T12:00:00.000Z") + +function uniqueViolation(constraint: string): Error { + return Object.assign(new Error("duplicate key value violates unique constraint"), { + code: "23505", + constraint_name: constraint, + }) +} + +function createArgs() { + return { + organizationId: ORG, + slug: "adopt-a-block", + name: "Adopt-a-Block", + description: null, + websiteUrl: null, + logoMediaId: null, + socialLinks: null, + createdBy: OWNER, + now: NOW, + } +} + +describe("creating an organization", () => { + it("answers slug_taken only for the slug index", async () => { + const fake = makeFakeSql([ + { + match: /INSERT INTO organizations\s*\(/, + rows: () => { + throw uniqueViolation("organizations_slug_uidx") + }, + }, + ]) + + await expect( + makeDrizzleOrganizationRepository(fake.sql as unknown as Sql).createOrganizationTx( + createArgs(), + ), + ).resolves.toBe("slug_taken") + }) + + it("surfaces any other unique violation instead of calling it a taken slug", async () => { + const fake = makeFakeSql([ + { + match: /INSERT INTO organization_members/, + rows: () => { + throw uniqueViolation("organization_members_one_owner_uidx") + }, + }, + ]) + + await expect( + makeDrizzleOrganizationRepository(fake.sql as unknown as Sql).createOrganizationTx( + createArgs(), + ), + ).rejects.toMatchObject({ constraint_name: "organization_members_one_owner_uidx" }) + }) +}) + +describe("seating a member by handle", () => { + it("is audited as a member added, not a role change", async () => { + const fake = makeFakeSql([ + { match: /SELECT role FROM organization_members/, rows: [{ role: "owner" }] }, + { match: /INSERT INTO organization_members/, rows: [{ user_id: MEMBER }] }, + { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, + ]) + + const outcome = await makeDrizzleOrganizationRepository(fake.sql as unknown as Sql).addMemberTx( + { + organizationId: ORG, + userId: MEMBER, + role: "member", + actorId: OWNER, + now: NOW, + }, + ) + + expect(outcome).toBe("added") + + const audit = fake.statements.find((s) => s.values.includes("org.member_added")) + expect(audit).toBeDefined() + expect(fake.statements.some((s) => s.values.includes("org.member_role_changed"))).toBe(false) + }) + + it("is audited the same way by the in-memory repository", async () => { + const repo = new InMemoryOrganizationRepository() + await repo.createOrganizationTx(createArgs()) + + const outcome = await repo.addMemberTx({ + organizationId: ORG, + userId: MEMBER, + role: "member", + actorId: OWNER, + now: NOW, + }) + + expect(outcome).toBe("added") + + expect(repo.audits.map((a) => a.action)).toEqual(["org.member_added"]) + }) +}) diff --git a/services/api/test/unit/host/organization-service.test.ts b/services/api/test/unit/host/organization-service.test.ts index 2d510a74..2e64e688 100644 --- a/services/api/test/unit/host/organization-service.test.ts +++ b/services/api/test/unit/host/organization-service.test.ts @@ -319,9 +319,7 @@ describe("membership", () => { it("audits every role change", async () => { const id = await seeded() await service.setMemberRole(id, OWNER, MEMBER, "admin") - expect( - repo.audits.filter((a) => a.action === "org.member_role_changed").length, - ).toBeGreaterThanOrEqual(3) + expect(repo.audits.filter((a) => a.action === "org.member_role_changed").length).toBe(1) }) it("refuses to let anyone change their own role", async () => { diff --git a/services/api/test/unit/host/page-service-correctness.test.ts b/services/api/test/unit/host/page-service-correctness.test.ts new file mode 100644 index 00000000..dda751e4 --- /dev/null +++ b/services/api/test/unit/host/page-service-correctness.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest" +import { PAGE_SLUG_MAX, PageSlugSchema } from "@civfix/shared" +import type { EventPageBlock } from "@civfix/shared" +import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" +import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { + HOST_PAGE_PUBLISH_PER_DAY, + makePageService, +} from "../../../src/services/host/page-service.js" +import { RESERVED_SLUGS } from "../../../src/services/host/slugs.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const OTHER_EVENT = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +const THIRD_EVENT = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +const HOST = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +const MEDIA = "ffffffff-ffff-4fff-8fff-ffffffffffff" +const NOW = new Date("2026-01-01T12:00:00.000Z") + +const ABOUT: EventPageBlock = { id: "b1", kind: "about", body: "Bring **gloves**." } + +function build() { + const repo = new InMemoryHostRegistrationRepository() + repo.seedEvent({ cleanupId: EVENT }) + repo.seedEvent({ cleanupId: OTHER_EVENT }) + repo.seedEvent({ cleanupId: THIRD_EVENT }) + const service = makePageService({ + repo, + counters: new InMemoryCounterStore(() => NOW.getTime()), + presignCover: (key) => Promise.resolve({ url: `memory://${key}` }), + now: () => NOW, + }) + return { repo, service } +} + +describe("the daily publish budget", () => { + it("is not spent by a publish the page was never ready for", async () => { + const { service } = build() + for (let i = 0; i < HOST_PAGE_PUBLISH_PER_DAY; i++) { + await expect(service.publish({ id: EVENT, published: true }, HOST)).rejects.toMatchObject({ + code: "VALIDATION", + }) + } + + await service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) + const published = await service.publish({ id: EVENT, published: true }, HOST) + + expect(published.status).toBe("published") + }) +}) + +describe("publishing a page flagged between the read and the write", () => { + it("answers 403, not a page", async () => { + const { repo, service } = build() + await service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) + repo.publishPage = () => Promise.resolve({ kind: "flagged" }) + + await expect(service.publish({ id: EVENT, published: true }, HOST)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "This page is under review and cannot be published.", + }) + }) +}) + +describe("slug suggestions", () => { + it("never suggests an address another event already holds", async () => { + const { repo, service } = build() + repo.seedEvent({ cleanupId: OTHER_EVENT, pageSlug: "beach-day" }) + repo.seedEvent({ cleanupId: THIRD_EVENT, pageSlug: "beach-day-2" }) + + const answer = await service.checkSlug({ id: EVENT, slug: "beach-day" }) + + expect(answer).toMatchObject({ available: false, reason: "taken" }) + expect(answer.suggestion).not.toBe("beach-day-2") + expect(await repo.slugTaken(EVENT, answer.suggestion as string)).toBe(false) + }) + + it("only suggests an address the slug schema accepts", async () => { + const { repo, service } = build() + const longest = "a".repeat(PAGE_SLUG_MAX) + repo.seedEvent({ cleanupId: OTHER_EVENT, pageSlug: longest }) + + const answer = await service.checkSlug({ id: EVENT, slug: longest }) + + expect(answer.reason).toBe("taken") + expect(answer.suggestion).not.toBeNull() + expect(PageSlugSchema.safeParse(answer.suggestion).success).toBe(true) + }) + + it("never suggests a reserved address for a reserved one", async () => { + const { service } = build() + for (const reserved of RESERVED_SLUGS) { + const answer = await service.checkSlug({ id: EVENT, slug: reserved }) + expect(answer.reason).toBe("reserved") + if (answer.suggestion != null) { + expect(RESERVED_SLUGS.has(answer.suggestion)).toBe(false) + expect(PageSlugSchema.safeParse(answer.suggestion).success).toBe(true) + } + } + }) +}) + +describe("block media lookups", () => { + it("surfaces a database failure instead of rendering the page without its images", async () => { + const { repo, service } = build() + repo.mediaKeys.set(MEDIA, "event-media/hero.jpg") + await service.save( + { + id: EVENT, + slug: "beach-sweep", + blocks: [{ id: "h1", kind: "hero", mediaId: MEDIA }], + }, + HOST, + ) + repo.mediaKeysFor = () => Promise.reject(new Error("connection terminated")) + + await expect(service.get({ id: EVENT })).rejects.toThrow("connection terminated") + }) + + it("still renders the page when only the presign fails", async () => { + const repo = new InMemoryHostRegistrationRepository() + repo.seedEvent({ cleanupId: EVENT }) + repo.mediaKeys.set(MEDIA, "event-media/hero.jpg") + const service = makePageService({ + repo, + presignCover: () => Promise.reject(new Error("signer offline")), + now: () => NOW, + }) + await service.save( + { + id: EVENT, + slug: "beach-sweep", + blocks: [{ id: "h1", kind: "hero", mediaId: MEDIA }], + }, + HOST, + ) + + const page = await service.get({ id: EVENT }) + + expect(page.blocks).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/host/page-views-route.test.ts b/services/api/test/unit/host/page-views-route.test.ts new file mode 100644 index 00000000..beb5cba5 --- /dev/null +++ b/services/api/test/unit/host/page-views-route.test.ts @@ -0,0 +1,52 @@ +import Fastify, { type FastifyInstance } from "fastify" +import { afterEach, describe, expect, it } from "vitest" +import { RecordEventPageViewResponseSchema } from "@civfix/shared" +import type { Container } from "../../../src/di.js" +import { registerPageViewRoutes } from "../../../src/routes/host/page-views.routes.js" +import type { CommsRuntime } from "../../../src/services/host/comms-wiring.js" + +let app: FastifyInstance | undefined + +afterEach(async () => { + await app?.close() + app = undefined +}) + +async function build(recordPageView: () => Promise<{ ok: true }>): Promise { + const instance = Fastify() + instance.decorate("broadcastOverrides", { + runtime: { metrics: { recordPageView } } as unknown as CommsRuntime, + }) + await registerPageViewRoutes(instance, {} as unknown as Container) + await instance.ready() + app = instance + return instance +} + +describe("POST /v1/pages/:slug/view", () => { + it("answers with the body the endpoint registry declares", async () => { + const instance = await build(() => Promise.resolve({ ok: true })) + + const res = await instance.inject({ + method: "POST", + url: "/v1/pages/park-day/view", + payload: {}, + }) + + expect(res.statusCode).toBe(200) + expect(RecordEventPageViewResponseSchema.safeParse(res.json()).success).toBe(true) + }) + + it("answers the same when the counter write fails", async () => { + const instance = await build(() => Promise.reject(new Error("redis down"))) + + const res = await instance.inject({ + method: "POST", + url: "/v1/pages/park-day/view", + payload: {}, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) +}) diff --git a/services/api/test/unit/host/registration-repository-correctness.test.ts b/services/api/test/unit/host/registration-repository-correctness.test.ts new file mode 100644 index 00000000..6fcd9826 --- /dev/null +++ b/services/api/test/unit/host/registration-repository-correctness.test.ts @@ -0,0 +1,526 @@ +import { describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { + makeDrizzleHostRegistrationRepository, + REGISTER_IDEMPOTENCY_SCOPE, +} from "../../../src/services/host/registration-repository.drizzle.js" +import { deterministicUuid } from "../../../src/services/deterministic-uuid.js" +import type { + RegisterTxArgs, + SeatDraft, +} from "../../../src/services/host/registration-repository.types.js" +import { WAITLIST_CLAIM_WINDOW_MS } from "../../../src/services/host/waitlist-service.js" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const USER = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +const SLOT = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const HOST = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +const TYPE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +const REGISTRATION = "ffffffff-ffff-4fff-8fff-ffffffffffff" +const WAITLIST = "abababab-abab-4bab-8bab-abababababab" +const GUEST = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd" +const NOW = new Date("2026-01-01T12:00:00.000Z") +const HOUR_MS = 60 * 60 * 1000 + +function repoOver(fake: FakeSqlControl) { + return makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql) +} + +function seats(n: number): SeatDraft[] { + return Array.from({ length: n }, () => ({ + id: randomUUID(), + attendeeName: null, + tokenHash: randomUUID(), + })) +} + +function registerArgs(over: Partial = {}): RegisterTxArgs { + return { + cleanupId: EVENT, + subject: { kind: "user", userId: USER }, + ticketTypeId: null, + seats: seats(1), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: "retry-key-1", + waitlistId: null, + now: NOW, + ...over, + } +} + +function eventRow(over: Record = {}) { + return { + status: "upcoming", + visibility: "public", + registration_opens_at: null, + registration_closes_at: null, + capacity: null, + scheduled_at: new Date(NOW.getTime() + 24 * HOUR_MS), + ends_at: new Date(NOW.getTime() + 28 * HOUR_MS), + now: NOW, + ...over, + } +} + +function registrationRow(over: Record = {}) { + return { + id: REGISTRATION, + cleanup_id: EVENT, + ticket_type_id: null, + ticket_type_name: null, + user_id: USER, + guest_id: null, + guest_name: null, + party_size: 1, + status: "registered", + source: "self", + host_note: null, + registered_at: NOW, + cancelled_at: null, + checked_in_at: null, + slot_id: null, + slot_title: null, + answers_preview: null, + person_display_name: null, + person_handle: null, + person_bio: null, + person_avatar_url: null, + person_deleted_at: null, + ...over, + } +} + +const EVENT_LOCK = /FROM cleanups WHERE id = \?\s+LIMIT 1 FOR SHARE/ +const REGISTRATION_INSERT = /INSERT INTO cleanup_registrations\s*\(/ +const REGISTRATION_RELOAD = /FROM cleanup_registrations r\s/ + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ").trim() +} + +describe("a slot claimed while registering is claimed under the slot's row lock", () => { + function slotHandlers(claimed: number): SqlHandler[] { + return [ + { match: EVENT_LOCK, rows: [eventRow()] }, + { match: REGISTRATION_INSERT, rows: [{ id: REGISTRATION }] }, + { match: /FROM cleanup_slots\s+WHERE id = \?/, rows: [{ capacity: 1 }] }, + { match: /SELECT slot_id FROM cleanup_slot_claims/, rows: [] }, + { match: /count\(\*\)::int AS n FROM cleanup_slot_claims/, rows: [{ n: claimed }] }, + { match: REGISTRATION_RELOAD, rows: [registrationRow({ slot_id: SLOT })] }, + ] + } + + it("refuses the whole registration when the slot is already full", async () => { + const fake = makeFakeSql(slotHandlers(1)) + + await expect(repoOver(fake).registerTx(registerArgs({ slotId: SLOT }))).rejects.toMatchObject({ + code: "CONFLICT", + message: "That slot is already full.", + }) + const lock = fake.statements.find((s) => /FROM cleanup_slots/.test(s.sql)) + expect(flat(lock?.sql ?? "")).toContain("FOR UPDATE") + expect(fake.statements.some((s) => /INSERT INTO cleanup_slot_claims/.test(s.sql))).toBe(false) + }) + + it("claims the slot when it still has room", async () => { + const fake = makeFakeSql(slotHandlers(0)) + + const outcome = await repoOver(fake).registerTx(registerArgs({ slotId: SLOT })) + + expect(outcome.kind).toBe("registered") + const claim = fake.statements.find((s) => /INSERT INTO cleanup_slot_claims/.test(s.sql)) + expect(claim?.values).toEqual([EVENT, USER, SLOT]) + }) + + it("refuses a slot that is not on this event instead of dropping it silently", async () => { + const fake = makeFakeSql([ + { match: /FROM cleanup_slots\s+WHERE id = \?/, rows: [] }, + ...slotHandlers(0), + ]) + + await expect(repoOver(fake).registerTx(registerArgs({ slotId: SLOT }))).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "That slot no longer exists.", + }) + expect(fake.statements.some((s) => /INSERT INTO cleanup_slot_claims/.test(s.sql))).toBe(false) + }) +}) + +describe("a retried registration replays its committed result", () => { + it("replays a committed registration after the sales window has closed", async () => { + const fake = makeFakeSql([ + { + match: EVENT_LOCK, + rows: [eventRow({ registration_closes_at: new Date(NOW.getTime() - HOUR_MS) })], + }, + { + match: /SELECT response_snapshot FROM idempotency_keys/, + rows: [{ response_snapshot: { registrationId: REGISTRATION } }], + }, + ]) + + const outcome = await repoOver(fake).registerTx(registerArgs()) + + expect(outcome).toEqual({ kind: "replayed", registration: null }) + }) + + it("replays when a concurrent twin with the same key won the active-registration index", async () => { + let snapshotReads = 0 + const fake = makeFakeSql([ + { match: EVENT_LOCK, rows: [eventRow()] }, + { + match: /SELECT response_snapshot FROM idempotency_keys/, + rows: () => { + snapshotReads += 1 + return snapshotReads === 1 + ? [] + : [{ response_snapshot: { registrationId: REGISTRATION } }] + }, + }, + { + match: REGISTRATION_INSERT, + rows: () => { + throw Object.assign(new Error("duplicate key"), { + code: "23505", + constraint_name: "cleanup_registrations_active_user_uidx", + }) + }, + }, + { match: REGISTRATION_RELOAD, rows: [registrationRow()] }, + ]) + + const outcome = await repoOver(fake).registerTx(registerArgs()) + + expect(outcome.kind).toBe("replayed") + expect(outcome.kind === "replayed" ? outcome.registration?.id : null).toBe(REGISTRATION) + }) + + it("still answers already_registered when the winner used a different key", async () => { + const fake = makeFakeSql([ + { match: EVENT_LOCK, rows: [eventRow()] }, + { + match: REGISTRATION_INSERT, + rows: () => { + throw Object.assign(new Error("duplicate key"), { + code: "23505", + constraint_name: "cleanup_registrations_active_user_uidx", + }) + }, + }, + ]) + + await expect(repoOver(fake).registerTx(registerArgs())).resolves.toEqual({ + kind: "already_registered", + }) + }) +}) + +describe("waitlist offers skip cancelled and ended events", () => { + it("scopes both offer queries to a live event without locking the event row", async () => { + for (const offer of [ + (fake: FakeSqlControl) => + repoOver(fake).offerNextWaitlistEntry({ + ticketTypeId: TYPE, + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }), + (fake: FakeSqlControl) => + repoOver(fake).offerWaitlistEntry({ + cleanupId: EVENT, + waitlistId: WAITLIST, + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }), + ]) { + const fake = makeFakeSql() + await expect(offer(fake)).resolves.toBeNull() + const select = flat(fake.statements[0]?.sql ?? "") + expect(select).toContain("FROM cleanups c WHERE c.id = w.cleanup_id") + expect(select).toContain("c.status <> 'cancelled'") + expect(select).toContain("COALESCE(c.ends_at, c.scheduled_at +") + expect(select).toContain("> now()") + expect(select).toMatch(/FOR UPDATE( SKIP LOCKED)?$/) + expect(select).not.toMatch(/JOIN cleanups/) + } + }) + + it("releases the hold instead of registering when the event ended before the claim", async () => { + const fake = makeFakeSql([ + { + match: /FROM cleanup_waitlist w/, + rows: [ + { + id: WAITLIST, + cleanup_id: EVENT, + ticket_type_id: TYPE, + ticket_type_name: "General", + user_id: USER, + guest_id: null, + guest_name: null, + party_size: 1, + status: "offered", + position: null, + created_at: NOW, + offered_at: NOW, + claim_expires_at: new Date(NOW.getTime() + HOUR_MS), + person_display_name: null, + person_handle: null, + person_bio: null, + person_avatar_url: null, + person_deleted_at: null, + }, + ], + }, + { + match: /FROM cleanups\s+WHERE id = \?\s+LIMIT 1 FOR SHARE/, + rows: [ + eventRow({ + scheduled_at: new Date(NOW.getTime() - 6 * HOUR_MS), + ends_at: new Date(NOW.getTime() - HOUR_MS), + }), + ], + }, + { + match: /SET status = 'claimed'/, + rows: [{ party_size: 1, user_id: USER, guest_id: null }], + }, + ]) + + const outcome = await repoOver(fake).claimWaitlistOffer({ + cleanupId: EVENT, + waitlistId: WAITLIST, + subject: { kind: "user", userId: USER }, + seats: seats(1), + now: NOW, + }) + + expect(outcome).toEqual({ kind: "not_offered" }) + expect(fake.statements.some((s) => /SET status = 'expired'/.test(s.sql))).toBe(true) + expect(fake.statements.some((s) => REGISTRATION_INSERT.test(s.sql))).toBe(false) + }) +}) + +describe("a transfer names the ticket type it vacated", () => { + it("returns the previous ticket type so the caller can promote its waitlist", async () => { + const fake = makeFakeSql([ + { match: /FROM cleanups WHERE id = \? LIMIT 1 FOR NO KEY UPDATE/, rows: [{ id: EVENT }] }, + { + match: + /FROM cleanup_registrations\s+WHERE id = \? AND cleanup_id = \?\s+LIMIT 1 FOR UPDATE/, + rows: [{ id: REGISTRATION, ticket_type_id: TYPE, party_size: 1, status: "registered" }], + }, + { + match: /SELECT id, max_party_size, capacity, reserved_seats/, + rows: [ + { id: TYPE, max_party_size: 4, capacity: 1, reserved_seats: 1 }, + { id: SLOT, max_party_size: 4, capacity: null, reserved_seats: 0 }, + ], + }, + { match: /SET reserved_seats = reserved_seats \+/, rows: [{ id: SLOT }] }, + { match: REGISTRATION_RELOAD, rows: [registrationRow({ ticket_type_id: SLOT })] }, + ]) + + const outcome = await repoOver(fake).transferRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + ticketTypeId: SLOT, + now: NOW, + }) + + expect(outcome).toMatchObject({ kind: "transferred", previousTicketTypeId: TYPE }) + }) +}) + +describe("publishing re-checks the review flag in the write itself", () => { + it("refuses to publish a page an operator flagged after the service read it", async () => { + const fake = makeFakeSql([ + { match: /UPDATE cleanup_pages/, rows: [] }, + { match: /SELECT flagged_at FROM cleanup_pages/, rows: [{ flagged_at: NOW }] }, + ]) + + const outcome = await repoOver(fake).publishPage({ + cleanupId: EVENT, + published: true, + actorId: HOST, + now: NOW, + }) + + expect(outcome).toEqual({ kind: "flagged" }) + const update = flat(fake.statements[0]?.sql ?? "") + expect(update).toContain("flagged_at IS NULL") + }) + + it("still lets a flagged page be unpublished", async () => { + const fake = makeFakeSql([ + { match: /UPDATE cleanup_pages/, rows: [{ cleanup_id: EVENT }] }, + { + match: /FROM cleanups c\s+LEFT JOIN cleanup_pages p/, + rows: [ + { + cleanup_id: EVENT, + slug: "park-day", + status: "unpublished", + theme_accent: "bloom", + blocks: [], + seo: {}, + cover_media_id: null, + cover_key: null, + visibility: "public", + published_at: null, + updated_at: NOW, + flagged_at: NOW, + flag_reason: "spam", + view_count: 0, + }, + ], + }, + ]) + + const outcome = await repoOver(fake).publishPage({ + cleanupId: EVENT, + published: false, + actorId: HOST, + now: NOW, + }) + + expect(outcome.kind).toBe("published") + expect(fake.statements[0]?.values).toContain(false) + }) +}) + +describe("a walk-up checked in on arrival is registered and checked in atomically", () => { + it("checks every seat in with one statement inside the registration transaction", async () => { + const fake = makeFakeSql([ + { match: /INSERT INTO cleanup_guests/, rows: [{ id: GUEST }] }, + { match: EVENT_LOCK, rows: [eventRow()] }, + { match: REGISTRATION_INSERT, rows: [{ id: REGISTRATION }] }, + { + match: REGISTRATION_RELOAD, + rows: [registrationRow({ user_id: null, guest_id: GUEST, source: "walkup" })], + }, + ]) + let began = -1 + let ended = -1 + const begin = fake.sql.begin + fake.sql.begin = async (cb) => { + began = fake.statements.length + const result = await begin(cb) + ended = fake.statements.length + return result + } + + const outcome = await repoOver(fake).registerWalkupTx({ + cleanupId: EVENT, + name: "Pat Walker", + manageTokenHash: "hash", + ticketTypeId: null, + seats: seats(2), + idempotencyKey: "walkup-key", + idempotencyOwner: `user:${HOST}`, + now: NOW, + checkIn: { actorId: HOST, method: "walkup" }, + }) + + expect(outcome.kind).toBe("registered") + const checkIns = fake.statements + .map((s, index) => ({ ...s, index })) + .filter((s) => /UPDATE cleanup_registration_seats/.test(s.sql) && /checked_in_at/.test(s.sql)) + expect(checkIns).toHaveLength(1) + expect(checkIns[0]?.index).toBeGreaterThanOrEqual(began) + expect(checkIns[0]?.index).toBeLessThan(ended) + expect(checkIns[0]?.values).toEqual(expect.arrayContaining([REGISTRATION, HOST, "walkup"])) + }) +}) + +describe("a same-key twin serializes on its idempotency key", () => { + const KEY_LOCK = /pg_advisory_xact_lock\(hashtext\(/ + const SNAPSHOT = /SELECT response_snapshot FROM idempotency_keys/ + + function lockedKeyOf(args: RegisterTxArgs): string { + return deterministicUuid([REGISTER_IDEMPOTENCY_SCOPE, `user:${USER}`, args.idempotencyKey]) + } + + it("takes the key lock before any other statement, so a twin reads the winner's snapshot", async () => { + const args = registerArgs() + const fake = makeFakeSql([ + { match: EVENT_LOCK, rows: [eventRow()] }, + { match: REGISTRATION_INSERT, rows: [{ id: REGISTRATION }] }, + { match: REGISTRATION_RELOAD, rows: [registrationRow()] }, + ]) + + await repoOver(fake).registerTx(args) + + const first = fake.statements[0] + expect(first?.sql).toMatch(KEY_LOCK) + expect(first?.values).toContain(lockedKeyOf(args)) + const snapshotAt = fake.statements.findIndex((s) => SNAPSHOT.test(s.sql)) + expect(snapshotAt).toBeGreaterThan(0) + expect(fake.statements.filter((s) => KEY_LOCK.test(s.sql))).toHaveLength(1) + }) + + it("replays the winner's registration instead of answering full once the lock is granted", async () => { + const fake = makeFakeSql([ + { match: EVENT_LOCK, rows: [eventRow({ capacity: 1 })] }, + { match: SNAPSHOT, rows: [{ response_snapshot: { registrationId: REGISTRATION } }] }, + { match: /COALESCE\(sum\(party_size\), 0\)/, rows: [{ held: 1 }] }, + { match: REGISTRATION_RELOAD, rows: [registrationRow()] }, + ]) + + const outcome = await repoOver(fake).registerTx(registerArgs()) + + expect(outcome.kind).toBe("replayed") + const keyLockAt = fake.statements.findIndex((s) => KEY_LOCK.test(s.sql)) + const snapshotAt = fake.statements.findIndex((s) => SNAPSHOT.test(s.sql)) + expect(keyLockAt).toBe(0) + expect(snapshotAt).toBeGreaterThan(keyLockAt) + }) +}) + +describe("registering with a slot takes ticket type, then slot, then member rows", () => { + it("locks the slot after the seat reserve and before the member and registration inserts", async () => { + const fake = makeFakeSql([ + { match: EVENT_LOCK, rows: [eventRow()] }, + { + match: /FROM cleanup_ticket_types\s+WHERE cleanup_id = \?\s+ORDER BY sort_order, id/, + rows: [ + { + id: TYPE, + capacity: 5, + reserved_seats: 0, + sales_opens_at: null, + sales_closes_at: null, + visibility: "public", + access_code_hash: null, + max_party_size: 4, + }, + ], + }, + { match: /SET reserved_seats = reserved_seats \+/, rows: [{ id: TYPE }] }, + { match: /FROM cleanup_slots\s+WHERE id = \?/, rows: [{ capacity: 3 }] }, + { match: /count\(\*\)::int AS n FROM cleanup_slot_claims/, rows: [{ n: 0 }] }, + { match: REGISTRATION_INSERT, rows: [{ id: REGISTRATION }] }, + { match: REGISTRATION_RELOAD, rows: [registrationRow({ slot_id: SLOT })] }, + ]) + + const outcome = await repoOver(fake).registerTx( + registerArgs({ ticketTypeId: TYPE, slotId: SLOT }), + ) + + expect(outcome.kind).toBe("registered") + const at = (match: RegExp): number => fake.statements.findIndex((s) => match.test(s.sql)) + const reserve = at(/SET reserved_seats = reserved_seats \+/) + const slotLock = at(/FROM cleanup_slots\s+WHERE id = \?/) + const memberInsert = at(/INSERT INTO cleanup_members/) + const registrationInsert = at(REGISTRATION_INSERT) + expect(reserve).toBeGreaterThanOrEqual(0) + expect(slotLock).toBeGreaterThan(reserve) + expect(memberInsert).toBeGreaterThan(slotLock) + expect(registrationInsert).toBeGreaterThan(slotLock) + }) +}) diff --git a/services/api/test/unit/host/registration-service.test.ts b/services/api/test/unit/host/registration-service.test.ts index 07270d33..f955c0da 100644 --- a/services/api/test/unit/host/registration-service.test.ts +++ b/services/api/test/unit/host/registration-service.test.ts @@ -504,6 +504,7 @@ describe("registration service", () => { counters: { incr: () => Promise.reject(new Error("redis is down")), incrBy: () => Promise.reject(new Error("redis is down")), + decrBy: () => Promise.reject(new Error("redis is down")), }, }) await expect(broken.register(request(), { kind: "user", userId: OTHER })).rejects.toThrow( diff --git a/services/api/test/unit/host/roster-checked-in-cursor.test.ts b/services/api/test/unit/host/roster-checked-in-cursor.test.ts new file mode 100644 index 00000000..e35b7b28 --- /dev/null +++ b/services/api/test/unit/host/roster-checked-in-cursor.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest" +import type { Sql } from "../../../src/db/client.js" +import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" +import type { RosterQuery } from "../../../src/services/host/registration-repository.types.js" +import { makeFakeSql } from "../../helpers/fake-sql.js" + +const EVENT = "11111111-1111-4111-8111-111111111111" +const EPOCH = new Date(0) +const PAGE_SIZE = 2 + +interface RosterRow { + id: string + registered_at: Date + checked_in_at: Date | null + cursor_at: string + checked_in_cursor_at: string +} + +const CHECKED_IN = row("aaaaaaaa-0000-4000-8000-000000000009", "2026-09-01T09:00:00.000Z", { + checkedInAt: "2026-09-02T10:00:00.000Z", +}) +const LATEST = row("aaaaaaaa-0000-4000-8000-000000000001", "2026-09-01T12:00:00.000Z") +const MIDDLE = row("aaaaaaaa-0000-4000-8000-000000000003", "2026-09-01T11:00:00.000Z") +const EARLIEST = row("aaaaaaaa-0000-4000-8000-000000000002", "2026-09-01T10:00:00.000Z") +const ROSTER = [CHECKED_IN, LATEST, MIDDLE, EARLIEST] + +/** The instant as Postgres renders it for a cursor: microsecond text in UTC. */ +function microText(at: Date): string { + return at.toISOString().replace("Z", "000Z") +} + +const ISO_INSTANT = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,6}))?Z$/ + +function micros(text: string): number { + const match = ISO_INSTANT.exec(text) + if (match === null) throw new Error(`not an instant: ${text}`) + return Date.parse(`${match[1]}Z`) * 1000 + Number((match[2] ?? "").padEnd(6, "0")) +} + +function row(id: string, registeredAt: string, opts: { checkedInAt?: string } = {}): RosterRow { + const checkedInAt = opts.checkedInAt === undefined ? null : new Date(opts.checkedInAt) + return { + id, + registered_at: new Date(registeredAt), + checked_in_at: checkedInAt, + cursor_at: microText(new Date(registeredAt)), + checked_in_cursor_at: microText(checkedInAt ?? EPOCH), + } +} + +function orderKey(r: RosterRow): [number, number, string] { + return [micros(r.checked_in_cursor_at), micros(r.cursor_at), r.id] +} + +function tupleBefore(left: (number | string)[], right: (number | string)[]): boolean { + for (let i = 0; i < left.length; i += 1) { + if (left[i]! < right[i]!) return true + if (left[i]! > right[i]!) return false + } + return false +} + +function asKeyPart(value: unknown): number | string { + if (value instanceof Date) return value.getTime() * 1000 + const text = String(value) + return ISO_INSTANT.test(text) ? micros(text) : text +} + +/** + * Serves the roster the way Postgres would: ORDER BY the checked_in_at_desc keys, then the recorded + * cursor predicate, whose tuple width is read off the statement so the old two-column shape is + * evaluated as written rather than as intended. + */ +function servedRoster(sqlText: string, values: unknown[]): RosterRow[] { + const limit = values[values.length - 1] as number + const anchor = values.slice(values.indexOf(EVENT) + 1, -1).map(asKeyPart) + const ordered = [...ROSTER].sort((a, b) => (tupleBefore(orderKey(a), orderKey(b)) ? 1 : -1)) + let visible = ordered + if (/'epoch'::timestamptz\), r\.registered_at, r\.id\) < \(/.test(sqlText)) { + visible = ordered.filter((r) => tupleBefore(orderKey(r), anchor)) + } else if (/'epoch'::timestamptz\), r\.id\) < \(/.test(sqlText)) { + visible = ordered.filter((r) => tupleBefore([orderKey(r)[0], r.id], anchor)) + } else if (/'epoch'::timestamptz\) <= \?/.test(sqlText)) { + visible = ordered.filter((r) => orderKey(r)[0] <= (anchor[0] as number)) + } else if (anchor.length > 0) { + throw new Error(`unrecognised roster cursor predicate: ${sqlText}`) + } + return visible.slice(0, limit) +} + +async function page(cursor: string | null): Promise<{ ids: string[]; nextCursor: string | null }> { + const query: RosterQuery = { + cleanupId: EVENT, + filter: "all", + ticketTypeId: null, + slotId: null, + sort: "checked_in_at_desc", + q: null, + cursor, + limit: PAGE_SIZE, + withTotal: false, + } + const served = makeFakeSql([ + { + match: /FROM cleanup_registrations r\b[\s\S]*ORDER BY/, + rows: (values) => { + const statement = served.statements[served.statements.length - 1]! + return servedRoster(statement.sql, values) + }, + }, + ]) + const result = await makeDrizzleHostRegistrationRepository( + served.sql as unknown as Sql, + ).listRoster(query) + return { ids: result.rows.map((r) => r.id), nextCursor: result.nextCursor } +} + +async function walk(first: string | null): Promise { + const seen: string[] = [] + let cursor = first + for (let guard = 0; guard < ROSTER.length + 2; guard += 1) { + const next = await page(cursor) + seen.push(...next.ids) + if (next.nextCursor === null) return seen + cursor = next.nextCursor + } + throw new Error("roster paging never ended") +} + +describe("the checked-in roster sort pages on its full ORDER BY", () => { + it("serves page 2 past a tie on the check-in time without skipping or repeating", async () => { + const first = await page(null) + expect(first.ids).toEqual([CHECKED_IN.id, LATEST.id]) + + const second = await page(first.nextCursor) + + expect(second.ids).toEqual([MIDDLE.id, EARLIEST.id]) + }) + + it("visits every registration exactly once across all pages", async () => { + await expect(walk(null)).resolves.toEqual(ROSTER.map((r) => r.id)) + }) + + it("still accepts a cursor issued before registered_at joined it, repeating ties rather than skipping", async () => { + const legacy = `${EPOCH.toISOString()}|${LATEST.id}` + + const seen = await walk(legacy) + + expect(new Set(seen)).toEqual(new Set([LATEST.id, MIDDLE.id, EARLIEST.id])) + }) +}) diff --git a/services/api/test/unit/host/ticket-type-service.test.ts b/services/api/test/unit/host/ticket-type-service.test.ts index 39cb22df..47effd15 100644 --- a/services/api/test/unit/host/ticket-type-service.test.ts +++ b/services/api/test/unit/host/ticket-type-service.test.ts @@ -229,6 +229,7 @@ describe("ticket type service", () => { counters: { incr: () => Promise.reject(new Error("redis is down")), incrBy: () => Promise.reject(new Error("redis is down")), + decrBy: () => Promise.reject(new Error("redis is down")), }, }) await expect(broken.create({ ...base, name: "Nope" }, HOST)).rejects.toThrow( diff --git a/services/api/test/unit/inbound-bounce-redrive.test.ts b/services/api/test/unit/inbound-bounce-redrive.test.ts new file mode 100644 index 00000000..71247a69 --- /dev/null +++ b/services/api/test/unit/inbound-bounce-redrive.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from "vitest" +import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" +import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { + processInboundObject, + INBOUND_BOUNCE_MAX_ATTEMPTS, + INBOUND_PENDING_PREFIX, + type InboundProcessorDeps, +} from "../../src/services/admin/inbound-processor.js" +import type { Container } from "../../src/di.js" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleMailRepository } from "../../src/services/admin/mail-repository.drizzle.js" +import { JURISDICTION_DISCOVERY_JOB } from "../../src/services/jurisdiction-service.js" + +const GEOID = "0644000" +const FAILED = "clerk@lacity.gov" +const OUTBOUND_ID = "" +const KEY = `${INBOUND_PENDING_PREFIX}dsn-1.eml` + +function dsnBytes(): Buffer { + return Buffer.from( + [ + "From: mailer-daemon@lacity.gov", + "To: outreach@civfix.org", + "Message-ID: ", + `X-Failed-Recipients: ${FAILED}`, + "", + "Your message could not be delivered.", + `Original-Message-ID: ${OUTBOUND_ID}`, + ].join("\n"), + "utf8", + ) +} + +function setup() { + const storage = new FakeStorage() + const mailRepo = new InMemoryMailRepository() + const inboundRepo = new InMemoryInboundRepository() + const jobs = new FakeJobs() + const db = makeFakeSql([ + { match: /UPDATE\s+jurisdiction_contacts\s+SET\s+bounced_at/i, rows: [] }, + { match: /FROM\s+mail_messages/i, rows: [{ ok: true }] }, + ]) + const warn = vi.fn() + const deps: InboundProcessorDeps = { + storage, + inboundMail: new FakeInboundMail(), + mailRepo, + inboundRepo, + logger: { warn, error: vi.fn() }, + } + const container = { + env: {}, + storage, + inboundStorage: storage, + inboundMail: deps.inboundMail, + jobs, + getDb: () => ({ sql: db.sql }), + } as unknown as Container + const thread = mailRepo.seedThread({ jurisdictionGeoid: GEOID, status: "sent" }) + mailRepo.seedMessage({ threadId: thread.id, direction: "out", messageId: OUTBOUND_ID }) + return { storage, mailRepo, inboundRepo, jobs, deps, container, thread, warn } +} + +function bouncedEvents(repo: InMemoryMailRepository) { + return repo.events.filter((e) => e.type === "bounced") +} + +describe("bounce bookkeeping survives a transient failure", () => { + it("keeps the pending object and logs when the bookkeeping fails, then completes it on the next run", async () => { + const s = setup() + const realRecordEvent = s.mailRepo.recordEvent.bind(s.mailRepo) + let failures = 1 + s.mailRepo.recordEvent = (input) => { + if (input.type === "bounced" && failures > 0) { + failures -= 1 + return Promise.reject(new Error("db connection reset")) + } + return realRecordEvent(input) + } + await s.storage.put(KEY, dsnBytes()) + + const first = await processInboundObject(s.container, KEY, s.deps) + expect(first.outcome).toBe("inbox") + expect(await s.storage.getObject(KEY)).not.toBeNull() + expect(s.warn).toHaveBeenCalledWith( + expect.objectContaining({ key: KEY, originalMessageId: OUTBOUND_ID }), + expect.any(String), + ) + expect(bouncedEvents(s.mailRepo)).toHaveLength(0) + + const second = await processInboundObject(s.container, KEY, s.deps) + expect(second.outcome).toBe("replay") + expect(await s.storage.getObject(KEY)).toBeNull() + expect(bouncedEvents(s.mailRepo)).toHaveLength(1) + expect((await s.mailRepo.getThreadRecord(s.thread.id))?.status).toBe("bounced") + }) + + it("does not record a second bounce or reset the status when a finished DSN is delivered again", async () => { + const s = setup() + await s.storage.put(KEY, dsnBytes()) + await processInboundObject(s.container, KEY, s.deps) + expect(bouncedEvents(s.mailRepo)).toHaveLength(1) + await s.mailRepo.setThreadStatus(s.thread.id, "replied") + + await s.storage.put(KEY, dsnBytes()) + const again = await processInboundObject(s.container, KEY, s.deps) + expect(again.outcome).toBe("replay") + expect(await s.storage.getObject(KEY)).toBeNull() + expect(bouncedEvents(s.mailRepo)).toHaveLength(1) + expect((await s.mailRepo.getThreadRecord(s.thread.id))?.status).toBe("replied") + }) +}) + +describe("bounce discovery sees the bounce it was enqueued for", () => { + it("records the bounced event before it enqueues discovery", async () => { + const s = setup() + const eventsAtEnqueue: number[] = [] + const realEnqueue = s.jobs.enqueue.bind(s.jobs) + s.jobs.enqueue = (name, data, opts) => { + if (name === JURISDICTION_DISCOVERY_JOB) + eventsAtEnqueue.push(bouncedEvents(s.mailRepo).length) + return realEnqueue(name, data, opts) + } + await s.storage.put(KEY, dsnBytes()) + await processInboundObject(s.container, KEY, s.deps) + expect(eventsAtEnqueue).toEqual([1]) + }) + + it("re-enqueues discovery on the next run when the enqueue failed after the bounce was recorded", async () => { + const s = setup() + const realEnqueue = s.jobs.enqueue.bind(s.jobs) + let failures = 1 + s.jobs.enqueue = (name, data, opts) => { + if (name === JURISDICTION_DISCOVERY_JOB && failures > 0) { + failures -= 1 + return Promise.reject(new Error("pg-boss unavailable")) + } + return realEnqueue(name, data, opts) + } + await s.storage.put(KEY, dsnBytes()) + await processInboundObject(s.container, KEY, s.deps) + expect(await s.storage.getObject(KEY)).not.toBeNull() + expect(bouncedEvents(s.mailRepo)).toHaveLength(1) + await s.mailRepo.setThreadStatus(s.thread.id, "replied") + + await processInboundObject(s.container, KEY, s.deps) + expect(await s.storage.getObject(KEY)).toBeNull() + expect(s.jobs.jobsFor(JURISDICTION_DISCOVERY_JOB)).toHaveLength(1) + expect(bouncedEvents(s.mailRepo)).toHaveLength(1) + expect((await s.mailRepo.getThreadRecord(s.thread.id))?.status).toBe("replied") + }) +}) + +describe("a DSN whose bounce bookkeeping never succeeds", () => { + const FAILED_KEY = KEY.replace(INBOUND_PENDING_PREFIX, "inbound/failed/") + + function failEveryBounceEvent(s: ReturnType): void { + s.mailRepo.recordEvent = (input) => + input.type === "bounced" + ? Promise.reject(new Error("constraint violation")) + : Promise.resolve("event-id") + } + + it("stays pending until the attempt cap, then is parked under inbound/failed/", async () => { + const s = setup() + failEveryBounceEvent(s) + await s.storage.put(KEY, dsnBytes()) + + for (let attempt = 1; attempt < INBOUND_BOUNCE_MAX_ATTEMPTS; attempt += 1) { + await processInboundObject(s.container, KEY, s.deps) + expect(await s.storage.getObject(KEY)).not.toBeNull() + } + const last = await processInboundObject(s.container, KEY, s.deps) + + expect(last).toMatchObject({ outcome: "failed", reason: "bounce-bookkeeping" }) + expect(await s.storage.getObject(KEY)).toBeNull() + expect(await s.storage.getObject(FAILED_KEY)).not.toBeNull() + expect(s.inboundRepo.bounceAttempts.size).toBe(0) + }) + + it("forgets earlier failures once the bookkeeping succeeds", async () => { + const s = setup() + const realRecordEvent = s.mailRepo.recordEvent.bind(s.mailRepo) + let failures = INBOUND_BOUNCE_MAX_ATTEMPTS - 1 + s.mailRepo.recordEvent = (input) => { + if (input.type === "bounced" && failures > 0) { + failures -= 1 + return Promise.reject(new Error("constraint violation")) + } + return realRecordEvent(input) + } + await s.storage.put(KEY, dsnBytes()) + for (let attempt = 1; attempt < INBOUND_BOUNCE_MAX_ATTEMPTS; attempt += 1) { + await processInboundObject(s.container, KEY, s.deps) + } + const done = await processInboundObject(s.container, KEY, s.deps) + + expect(done.outcome).toBe("replay") + expect(await s.storage.getObject(KEY)).toBeNull() + expect(await s.storage.getObject(FAILED_KEY)).toBeNull() + expect(s.inboundRepo.bounceAttempts.size).toBe(0) + }) +}) + +describe("the stored bounce marker", () => { + const marker = { threadId: "t-1", failedRecipient: FAILED, originalMessageId: OUTBOUND_ID } + + it("reads as none, discovery pending, or complete", async () => { + const cases = [ + [{ recorded: false, complete: false }, "none"], + [{ recorded: true, complete: false }, "discovery_pending"], + [{ recorded: true, complete: true }, "complete"], + ] as const + for (const [row, state] of cases) { + const fake = makeFakeSql([{ match: /FROM mail_events/, rows: [row] }]) + const repo = makeDrizzleMailRepository(fake.sql as unknown as Sql) + expect(await repo.bounceEventState(marker)).toBe(state) + } + }) + + it("clears only the pending flag once discovery is enqueued", async () => { + const fake = makeFakeSql() + await makeDrizzleMailRepository(fake.sql as unknown as Sql).markBounceDiscoveryEnqueued(marker) + const update = fake.statements[0] + expect(update?.sql).toMatch(/SET meta = meta - \?::text/) + expect(update?.values).toContain("discoveryPending") + expect(update?.sql).toMatch(/type = 'bounced'/) + }) +}) diff --git a/services/api/test/unit/inbound-event-reply-note.test.ts b/services/api/test/unit/inbound-event-reply-note.test.ts new file mode 100644 index 00000000..d8210902 --- /dev/null +++ b/services/api/test/unit/inbound-event-reply-note.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { onEventReply } from "../../src/services/admin/inbound-thread-correlation.js" +import { MESSAGE_BODY_MAX } from "@civfix/shared" +import type { CleanupRepository } from "../../src/services/cleanup-service.js" +import type { Container } from "../../src/di.js" + +const REPLY_DOMAIN = "civfix.org" + +async function timelineNoteFor(body: string | null): Promise { + const notes: (string | null | undefined)[] = [] + const cleanupRepo = { + appendCleanupTimeline: (_id: string, entry: { note?: string | null }) => { + notes.push(entry.note) + return Promise.resolve() + }, + } as unknown as CleanupRepository + const mailRepo = new InMemoryMailRepository() + const thread = mailRepo.seedThread({ cleanupId: "cleanup-1" }) + const message = await mailRepo.insertMessage({ + threadId: thread.id, + direction: "in", + fromAddr: "clerk@lacity.gov", + body, + }) + const container = { env: { MAIL_REPLY_DOMAIN: REPLY_DOMAIN } } as unknown as Container + await onEventReply(container, cleanupRepo, mailRepo, thread, message!, 0) + return notes[0] +} + +describe("event reply timeline note", () => { + it("keeps the city's own words and drops the quoted history", async () => { + const note = await timelineNoteFor( + [ + "Thanks, a crew will be there Saturday.", + "", + "On Mon, Jan 5, 2026 at 9:00 AM civfix wrote:", + "> Hello, residents are organizing a cleanup at the park.", + ].join("\n"), + ) + expect(note).toBe("Thanks, a crew will be there Saturday.") + }) + + it("clips a long reply to the message body limit", async () => { + const note = await timelineNoteFor("x".repeat(MESSAGE_BODY_MAX + 500)) + expect(note?.length).toBe(MESSAGE_BODY_MAX) + }) + + it("falls back to a fixed note when the reply has no text of its own", async () => { + expect(await timelineNoteFor(null)).toBe("Jurisdiction replied") + expect(await timelineNoteFor("> only quoted text")).toBe("Jurisdiction replied") + }) +}) diff --git a/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts b/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts index 360c3fb2..32549c86 100644 --- a/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts +++ b/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts @@ -200,6 +200,94 @@ describe("sanitizer is linear at the size cap (H14)", () => { expect(sanitizeInboundHtml('a')).toBe('a') }) + it("keeps the spaces in title and alt text", () => { + expect(sanitizeInboundHtml('go')).toBe( + 'go', + ) + expect(sanitizeInboundHtml('City of Los Angeles logo')).toBe( + 'City of Los Angeles logo', + ) + }) + + it("keeps an href's own spaces so the link still points where the sender meant", () => { + expect(sanitizeInboundHtml('go')).toBe( + 'go', + ) + }) + + it("decodes common named entities once instead of double-encoding them", () => { + expect(sanitizeInboundHtml('x')).toBe( + 'x', + ) + expect(sanitizeInboundHtml('a b <c> it's')).toBe( + 'a\u00a0b <c> it\'s', + ) + }) + + it("still refuses a script scheme hidden behind spaces, controls or named entities", () => { + for (const href of [ + "java script:alert(1)", + " \u0001javascript:alert(1)", + "java script:alert(1)", + "javascript:alert(1)", + "java script:alert(1)", + ]) { + expect(sanitizeInboundHtml(`x`)).toBe("x") + } + }) + + it("drops an href whose scheme only reads as http once inner spaces are removed", () => { + for (const href of ["h ttp:x", "ht tp://evil.test/", "mail to:someone@x.test", "http :x"]) { + expect(sanitizeInboundHtml(`x`)).toBe("x") + } + }) + + it("keeps an absolute link a browser reads the same way after its own URL cleanup", () => { + expect(sanitizeInboundHtml('go')).toBe( + 'go', + ) + expect(sanitizeInboundHtml('go')).toBe( + 'go', + ) + }) + + it("refuses every hostile href and attribute from the review probe", () => { + const hostile = [ + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + "x", + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'a', + 'x', + 'a', + ] + expect(hostile).toHaveLength(22) + for (const html of hostile) { + const out = sanitizeInboundHtml(html) ?? "" + const hrefs = [...out.matchAll(/href="([^"]*)"/g)].map((m) => m[1]!) + for (const href of hrefs) expect(href).toMatch(/^(?:https?:|mailto:)/i) + const attributeNames = [...out.replace(/="[^"]*"/g, "").matchAll(/\s([^\s=>]+)/g)].map((m) => + m[1]!.toLowerCase(), + ) + for (const name of attributeNames) expect(["href", "title", "alt"]).toContain(name) + } + }) + it("still refuses a body over the size cap", () => { expect(sanitizeInboundHtml("a".repeat(INBOUND_HTML_MAX_CHARS + 1))).toBeNull() }) diff --git a/services/api/test/unit/inbound-html-sanitizer-linear-time.test.ts b/services/api/test/unit/inbound-html-sanitizer-linear-time.test.ts new file mode 100644 index 00000000..0dcc3d60 --- /dev/null +++ b/services/api/test/unit/inbound-html-sanitizer-linear-time.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest" +import { sanitizeInboundHtml } from "../../src/services/admin/inbound-html-sanitizer.js" + +const RUN = 64 * 1024 + +describe("inbound HTML link cleanup stays linear on attacker-sized input", () => { + it("cleans an href with a long interior run of spaces in linear time", () => { + const html = `link` + const started = performance.now() + const out = sanitizeInboundHtml(html) + expect(performance.now() - started).toBeLessThan(500) + expect(out).toContain("link") + }) + + it("still trims control characters and spaces at both ends of an href", () => { + const out = sanitizeInboundHtml(`link`) + expect(out).toContain('href="https://x.example/a"') + }) +}) diff --git a/services/api/test/unit/ingest-jurisdictions.test.ts b/services/api/test/unit/ingest-jurisdictions.test.ts index 782efe43..84aae4ba 100644 --- a/services/api/test/unit/ingest-jurisdictions.test.ts +++ b/services/api/test/unit/ingest-jurisdictions.test.ts @@ -126,3 +126,17 @@ describe("normalizeFeatures geoid prefix", () => { expect(rows[0]?.layer).toBe("federal") }) }) + +describe("normalizeFeatures layer validation", () => { + it("ignores a layer property that only names an Object.prototype member", () => { + for (const layer of ["constructor", "__proto__"]) { + const { rows } = normalizeFeatures( + fc([ + { type: "Feature", properties: { geoid: "1", name: "P", layer }, geometry: validPolygon }, + ]), + "federal", + ) + expect(rows[0]?.layer).toBe("federal") + } + }) +}) diff --git a/services/api/test/unit/jobs-pgboss.test.ts b/services/api/test/unit/jobs-pgboss.test.ts index c4240cf4..eafffbe5 100644 --- a/services/api/test/unit/jobs-pgboss.test.ts +++ b/services/api/test/unit/jobs-pgboss.test.ts @@ -152,4 +152,42 @@ describe("PgBossJobs (API enqueue adapter)", () => { await jobs.stop() expect(lastBoss.stop).toHaveBeenCalledWith({ graceful: true, wait: true }) }) + + it("gives data.export a backoff retry policy that outlasts a mail config outage", async () => { + const OUTAGE_SECONDS = 6 * 60 * 60 + const jobs = new PgBossJobs({ connectionString: "postgres://localhost/civfix" }) + await jobs.start() + for (const call of [lastBoss.createQueue, lastBoss.updateQueue]) { + const policy = call.mock.calls.find((c) => c[0] === "data.export")?.[1] as { + policy: string + retryLimit: number + retryDelay: number + retryBackoff: boolean + } + expect(policy).toMatchObject({ policy: "short", retryBackoff: true }) + const minimumSpan = policy.retryDelay * (2 ** policy.retryLimit - 1) + expect(minimumSpan).toBeGreaterThanOrEqual(OUTAGE_SECONDS) + } + const other = lastBoss.createQueue.mock.calls.find((c) => c[0] === "media.checks")?.[1] + expect(other).toEqual({ name: "media.checks", policy: "short" }) + }) + + it("work() hands each job its retry count and limit", async () => { + const jobs = new PgBossJobs({ connectionString: "postgres://localhost/civfix" }) + await jobs.start() + const seen: unknown[] = [] + await jobs.work("data.export", (job) => { + seen.push(job) + return Promise.resolve() + }) + const [name, options, poll] = lastBoss.work.mock.calls[0]! as [ + string, + { includeMetadata: boolean }, + (batch: unknown[]) => Promise, + ] + expect(name).toBe("data.export") + expect(options).toMatchObject({ includeMetadata: true }) + await poll([{ id: "j1", data: { userId: "u1" }, retryCount: 3, retryLimit: 10 }]) + expect(seen).toEqual([{ id: "j1", data: { userId: "u1" }, retryCount: 3, retryLimit: 10 }]) + }) }) diff --git a/services/api/test/unit/jurisdiction-service.test.ts b/services/api/test/unit/jurisdiction-service.test.ts index 06f00717..4be72e76 100644 --- a/services/api/test/unit/jurisdiction-service.test.ts +++ b/services/api/test/unit/jurisdiction-service.test.ts @@ -14,6 +14,7 @@ import { JurisdictionLookupUnavailableError, type JurisdictionLookup, } from "../../src/adapters/jurisdiction-lookup.census.js" +import { makeFakeSql as makeRecordingSql } from "../helpers/fake-sql.js" const NOW = new Date("2026-05-31T00:00:00.000Z") @@ -278,6 +279,50 @@ describe("makeJurisdictionService.resolveForPoint", () => { }) }) +describe("resolveForPoint health probe ignores bounced contacts", () => { + it("counts neither a bounced per-category contact nor a bounced legacy address as routable", async () => { + const recording = makeRecordingSql([ + { + match: /has_routing_contact/, + rows: [ + { + geoid: "0644000", + contact_emails: [], + contact_updated_at: NOW, + population: 3_900_000, + has_routing_contact: false, + }, + ], + }, + ]) + const resolved: ResolvedRow = { geoid: "0644000", name: "Los Angeles", layer: "place" } + const sql = Object.assign(recording.sql, { + unsafe: () => Promise.resolve([resolved]), + }) as unknown as Sql + const jobs = new FakeJobs() + const service = makeJurisdictionService({ + sql, + geocoder: new FakeGeocoder(), + jobs, + now: () => NOW, + }) + + const dto = await service.resolveForPoint(34.1, -118.35) + + const probe = recording.statements.find((s) => /has_routing_contact/.test(s.sql))?.sql ?? "" + const flat = probe.replace(/\s+/g, " ") + expect(flat).toMatch( + /FROM jurisdiction_contacts jc WHERE jc\.geoid = j\.geoid AND jc\.email IS NOT NULL AND jc\.email <> '' AND jc\.bounced_at IS NULL/, + ) + expect(flat).toMatch(/FROM unnest\(j\.contact_emails\) AS e WHERE NOT EXISTS/) + expect(flat).toMatch( + /me\.type = 'bounced' AND lower\(me\.meta->>'failedRecipient'\) = lower\(e\)/, + ) + expect(dto?.routable).toBe(false) + expect(jobs.jobsFor(JURISDICTION_DISCOVERY_JOB)).toHaveLength(1) + }) +}) + describe("resolveForPoint write-time Census fallback", () => { it("local miss + lookup HIT -> upserts the API row and returns its DTO (routable:false)", async () => { const taggedCalls: TaggedCall[] = [] diff --git a/services/api/test/unit/keyset-cursor-forged.test.ts b/services/api/test/unit/keyset-cursor-forged.test.ts new file mode 100644 index 00000000..6cab87d2 --- /dev/null +++ b/services/api/test/unit/keyset-cursor-forged.test.ts @@ -0,0 +1,191 @@ +/** + * A keyset cursor is caller-controlled text, and the keyset lists bind its instant as `::timestamptz`. + * JS Date rolls impossible calendar fields forward (Feb 30 becomes Mar 2) and accepts offsets Postgres + * refuses, so validating with Date alone let a forged cursor reach Postgres, which raised 22008/22009: + * not an AppError, so a 500 on public lists. A forged cursor must degrade to the first page instead. + */ + +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyInstance } from "fastify" +import { buildServer } from "../../src/server.js" +import { buildContainer, type Container } from "../../src/di.js" +import { loadEnv } from "../../src/env.js" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" +import { decodeCursor } from "../../src/services/admin/pagination.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import { makeDrizzleBlocksRepository } from "../../src/services/blocks-repository.drizzle.js" + +const ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" +const VIEWER = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e03" +const FEB_30 = "2026-02-30T00:00:00Z" + +const FORGED_INSTANTS = [ + ["February 30", FEB_30], + ["February 29 in a non-leap year", "2027-02-29T12:00:00.000Z"], + ["a +16:00 offset", "2026-09-01T10:00:00+16:00"], + ["a -16:00 offset", "2026-09-01T10:00:00.5-16:00"], + ["an offset with 60 minutes", "2026-09-01T10:00:00+05:60"], + ["hour 24", "2026-09-01T24:00:00Z"], + ["April 31", "2026-04-31T08:00:00.123456Z"], +] as const + +describe("parseKeysetCursor refuses instants Postgres would not accept", () => { + it.each(FORGED_INSTANTS)("%s gives the first page", (_label, instant) => { + expect(parseKeysetCursor(`${instant}|${ID}`)).toBeNull() + expect(parseKeysetCursor(instant)).toBeNull() + expect(decodeCursor(`${instant}|${ID}`, true)).toBeNull() + }) +}) + +describe("parseKeysetCursor rebuilds the bound instant from the validated one", () => { + it("keeps a microsecond UTC cursor unchanged", () => { + const parsed = parseKeysetCursor(`2026-09-01T10:00:00.123456Z|${ID}`) + expect(parsed?.atText).toBe("2026-09-01T10:00:00.123456Z") + expect(parsed?.at.toISOString()).toBe("2026-09-01T10:00:00.123Z") + }) + + it("keeps a legacy toISOString cursor unchanged", () => { + expect(parseKeysetCursor(`2026-09-01T10:00:00.123Z|${ID}`)?.atText).toBe( + "2026-09-01T10:00:00.123Z", + ) + expect(parseKeysetCursor("2026-09-01T10:00:00.000Z")?.atText).toBe("2026-09-01T10:00:00.000Z") + }) + + it("keeps a cursor with no fraction unchanged", () => { + expect(parseKeysetCursor(`2026-09-01T10:00:00Z|${ID}`)?.atText).toBe("2026-09-01T10:00:00Z") + }) + + it("converts an offset cursor to UTC and keeps every fraction digit", () => { + const parsed = parseKeysetCursor(`2026-03-01T01:30:00.000007+02:00|${ID}`) + expect(parsed?.atText).toBe("2026-02-28T23:30:00.000007Z") + expect(parsed?.at.toISOString()).toBe("2026-02-28T23:30:00.000Z") + }) + + it("accepts the widest offset Postgres accepts", () => { + expect(parseKeysetCursor(`2026-09-01T10:00:00-15:59|${ID}`)?.atText).toBe( + "2026-09-02T01:59:00Z", + ) + expect(parseKeysetCursor(`2028-02-29T10:00:00+15:59|${ID}`)?.atText).toBe( + "2028-02-28T18:01:00Z", + ) + }) +}) + +describe("keyset lists answer a forged cursor with the first page", () => { + function expectNoAnchor(ctl: FakeSqlControl, match: RegExp): void { + const stmt = [...ctl.statements].reverse().find((s) => match.test(s.sql)) + expect(stmt).toBeDefined() + expect(stmt?.sql).not.toContain("::timestamptz") + expect(stmt?.values).not.toContain(FEB_30) + } + + const forged = `${FEB_30}|${ID}` + + it("report search", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzleReportRepository(ctl.sql as unknown as Sql) + await repo.searchReports({ q: null, categories: null, types: null, limit: 1, cursor: forged }) + expectNoAnchor(ctl, /FROM reports r/) + }) + + it("my reports", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzleReportRepository(ctl.sql as unknown as Sql) + await repo.listMyReports(VIEWER, forged, 1) + expectNoAnchor(ctl, /FROM reports\s+WHERE reporter_user_id/) + }) + + it("notifications", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzleNotificationRepository(ctl.sql as unknown as Sql) + await repo.listNotifications(VIEWER, forged, 1) + expectNoAnchor(ctl, /FROM notifications/) + }) + + it("public feed and replies", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzlePostRepository(ctl.sql as unknown as Sql, { + presignMedia: () => Promise.resolve({ url: "u" }), + presignAvatar: () => Promise.resolve("a"), + }) + await repo.publicFeed({ filter: "all", limit: 1, cursor: forged }) + expectNoAnchor(ctl, /FROM posts p\s+WHERE p\.deleted_at IS NULL\s+AND p\.reply_to_id IS NULL/) + await repo.listReplies(ID, { + viewerId: VIEWER, + focalAuthorId: VIEWER, + limit: 1, + cursor: forged, + }) + expectNoAnchor(ctl, /WHERE p\.reply_to_id = \? AND p\.deleted_at IS NULL/) + }) + + it("blocked users", async () => { + const ctl = makeFakeSql() + const repo = makeDrizzleBlocksRepository(ctl.sql as unknown as Sql) + await repo.listBlocked(VIEWER, { cursor: forged, limit: 1 }) + expectNoAnchor(ctl, /FROM user_blocks b\s+JOIN users u/) + }) +}) + +describe("GET /v1/reports/search with a forged cursor", () => { + let app: FastifyInstance | undefined + + afterEach(async () => { + if (app) { + await app.close() + app = undefined + } + }) + + it("answers 200 with the first page instead of a 500", async () => { + const pin = { + id: ID, + lng: -118.24, + lat: 34.05, + category: "trash", + type: "dump", + status: "published", + title: "Mattress on the curb", + description: null, + addr: null, + reference_code: "CF-000001", + thumb_key: null, + r2_key: null, + created_at: new Date("2026-09-01T10:00:00.123Z"), + cursor_at: "2026-09-01T10:00:00.123456Z", + } + const db = makeFakeSql([ + { + match: /FROM reports r/, + rows: (values) => { + if (values.includes(FEB_30)) { + throw Object.assign(new Error(`date/time field value out of range: "${FEB_30}"`), { + code: "22008", + }) + } + return [pin] + }, + }, + ]) + const env = loadEnv({ NODE_ENV: "test" }) + const container = { + ...buildContainer(env), + getDb: () => ({ sql: db.sql }), + } as unknown as Container + app = await buildServer({ env, container }) + + const res = await app.inject({ + method: "GET", + url: `/v1/reports/search?cursor=${encodeURIComponent(`${FEB_30}|${ID}`)}`, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().items.map((i: { id: string }) => i.id)).toEqual([ID]) + const search = db.statements.find((s) => /FROM reports r/.test(s.sql)) + expect(search?.sql).not.toContain("::timestamptz") + }) +}) diff --git a/services/api/test/unit/keyset-cursor-precision.test.ts b/services/api/test/unit/keyset-cursor-precision.test.ts new file mode 100644 index 00000000..96539c14 --- /dev/null +++ b/services/api/test/unit/keyset-cursor-precision.test.ts @@ -0,0 +1,315 @@ +/** + * Time-keyset cursors must carry the column's full microsecond instant. postgres-js hands timestamptz back + * as a JS Date (millisecond precision) and serializes a Date param with toISOString(), so a cursor built + * from the Date skips every row in the anchor's millisecond on a DESC list and repeats them on an ASC one. + * Rows written by one transaction share now(), so a burst of posts or notifications is exactly that case. + */ + +import { describe, it, expect } from "vitest" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import { makeDrizzleBlocksRepository } from "../../src/services/blocks-repository.drizzle.js" +import { makeDrizzleSocialRepository } from "../../src/services/social-repository.drizzle.js" +import { makeDrizzleVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.drizzle.js" +import { makeDrizzleGovClaimsRepository } from "../../src/services/admin/gov-claims-repository.drizzle.js" +import { makeDrizzleInboundRepository } from "../../src/services/admin/inbound-repository.drizzle.js" +import { makeDrizzleMailRepository } from "../../src/services/admin/mail-repository.drizzle.js" + +const AT = new Date("2026-09-01T10:00:00.123Z") +const AT_TEXT = "2026-09-01T10:00:00.123456Z" +const LEGACY_AT_TEXT = "2026-09-01T10:00:00.123Z" +const ID_A = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" +const ID_B = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e02" +const VIEWER = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e03" +const CURSOR = `${AT_TEXT}|${ID_A}` + +/** Two rows sharing one millisecond, so a limit-1 page has a next cursor anchored on the first. */ +function twoRows(extra: Record): Record[] { + return [ + { id: ID_A, cursor_at: AT_TEXT, ...extra }, + { id: ID_B, cursor_at: "2026-09-01T10:00:00.123001Z", ...extra }, + ] +} + +function lastStatement(ctl: FakeSqlControl, match: RegExp): { sql: string; values: unknown[] } { + const hit = [...ctl.statements].reverse().find((s) => match.test(s.sql)) + if (hit === undefined) throw new Error(`no statement matched ${String(match)}`) + return hit +} + +/** The page statement selects the text instant and binds the anchor as text cast to timestamptz. */ +function expectExactAnchor(ctl: FakeSqlControl, match: RegExp, atText = AT_TEXT): void { + const stmt = lastStatement(ctl, match) + expect(stmt.sql).toMatch(/to_char\(.* AT TIME ZONE 'UTC', \?\) AS cursor_at/) + expect(stmt.values).toContain(atText) + expect(stmt.values.some((v) => v instanceof Date && v.getTime() === AT.getTime())).toBe(false) + expect(stmt.sql).toContain("?::timestamptz") +} + +function person(id: string): Record { + return { + id, + display_name: "Person", + handle: "person", + bio: null, + followers: 0, + following: 0, + avatar_r2_key: null, + avatar_url: null, + is_following: false, + deleted_at: null, + } +} + +function postRow(): Record { + return { + author_id: VIEWER, + kind: "post", + body: "hello", + reply_to_id: null, + thread_root_id: null, + repost_of_id: null, + event_id: null, + report_id: null, + like_count: 0, + repost_count: 0, + reply_count: 0, + save_count: 0, + organization_id: null, + created_at: AT, + updated_at: AT, + saved_at: AT, + } +} + +const POST_AUTHORS = /LEFT JOIN media_assets am ON am\.id = u\.avatar_media_id/ + +function postRepo(list: RegExp): FakeSqlControl & { + repo: ReturnType +} { + const handlers: SqlHandler[] = [ + { match: POST_AUTHORS, rows: [person(VIEWER)] }, + { match: list, rows: twoRows(postRow()) }, + ] + const ctl = makeFakeSql(handlers) + const repo = makeDrizzlePostRepository(ctl.sql as unknown as Sql, { + presignMedia: () => Promise.resolve({ url: "u" }), + presignAvatar: () => Promise.resolve("a"), + }) + return { ...ctl, repo } +} + +describe("parseKeysetCursor keeps the cursor instant as text", () => { + it("returns the microsecond text alongside the Date", () => { + const parsed = parseKeysetCursor(CURSOR) + expect(parsed?.atText).toBe(AT_TEXT) + expect(parsed?.at.getTime()).toBe(AT.getTime()) + expect(parsed?.id).toBe(ID_A) + }) + + it("still decodes a legacy millisecond cursor and a timestamp-only cursor", () => { + expect(parseKeysetCursor(`${LEGACY_AT_TEXT}|${ID_A}`)).toEqual({ + at: AT, + id: ID_A, + atText: LEGACY_AT_TEXT, + }) + expect(parseKeysetCursor(LEGACY_AT_TEXT, { direction: "asc" })).toEqual({ + at: AT, + id: "00000000-0000-0000-0000-000000000000", + atText: LEGACY_AT_TEXT, + }) + }) + + it("rejects what parseTimeCursor rejects", () => { + expect(parseKeysetCursor(null)).toBeNull() + expect(parseKeysetCursor(`${AT_TEXT}|not-a-uuid`)).toBeNull() + expect(parseKeysetCursor(`2101-01-01T00:00:00.000Z|${ID_A}`)).toBeNull() + }) + + it("a legacy millisecond cursor binds the same instant it always meant", async () => { + const ctl = makeFakeSql([{ match: /FROM notifications/, rows: [] }]) + const repo = makeDrizzleNotificationRepository(ctl.sql as unknown as Sql) + await repo.listNotifications(VIEWER, `${LEGACY_AT_TEXT}|${ID_A}`, 1) + expectExactAnchor(ctl, /FROM notifications/, LEGACY_AT_TEXT) + }) +}) + +describe("consumer keyset lists carry microsecond cursors", () => { + it("my reports", async () => { + const match = /FROM reports\s+WHERE reporter_user_id/ + const ctl = makeFakeSql([{ match, rows: twoRows({ created_at: AT, lat: 0, lng: 0 }) }]) + const repo = makeDrizzleReportRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listMyReports(VIEWER, null, 1) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listMyReports(VIEWER, CURSOR, 1) + expectExactAnchor(ctl, match) + }) + + it("report search", async () => { + const match = /FROM reports r/ + const ctl = makeFakeSql([{ match, rows: twoRows({ created_at: AT, lat: 0, lng: 0 }) }]) + const repo = makeDrizzleReportRepository(ctl.sql as unknown as Sql) + const base = { q: null, categories: null, types: null, limit: 1 } + const page1 = await repo.searchReports({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.searchReports({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + }) + + it("notifications", async () => { + const match = /FROM notifications/ + const ctl = makeFakeSql([{ match, rows: twoRows({ created_at: AT, type: "site" }) }]) + const repo = makeDrizzleNotificationRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listNotifications(VIEWER, null, 1) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listNotifications(VIEWER, CURSOR, 1) + expectExactAnchor(ctl, match) + }) + + it("home feed (chronological)", async () => { + const match = /OR p\.author_id IN \(SELECT followee_id/ + const ctl = postRepo(match) + const base = { viewerId: VIEWER, filter: "all" as const, limit: 1 } + const page1 = await ctl.repo.homeFeedChronological({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await ctl.repo.homeFeedChronological({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + }) + + it("public feed", async () => { + const match = /FROM posts p\s+WHERE p\.deleted_at IS NULL\s+AND p\.reply_to_id IS NULL/ + const ctl = postRepo(match) + const base = { filter: "all" as const, limit: 1 } + const page1 = await ctl.repo.publicFeed({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await ctl.repo.publicFeed({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + }) + + it("a user's posts", async () => { + const match = /WHERE p\.author_id = \? AND p\.deleted_at IS NULL AND p\.reply_to_id IS NULL/ + const ctl = postRepo(match) + const base = { viewerId: VIEWER, limit: 1 } + const page1 = await ctl.repo.listUserPosts(VIEWER, { ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await ctl.repo.listUserPosts(VIEWER, { ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + }) + + it("saved posts", async () => { + const match = /FROM post_saves ps/ + const ctl = postRepo(match) + const base = { viewerId: VIEWER, limit: 1 } + const page1 = await ctl.repo.listSaves({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await ctl.repo.listSaves({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + expect(lastStatement(ctl, match).sql).toMatch(/to_char\(ps\.created_at /) + }) + + it("replies (ascending)", async () => { + const match = /WHERE p\.reply_to_id = \? AND p\.deleted_at IS NULL/ + const ctl = postRepo(match) + const base = { viewerId: VIEWER, focalAuthorId: VIEWER, limit: 1 } + const page1 = await ctl.repo.listReplies(ID_B, { ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await ctl.repo.listReplies(ID_B, { ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + expect(lastStatement(ctl, match).sql).toContain(") > (") + }) + + it("blocked users", async () => { + const match = /FROM user_blocks b\s+JOIN users u/ + const ctl = makeFakeSql([ + { match, rows: twoRows({ created_at: AT, display_name: "B", handle: null, bio: null }) }, + ]) + const repo = makeDrizzleBlocksRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listBlocked(VIEWER, { cursor: null, limit: 1 }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listBlocked(VIEWER, { cursor: CURSOR, limit: 1 }) + expectExactAnchor(ctl, match) + }) + + it.each(["listFollowers", "listFollowing"] as const)( + "social connections (%s)", + async (method) => { + const match = /JOIN follows_people f ON/ + const ctl = makeFakeSql([{ match, rows: twoRows({ ...person(ID_A), edge_created_at: AT }) }]) + const repo = makeDrizzleSocialRepository(ctl.sql as unknown as Sql) + const base = { id: VIEWER, viewerId: null, limit: 1 } + const page1 = await repo[method]({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo[method]({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + }, + ) + + it("volunteer hours ledger", async () => { + const match = /FROM volunteer_hours vh/ + const ctl = makeFakeSql([ + { + match, + rows: twoRows({ created_at: AT, scheduled_at: null, source: "event", hours: 1 }), + }, + ]) + const repo = makeDrizzleVolunteerHoursRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listEntries({ userId: VIEWER, cursor: null, limit: 1 }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listEntries({ userId: VIEWER, cursor: parseKeysetCursor(CURSOR), limit: 1 }) + expectExactAnchor(ctl, match) + }) +}) + +describe("admin keyset lists carry microsecond cursors", () => { + it.each(["newest", "oldest"] as const)("gov claims (%s)", async (sort) => { + const match = /FROM gov_claims\s+WHERE true/ + const ctl = makeFakeSql([{ match, rows: twoRows({ created_at: AT, checks: {} }) }]) + const repo = makeDrizzleGovClaimsRepository(ctl.sql as unknown as Sql) + const base = { q: null, filter: "all" as const, sort, limit: 1 } + const page1 = await repo.list({ ...base, cursor: null }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.list({ ...base, cursor: CURSOR }) + expectExactAnchor(ctl, match) + expect(lastStatement(ctl, match).sql).toContain(sort === "newest" ? ") < (" : ") > (") + }) + + it("inbound mail", async () => { + const match = /FROM inbound_emails\s+WHERE true/ + const ctl = makeFakeSql([ + { match, rows: twoRows({ received_at: AT, status: "unread", has_attachments: false }) }, + ]) + const repo = makeDrizzleInboundRepository(ctl.sql as unknown as Sql) + const page1 = await repo.list({ limit: 1 }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.list({ limit: 1, cursor: CURSOR }) + expectExactAnchor(ctl, match) + }) + + it("mail threads", async () => { + const match = /FROM mail_threads t\s+LEFT JOIN LATERAL/ + const ctl = makeFakeSql([ + { + match, + rows: twoRows({ + created_at: AT, + last_message_at: AT, + unread: false, + status: "open", + lm_direction: null, + }), + }, + ]) + const repo = makeDrizzleMailRepository(ctl.sql as unknown as Sql) + const page1 = await repo.listThreads({ limit: 1 }) + expect(page1.nextCursor).toBe(CURSOR) + await repo.listThreads({ limit: 1, cursor: CURSOR }) + expectExactAnchor(ctl, match) + expect(lastStatement(ctl, match).sql).toMatch( + /to_char\(COALESCE\(t\.last_message_at, t\.created_at\) /, + ) + }) +}) diff --git a/services/api/test/unit/mail-preview.test.ts b/services/api/test/unit/mail-preview.test.ts index d7880c51..ec810e06 100644 --- a/services/api/test/unit/mail-preview.test.ts +++ b/services/api/test/unit/mail-preview.test.ts @@ -34,6 +34,15 @@ describe("htmlToText", () => { ) }) + it("decodes the typographic named entities Outlook and Gmail emit", () => { + expect( + htmlToText("

Hello’s “crew” – Mon—Fri… ©

"), + ).toBe("Hello\u2019s \u201ccrew\u201d \u2013 Mon\u2014Fri\u2026 \u00a9") + expect(htmlToText("

&rsquo; &bogus; &toString; &

")).toBe( + "’ &bogus; &toString; &", + ) + }) + it("keeps the line breaks inside
 and collapses them everywhere else", () => {
     expect(htmlToText("a\nb
c\nd\n\ne
f\ng")).toBe("a b\nc\nd\n\ne\nf g") expect(htmlToText("
q1\r\nq2
")).toBe("> q1\n> q2") diff --git a/services/api/test/unit/media-byte-quota-window.test.ts b/services/api/test/unit/media-byte-quota-window.test.ts new file mode 100644 index 00000000..8949f7ff --- /dev/null +++ b/services/api/test/unit/media-byte-quota-window.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from "vitest" +import RedisMock from "ioredis-mock" +import type { RedisClient } from "../../src/adapters/redis.js" +import { + MEDIA_UPLOAD_BYTE_PREFIX, + MEDIA_UPLOAD_BYTE_WINDOW_SECONDS, + RedisByteMeter, +} from "../../src/services/media-byte-quota.js" + +const SUBJECT = "ip:203.0.113.7" +const KEY = MEDIA_UPLOAD_BYTE_PREFIX + SUBJECT +const ELAPSED_WINDOW_MS = 3_000 + +function freshRedis(): RedisClient { + return new RedisMock() as unknown as RedisClient +} + +describe("RedisByteMeter keeps a fixed daily window", () => { + beforeEach(async () => { + await freshRedis().flushall() + }) + + it("does not restart the window when a zero-byte charge created the key", async () => { + const redis = freshRedis() + const meter = new RedisByteMeter(redis) + + await meter.add(SUBJECT, 0) + await redis.pexpire(KEY, ELAPSED_WINDOW_MS) + await expect(meter.add(SUBJECT, 5)).resolves.toBe(5) + + const ttl = await redis.pttl(KEY) + expect(ttl).toBeGreaterThan(0) + expect(ttl).toBeLessThanOrEqual(ELAPSED_WINDOW_MS) + }) + + it("gives a budget key that lost its expiry a fresh window instead of locking the subject out forever", async () => { + const redis = freshRedis() + const meter = new RedisByteMeter(redis) + await redis.set(KEY, "1024") + + await expect(meter.add(SUBJECT, 2048)).resolves.toBe(3072) + const ttl = await redis.pttl(KEY) + expect(ttl).toBeGreaterThan(0) + expect(ttl).toBeLessThanOrEqual(MEDIA_UPLOAD_BYTE_WINDOW_SECONDS * 1000) + }) + + it("anchors the window on the first charge and does not extend it on later spend", async () => { + const redis = freshRedis() + const meter = new RedisByteMeter(redis) + + await expect(meter.add(SUBJECT, 100)).resolves.toBe(100) + expect(await redis.pttl(KEY)).toBeGreaterThan(ELAPSED_WINDOW_MS) + await redis.pexpire(KEY, ELAPSED_WINDOW_MS) + await expect(meter.add(SUBJECT, 200)).resolves.toBe(300) + + expect(await redis.pttl(KEY)).toBeLessThanOrEqual(ELAPSED_WINDOW_MS) + }) +}) diff --git a/services/api/test/unit/media-finalize-retry.test.ts b/services/api/test/unit/media-finalize-retry.test.ts new file mode 100644 index 00000000..fc5955fc --- /dev/null +++ b/services/api/test/unit/media-finalize-retry.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest" +import { FakeStorage, FakeJobs } from "@civfix/shared/fakes" +import { + makeMediaIntakeService, + MEDIA_CHECKS_JOB, +} from "../../src/services/media-intake-service.js" +import { InMemoryMediaRepository } from "../helpers/media.js" + +const SHA = "a".repeat(64) +const DECLARED_BYTES = 32 * 1024 +const PROCESSED_BYTES = 9 * 1024 + +async function finalizedAndProcessed(status: "ready" | "rejected") { + const repo = new InMemoryMediaRepository() + const storage = new FakeStorage() + const jobs = new FakeJobs() + const service = makeMediaIntakeService({ repo, storage, jobs }) + const created = await service.createUpload( + { kind: "image", contentType: "image/jpeg", byteSize: DECLARED_BYTES, sha256: SHA }, + {}, + ) + const asset = (await repo.findByUploadId(created.uploadId))! + await storage.put(asset.r2Key, new Uint8Array(DECLARED_BYTES), { contentType: "image/jpeg" }) + const first = await service.finalize({ uploadId: created.uploadId }, {}) + + repo.patch(asset.id, { status, byteSize: PROCESSED_BYTES }) + await storage.delete(asset.r2Key) + + return { service, jobs, created, first } +} + +describe("finalize retried after the worker processed the upload", () => { + it("returns the original media id instead of 422 once the raw upload is gone (ready)", async () => { + const { service, jobs, created, first } = await finalizedAndProcessed("ready") + + const retry = await service.finalize({ uploadId: created.uploadId }, {}) + + expect(retry).toEqual(first) + expect(jobs.jobsFor(MEDIA_CHECKS_JOB)).toHaveLength(1) + }) + + it("returns the original media id for an already-rejected asset without re-enqueueing", async () => { + const { service, jobs, created, first } = await finalizedAndProcessed("rejected") + + const retry = await service.finalize({ uploadId: created.uploadId }, {}) + + expect(retry).toEqual(first) + expect(jobs.jobsFor(MEDIA_CHECKS_JOB)).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/media-upload-etag.test.ts b/services/api/test/unit/media-upload-etag.test.ts new file mode 100644 index 00000000..efcc6dcf --- /dev/null +++ b/services/api/test/unit/media-upload-etag.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest" +import { drizzle } from "drizzle-orm/postgres-js" +import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" +import type { Db, Sql } from "../../src/db/client.js" +import { makeMediaIntakeService } from "../../src/services/media-intake-service.js" +import { makeDrizzleMediaRepository } from "../../src/services/media-repository.drizzle.js" +import { makeDrizzleMediaWorkerRepo } from "../../src/services/media-worker-repo.js" +import { InMemoryMediaRepository } from "../helpers/media.js" + +const SHA = "b".repeat(64) +const BYTES = 4096 +const RAW_ETAG = '"5D41402ABC4B2A76B9719D911017C592"' +const NORMALIZED_ETAG = "5d41402abc4b2a76b9719d911017c592" +const MEDIA_ID = "11111111-1111-1111-1111-111111111111" +const UPLOAD_ID = "22222222-2222-2222-2222-222222222222" + +interface Call { + query: string + params: unknown[] +} + +function stubDb(rowsFor: (query: string) => unknown[][]) { + const calls: Call[] = [] + const client = { + options: { parsers: {}, serializers: {} }, + unsafe(query: string, params: unknown[]) { + calls.push({ query, params }) + const rows = rowsFor(query) + return Object.assign(Promise.resolve([]), { values: () => Promise.resolve(rows) }) + }, + begin(fn: (tx: unknown) => Promise): Promise { + return fn(client) + }, + } + const db = drizzle(client as never) as unknown as Db + return { db, calls } +} + +async function finalizeWithEtag(repo: InMemoryMediaRepository, headEtag: string | undefined) { + const storage = new FakeStorage() + const head = storage.head.bind(storage) + storage.head = async (key: string) => { + const found = await head(key) + return found && headEtag !== undefined ? { ...found, etag: headEtag } : found + } + const jobs = new FakeJobs() + const service = makeMediaIntakeService({ repo, storage, jobs }) + const created = await service.createUpload( + { kind: "image", contentType: "image/jpeg", byteSize: BYTES, sha256: SHA }, + {}, + ) + const asset = (await repo.findByUploadId(created.uploadId))! + await storage.put(asset.r2Key, new Uint8Array(BYTES), { contentType: "image/jpeg" }) + await service.finalize({ uploadId: created.uploadId }, {}) + return created.uploadId +} + +describe("finalize records the upload etag on the media row", () => { + it("hands the normalized HEAD etag to markFinalized", async () => { + const repo = new InMemoryMediaRepository() + const markFinalized = vi.spyOn(repo, "markFinalized") + + const uploadId = await finalizeWithEtag(repo, RAW_ETAG) + + expect(markFinalized).toHaveBeenCalledWith(uploadId, NORMALIZED_ETAG) + }) + + it("passes null when storage reports no etag, so the overwrite check stays off as before", async () => { + const repo = new InMemoryMediaRepository() + const markFinalized = vi.spyOn(repo, "markFinalized") + + const uploadId = await finalizeWithEtag(repo, undefined) + + expect(markFinalized).toHaveBeenCalledWith(uploadId, null) + }) + + it("the drizzle repository writes the etag in the same claim that sets finalized_at", async () => { + const { db, calls } = stubDb(() => []) + const repo = makeDrizzleMediaRepository(db) + + await repo.markFinalized(UPLOAD_ID, NORMALIZED_ETAG) + + const update = calls.find((c) => /update "media_assets"/.test(c.query)) + expect(update?.query).toMatch(/"upload_etag" = \$\d+/) + expect(update?.query).toMatch(/"finalized_at" is null/) + expect(update?.params).toContain(NORMALIZED_ETAG) + }) +}) + +describe("the stuck sweep reads the stored upload etag", () => { + it("returns upload_etag with every stuck row it claims", async () => { + const { db, calls } = stubDb((query) => + /^select/.test(query) + ? [[MEDIA_ID]] + : [[MEDIA_ID, UPLOAD_ID, "uploads/2026/09/x", null, null, "image", 2, NORMALIZED_ETAG]], + ) + const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + + const rows = await repo.findStuckValidating(new Date(), 10) + + const update = calls.find((c) => /update "media_assets"/.test(c.query)) + expect(update?.query).toMatch(/returning .*"upload_etag"/) + expect(rows).toEqual([ + { + id: MEDIA_ID, + uploadId: UPLOAD_ID, + r2Key: "uploads/2026/09/x", + servedKey: null, + thumbKey: null, + kind: "image", + checkCount: 2, + uploadEtag: NORMALIZED_ETAG, + }, + ]) + }) +}) diff --git a/services/api/test/unit/migrate-failure-cleanup.test.ts b/services/api/test/unit/migrate-failure-cleanup.test.ts new file mode 100644 index 00000000..74b9eb66 --- /dev/null +++ b/services/api/test/unit/migrate-failure-cleanup.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { applyMigrations } from "../../src/db/migrate.js" +import type { Sql } from "../../src/db/client.js" + +const BACKEND_PID = 4242 + +interface FakeSession { + pool: Sql + released: () => boolean + poolStatements: string[] +} + +function fakeSession(opts: { failRollback: boolean }): FakeSession { + let released = false + let aborted = false + const poolStatements: string[] = [] + const tag = (strings: TemplateStringsArray): Promise => { + const text = strings.join("?") + if (aborted) return Promise.reject(new Error("current transaction is aborted")) + if (text.includes("pg_backend_pid")) return Promise.resolve([{ pid: BACKEND_PID }]) + if (text.includes("FROM _civfix_migrations")) return Promise.resolve([]) + return Promise.resolve([]) + } + const reserved = Object.assign(tag, { + unsafe: (text: string): Promise => { + if (text === "rollback") { + if (opts.failRollback) return Promise.reject(new Error("connection lost mid-rollback")) + aborted = false + return Promise.resolve([]) + } + if (text.includes("broken")) { + aborted = true + return Promise.reject(new Error("syntax error")) + } + return Promise.resolve([]) + }, + release: () => { + released = true + }, + }) + const pool = Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]): Promise => { + poolStatements.push(`${strings.join("?")} [${values.join(",")}]`) + return Promise.resolve([]) + }, + { reserve: () => Promise.resolve(reserved) }, + ) as unknown as Sql + return { pool, released: () => released, poolStatements } +} + +describe("applyMigrations failure cleanup", () => { + let dir: string + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "civfix-migrate-")) + await writeFile(join(dir, "0001_broken.sql"), "CREATE TABLE broken (") + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it("never hands a session that may still hold the migration lock back to the pool", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}) + const session = fakeSession({ failRollback: true }) + + await expect(applyMigrations(session.pool, dir)).rejects.toThrow("syntax error") + + expect(session.released()).toBe(false) + expect(session.poolStatements.some((s) => s.includes("pg_terminate_backend"))).toBe(true) + expect(session.poolStatements.join("\n")).toContain(String(BACKEND_PID)) + expect(errors).toHaveBeenCalled() + }) + + it("releases the session normally when the rollback succeeds", async () => { + const session = fakeSession({ failRollback: false }) + await expect(applyMigrations(session.pool, dir)).rejects.toThrow("syntax error") + expect(session.released()).toBe(true) + expect(session.poolStatements).toHaveLength(0) + }) +}) diff --git a/services/api/test/unit/notification-coalesce-sql.test.ts b/services/api/test/unit/notification-coalesce-sql.test.ts new file mode 100644 index 00000000..2ec03aac --- /dev/null +++ b/services/api/test/unit/notification-coalesce-sql.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" +import type { Sql } from "../../src/db/client.js" + +const USER = "11111111-1111-1111-1111-111111111111" + +describe("coalesced notifications serialize writers of the same thread", () => { + it("locks the (user, type, link) key before looking for the unread row it would refresh", async () => { + const fake = makeFakeSql([ + { + match: /INSERT INTO notifications/, + rows: [ + { + id: "22222222-2222-2222-2222-222222222222", + user_id: USER, + type: "cleanup_chat", + title: "New messages", + body: "3 new messages", + link: "/cleanups/abc/chat", + read_at: null, + created_at: new Date("2026-09-01T00:01:00.000Z"), + }, + ], + }, + ]) + await makeDrizzleNotificationRepository(fake.sql as unknown as Sql).upsertCoalescedNotification( + { + userId: USER, + type: "cleanup_chat", + link: "/cleanups/abc/chat", + title: "New messages", + body: "3 new messages", + since: new Date("2026-09-01T00:00:00.000Z"), + }, + ) + + const [lock, refresh, insert] = fake.statements + expect(lock?.sql).toMatch(/pg_advisory_xact_lock\(\s*hashtext\(/) + expect(lock?.values).toEqual([USER, "cleanup_chat", "/cleanups/abc/chat"]) + expect(refresh?.sql).toMatch(/UPDATE notifications/) + expect(insert?.sql).toMatch(/INSERT INTO notifications/) + }) +}) + +describe("deduped notifications serialize writers of the same key", () => { + const since = new Date("2026-09-01T00:00:00.000Z") + const row = { + id: "33333333-3333-3333-3333-333333333333", + user_id: USER, + type: "system", + title: "Heads up", + body: "The route changed", + link: null, + read_at: null, + created_at: new Date("2026-09-01T00:01:00.000Z"), + } + + it("locks the (user, type, link) key, looks for the duplicate, then inserts in one transaction", async () => { + const fake = makeFakeSql([{ match: /INSERT INTO notifications/, rows: [row] }]) + const result = await makeDrizzleNotificationRepository( + fake.sql as unknown as Sql, + ).insertUnlessRecentDuplicate({ + userId: USER, + type: "system", + link: null, + title: "Heads up", + body: "The route changed", + since, + }) + + const [lock, find, insert] = fake.statements + expect(lock?.sql).toMatch(/pg_advisory_xact_lock\(\s*hashtext\(/) + expect(lock?.sql).toMatch(/COALESCE\(\?::text, ''\)/) + expect(lock?.values).toEqual([USER, "system", null]) + expect(find?.sql).toMatch(/SELECT[\s\S]*FROM notifications[\s\S]*created_at > \?/) + expect(find?.sql).toMatch(/body IS NOT DISTINCT FROM \?/) + expect(insert?.sql).toMatch(/INSERT INTO notifications/) + expect(result.deduped).toBe(false) + expect(result.record.id).toBe(row.id) + }) + + it("returns the recent duplicate without inserting a second row", async () => { + const fake = makeFakeSql([{ match: /FROM notifications/, rows: [row] }]) + const result = await makeDrizzleNotificationRepository( + fake.sql as unknown as Sql, + ).insertUnlessRecentDuplicate({ + userId: USER, + type: "system", + link: null, + title: "Heads up", + body: "The route changed", + since, + }) + + expect(result).toMatchObject({ deduped: true, record: { id: row.id } }) + expect(fake.statements.some((s) => /INSERT INTO notifications/.test(s.sql))).toBe(false) + }) +}) diff --git a/services/api/test/unit/notification-fanout-failures.test.ts b/services/api/test/unit/notification-fanout-failures.test.ts new file mode 100644 index 00000000..7226dc07 --- /dev/null +++ b/services/api/test/unit/notification-fanout-failures.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest" +import { FakePushSender } from "@civfix/shared/fakes" +import { makeNotificationService } from "../../src/services/notification-service.js" +import { InMemoryNotificationRepository } from "../helpers/notifications.js" + +const OK_USER = "11111111-1111-1111-1111-111111111111" +const BROKEN_USER = "22222222-2222-2222-2222-222222222222" +const OTHER_USER = "33333333-3333-3333-3333-333333333333" + +function repoFailingFor(userId: string): InMemoryNotificationRepository { + const repo = new InMemoryNotificationRepository() + const insert = repo.insertNotification.bind(repo) + repo.insertNotification = (args) => + args.userId === userId ? Promise.reject(new Error("insert failed")) : insert(args) + return repo +} + +describe("fan-out notifications report the recipients whose row was never written", () => { + it("names exactly the recipient whose insert failed, so a caller can retry only them", async () => { + const repo = repoFailingFor(BROKEN_USER) + const service = makeNotificationService({ repo, pushSender: new FakePushSender() }) + + const result = await service.createNotificationsReportingFailures( + [OK_USER, BROKEN_USER, OTHER_USER], + { + type: "event_broadcast", + title: "Parking moved", + body: "Use the north lot", + link: "/e/abc", + }, + ) + + expect(result.failed).toEqual([BROKEN_USER]) + expect(repo.notifications.map((n) => n.userId).sort()).toEqual([OK_USER, OTHER_USER].sort()) + }) + + it("counts a deduped recipient as delivered, not as a failure", async () => { + const repo = new InMemoryNotificationRepository() + const service = makeNotificationService({ repo, pushSender: new FakePushSender() }) + const input = { + type: "event_broadcast" as const, + title: "Parking moved", + body: "Use the north lot", + link: "/e/abc", + dedupeWindowMs: 60_000, + } + + await service.createNotificationsReportingFailures([OK_USER], input) + const again = await service.createNotificationsReportingFailures([OK_USER], input) + + expect(again.failed).toEqual([]) + expect(repo.notifications).toHaveLength(1) + }) + + it("reports no failures for an empty recipient list", async () => { + const service = makeNotificationService({ + repo: new InMemoryNotificationRepository(), + pushSender: new FakePushSender(), + }) + + await expect( + service.createNotificationsReportingFailures([], { type: "event_broadcast", title: "x" }), + ).resolves.toEqual({ failed: [] }) + }) + + it("logs a store outage across a fan-out once, with counts, instead of once per recipient", async () => { + const recipients = Array.from( + { length: 20 }, + (_, i) => `44444444-4444-4444-4444-${String(i).padStart(12, "0")}`, + ) + const repo = new InMemoryNotificationRepository() + repo.insertNotification = () => Promise.reject(new Error("insert failed")) + repo.upsertCoalescedNotification = () => Promise.reject(new Error("upsert failed")) + const logger = { warn: vi.fn(), error: vi.fn() } + const service = makeNotificationService({ repo, pushSender: new FakePushSender(), logger }) + + const result = await service.createNotificationsReportingFailures(recipients, { + type: "group_chat", + title: "Dana", + body: "Parking moved", + link: "/chat/abc", + coalesceWindowMs: 60_000, + }) + + expect(result.failed).toHaveLength(20) + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ type: "group_chat", failed: 20, recipients: 20 }), + expect.stringMatching(/fan-out notification insert failed/), + ) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ type: "group_chat", fallbacks: 20, lane: "coalesce" }), + expect.stringMatching(/coalesce upsert failed/), + ) + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain("Parking moved") + }) +}) + +describe("deduped notifications written concurrently", () => { + it("writes one row when two writers race on the same dedupe key", async () => { + const repo = new InMemoryNotificationRepository() + const service = makeNotificationService({ repo, pushSender: new FakePushSender() }) + const input = { + type: "event_broadcast" as const, + title: "Parking moved", + body: "Use the north lot", + link: "/e/abc", + dedupeWindowMs: 60_000, + } + + await Promise.all([ + service.createNotification(OK_USER, input), + service.createNotification(OK_USER, input), + ]) + + expect(repo.notifications).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/otp-email-expiry.test.ts b/services/api/test/unit/otp-email-expiry.test.ts new file mode 100644 index 00000000..de4577c1 --- /dev/null +++ b/services/api/test/unit/otp-email-expiry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" +import { renderOtp } from "../../src/adapters/mailer.oci.js" +import { OTP_TTL_SECONDS } from "../../src/auth/otp.js" +import { SUPPORTED_LOCALES } from "../../src/i18n/locales.js" +import { en } from "../../src/i18n/messages/en.js" +import { es } from "../../src/i18n/messages/es.js" +import { de } from "../../src/i18n/messages/de.js" +import { ko } from "../../src/i18n/messages/ko.js" + +const CATALOGS: Record>> = { en, es, de, ko } + +const TTL_MINUTES = String(Math.floor(OTP_TTL_SECONDS / 60)) + +describe("sign-in code email expiry line", () => { + it.each(SUPPORTED_LOCALES)("states the real code lifetime in %s", (locale) => { + const { text, html } = renderOtp("424242", locale) + + expect(text).toContain(TTL_MINUTES) + expect(text).not.toContain("{{") + expect(html).not.toContain("{{") + }) + + it.each(SUPPORTED_LOCALES)("derives the lifetime from the OTP TTL in %s", (locale) => { + expect(CATALOGS[locale]!["email.otp.body_expiry"]).toContain("{{minutes}}") + }) +}) diff --git a/services/api/test/unit/outbound-send-sql.test.ts b/services/api/test/unit/outbound-send-sql.test.ts index 4097c861..cac11f17 100644 --- a/services/api/test/unit/outbound-send-sql.test.ts +++ b/services/api/test/unit/outbound-send-sql.test.ts @@ -70,6 +70,14 @@ describe("outbound send predicates are index-served", () => { } }) + it("counts a young attempt with no sent or failed event yet as in flight", async () => { + const normalise = (s: string): string => s.replace(/\s+/g, " ") + const sql = normalise(await inFlightStatement()) + expect(sql).toMatch( + /OR \( NOT EXISTS \( SELECT 1 FROM mail_events e .*? AND latest\.created_at > now\(\) - make_interval\(secs => \?\) \)/, + ) + }) + it("both repositories emit the SAME in-flight expression (one shared fragment)", async () => { const normalise = (s: string): string => s.replace(/\s+/g, " ").trim() const inFlight = normalise(await inFlightStatement()) diff --git a/services/api/test/unit/outreach-repository-bounced-legacy.test.ts b/services/api/test/unit/outreach-repository-bounced-legacy.test.ts new file mode 100644 index 00000000..93a4fe0b --- /dev/null +++ b/services/api/test/unit/outreach-repository-bounced-legacy.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { makeDrizzleOutreachRepository } from "../../src/services/admin/outreach-repository.drizzle.js" +import type { Sql } from "../../src/db/client.js" + +const GEOID = "0644000" +const LEGACY_BOUNCE_GUARD = + /FROM unnest\(COALESCE\(j\.contact_emails, ARRAY\[\]::text\[\]\)\) AS e WHERE e <> '' AND NOT EXISTS .*? me\.type = 'bounced' AND lower\(me\.meta->>'failedRecipient'\) = lower\(e\)/ + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ") +} + +describe("outreach repository skips a bounced legacy contact_emails address", () => { + it("loadDigest never picks a legacy address that already bounced", async () => { + const fake = makeFakeSql() + await makeDrizzleOutreachRepository(fake.sql as unknown as Sql).loadDigest(GEOID) + expect(flat(fake.statements[0]?.sql ?? "")).toMatch(LEGACY_BOUNCE_GUARD) + }) + + it("listCandidateGeoids does not treat a bounced legacy address as a routable contact", async () => { + const fake = makeFakeSql() + await makeDrizzleOutreachRepository(fake.sql as unknown as Sql).listCandidateGeoids(10) + expect(flat(fake.statements[0]?.sql ?? "")).toMatch(LEGACY_BOUNCE_GUARD) + }) +}) diff --git a/services/api/test/unit/pg-stores-handle-race.test.ts b/services/api/test/unit/pg-stores-handle-race.test.ts new file mode 100644 index 00000000..7e625f43 --- /dev/null +++ b/services/api/test/unit/pg-stores-handle-race.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest" +import { getTableColumns } from "drizzle-orm" +import { drizzle } from "drizzle-orm/postgres-js" +import * as schema from "../../src/db/schema/index.js" +import type { Db } from "../../src/db/client.js" +import { PgUserStore } from "../../src/auth/pg-stores.js" + +const USER_ID = "88888888-8888-4888-8888-888888888888" +const NOW = new Date("2026-09-01T12:00:00.000Z") + +interface Recorded { + sql: string + params: unknown[] +} + +type Responder = (query: string, params: unknown[]) => unknown[][] | undefined + +function recordingDb(respond: Responder): { db: Db; statements: Recorded[] } { + const statements: Recorded[] = [] + const client = { + options: { parsers: {}, serializers: {} }, + unsafe(query: string, params: unknown[] = []) { + statements.push({ sql: query, params }) + const rows = respond(query, params) ?? [] + const result = Promise.resolve([]) as Promise & { + values(): Promise + } + result.values = () => Promise.resolve(rows) + return result + }, + begin(callback: (tx: unknown) => Promise): Promise { + return callback(client) + }, + } + return { db: drizzle(client as never, { schema }) as unknown as Db, statements } +} + +function userRow(fields: Record): unknown[] { + const base: Record = { + id: USER_ID, + handle: "oldname", + displayName: "Pat", + role: "citizen", + email: "pat@example.com", + emailVerified: true, + profileComplete: true, + handleChangedAt: null, + createdAt: NOW.toISOString(), + ...fields, + } + return Object.keys(getTableColumns(schema.users)).map((key) => base[key] ?? null) +} + +describe("PgUserStore.updateProfile under a concurrent rename", () => { + it("guards the rename on the handle it read, and re-decides when another rename won", async () => { + let userReads = 0 + const { db, statements } = recordingDb((query) => { + if (/^select .* from "users" where "users"\."id" = \$1/i.test(query)) { + userReads += 1 + return userReads === 1 + ? [userRow({})] + : [userRow({ handle: "firstrename", handleChangedAt: NOW.toISOString() })] + } + if (/^update "users"/i.test(query)) return [] + return undefined + }) + + const store = new PgUserStore(db, { now: () => NOW }) + await expect( + store.updateProfile(USER_ID, { handle: "secondrename", displayName: "Pat" }), + ).rejects.toMatchObject({ httpStatus: 429 }) + + const update = statements.find((s) => /^update "users"/i.test(s.sql)) + expect(update, "no UPDATE issued").toBeDefined() + expect(update!.sql).toMatch(/"users"\."handle" = \$\d+/i) + expect(update!.params).toContain("oldname") + }) +}) diff --git a/services/api/test/unit/post-repository-writes-sql.test.ts b/services/api/test/unit/post-repository-writes-sql.test.ts new file mode 100644 index 00000000..3204a5c9 --- /dev/null +++ b/services/api/test/unit/post-repository-writes-sql.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { + makeDrizzlePostRepository, + type CreatePostArgs, +} from "../../src/services/post-repository.drizzle.js" + +const AUTHOR = "11111111-1111-1111-1111-111111111111" +const TARGET = "22222222-2222-2222-2222-222222222222" +const NEW_POST = "33333333-3333-3333-3333-333333333333" + +function repoOver(handlers: SqlHandler[]) { + const fake = makeFakeSql(handlers) + const repo = makeDrizzlePostRepository(fake.sql as unknown as Sql, { + presignMedia: () => Promise.resolve({ url: "u" }), + presignAvatar: () => Promise.resolve("a"), + }) + return { fake, repo } +} + +const LIVE_TARGET: SqlHandler = { + match: /SELECT id, kind, repost_of_id FROM posts/, + rows: [{ id: TARGET, kind: "post", repost_of_id: null }], +} +const INSERTED: SqlHandler = { match: /INSERT INTO posts/, rows: [{ id: NEW_POST }] } + +function replyArgs(over: Partial = {}): CreatePostArgs { + return { + authorId: AUTHOR, + kind: "reply", + body: "me too", + replyToId: TARGET, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds: [], + mentionedUserIds: [], + organizationId: null, + ...over, + } +} + +describe("reviving an un-reposted repost", () => { + it("dates the revived repost now, so it is neither backdated nor marked edited", async () => { + const { fake, repo } = repoOver([ + LIVE_TARGET, + { match: /UPDATE posts SET deleted_at = NULL/, rows: [{ id: NEW_POST }] }, + ]) + + await repo.repost(TARGET, AUTHOR) + + const revive = fake.statements.find((s) => /SET deleted_at = NULL/.test(s.sql)) + expect(revive?.sql).toMatch(/created_at = now\(\)/) + expect(revive?.sql).toMatch(/updated_at = now\(\)/) + }) +}) + +describe("creating a reply or quote whose target vanished after the service checked it", () => { + it("refuses a reply to a parent that is gone, writing nothing", async () => { + const { fake, repo } = repoOver([INSERTED]) + + await expect(repo.createPost(replyArgs())).rejects.toMatchObject({ code: "NOT_FOUND" }) + expect(fake.statements.some((s) => /INSERT INTO posts/.test(s.sql))).toBe(false) + }) + + it("refuses a quote of a target that is gone, writing nothing", async () => { + const { fake, repo } = repoOver([INSERTED]) + + await expect( + repo.createPost(replyArgs({ kind: "quote", replyToId: null, repostOfId: TARGET })), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + expect(fake.statements.some((s) => /INSERT INTO posts/.test(s.sql))).toBe(false) + }) + + it("rolls back when the parent is tombstoned between the lookup and the reply-count bump", async () => { + const { repo } = repoOver([LIVE_TARGET, INSERTED]) + + await expect(repo.createPost(replyArgs())).rejects.toMatchObject({ code: "NOT_FOUND" }) + }) + + it("bumps the reply count only on a parent that is still live", async () => { + const { fake, repo } = repoOver([ + LIVE_TARGET, + INSERTED, + { match: /SET reply_count = reply_count \+ 1/, rows: [{ id: TARGET }] }, + ]) + + await expect(repo.createPost(replyArgs())).resolves.toBe(NEW_POST) + const bump = fake.statements.find((s) => /reply_count = reply_count \+ 1/.test(s.sql)) + expect(bump?.sql).toMatch(/deleted_at IS NULL/) + }) +}) diff --git a/services/api/test/unit/post-unrepost-unreadable.test.ts b/services/api/test/unit/post-unrepost-unreadable.test.ts new file mode 100644 index 00000000..be5fa9f5 --- /dev/null +++ b/services/api/test/unit/post-unrepost-unreadable.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest" +import { RepostResponseSchema, type PostDTO } from "@civfix/shared" +import type { Sql } from "../../src/db/client.js" +import { makePostService } from "../../src/services/post-service.js" +import type { PostBrief, PostRepository } from "../../src/services/post-repository.drizzle.js" + +const REPOSTER = "11111111-1111-1111-1111-111111111111" +const AUTHOR = "22222222-2222-2222-2222-222222222222" +const TARGET = "33333333-3333-3333-3333-333333333333" +const SECRET_BODY = "the original's current text" +const NOW = Date.parse("2026-09-01T10:00:00.000Z") + +const throwingSql = (() => { + throw new Error("sql must not be called in these unit paths") +}) as unknown as Sql + +function hydrated(): PostDTO { + return { + id: TARGET, + author: { id: AUTHOR, name: "Ana", followers: 3, following: 1, isFollowing: false }, + kind: "post", + body: SECRET_BODY, + createdAt: "2026-08-01T10:00:00.000Z", + counts: { likes: 4, reposts: 1, replies: 0, saves: 0 }, + viewer: { liked: false, reposted: false, saved: false }, + media: [], + mentions: [], + } as unknown as PostDTO +} + +function harness(opts: { target?: Partial; blocked?: boolean; removed?: boolean } = {}) { + const unreposts: Array<{ id: string; userId: string }> = [] + const brief: PostBrief = { + id: TARGET, + authorId: AUTHOR, + kind: "post", + replyToId: null, + repostOfId: null, + deletedAt: null, + visibility: "public", + ...opts.target, + } + const repo = { + getPostBrief: () => Promise.resolve(brief), + unrepost: (id: string, userId: string) => { + unreposts.push({ id, userId }) + return Promise.resolve({ targetId: id, removed: opts.removed ?? true }) + }, + // Mirrors the Postgres read, which filters visibility but not blocks. + getPostDTO: () => + Promise.resolve( + brief.visibility === "public" && brief.deletedAt === null ? hydrated() : null, + ), + } as unknown as PostRepository + const svc = makePostService({ + repo, + sql: throwingSql, + isBlockedEitherWay: () => Promise.resolve(opts.blocked ?? false), + now: () => NOW, + }) + return { svc, unreposts } +} + +describe("un-reposting a post the reposter can no longer read", () => { + it("removes the repost once the original went hidden and answers success without its content", async () => { + const { svc, unreposts } = harness({ target: { visibility: "hidden" } }) + + const dto = await svc.unrepostPost(TARGET, REPOSTER) + + expect(unreposts).toEqual([{ id: TARGET, userId: REPOSTER }]) + expect(RepostResponseSchema.safeParse(dto).success).toBe(true) + expect(dto).toMatchObject({ + id: TARGET, + body: null, + viewer: { reposted: false, liked: false, saved: false }, + counts: { likes: 0, reposts: 0, replies: 0, saves: 0 }, + }) + }) + + it("removes the repost once the original's author blocked the reposter, and never returns the post", async () => { + const { svc, unreposts } = harness({ blocked: true }) + + const dto = await svc.unrepostPost(TARGET, REPOSTER) + + expect(unreposts).toEqual([{ id: TARGET, userId: REPOSTER }]) + expect(dto.viewer.reposted).toBe(false) + expect(JSON.stringify(dto)).not.toContain(SECRET_BODY) + expect(dto.author.name).not.toBe("Ana") + }) + + it("answers 404 for an unreadable post when there was no repost to take back", async () => { + const { svc } = harness({ blocked: true, removed: false }) + + await expect(svc.unrepostPost(TARGET, REPOSTER)).rejects.toMatchObject({ code: "NOT_FOUND" }) + }) + + it("answers the hydrated post when the reposter can still read it", async () => { + const { svc } = harness() + + await expect(svc.unrepostPost(TARGET, REPOSTER)).resolves.toMatchObject({ body: SECRET_BODY }) + }) +}) diff --git a/services/api/test/unit/push-fcm-invalid-argument.test.ts b/services/api/test/unit/push-fcm-invalid-argument.test.ts new file mode 100644 index 00000000..5e12dba0 --- /dev/null +++ b/services/api/test/unit/push-fcm-invalid-argument.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { PushLogger } from "../../src/adapters/push-sender.js" + +type FcmResponse = { success: boolean; error?: { code?: string; message?: string } } + +let nextResponses: FcmResponse[] = [] + +vi.mock("firebase-admin/app", () => ({ + getApps: () => [], + initializeApp: () => ({ name: "civfix-push" }), + cert: (value: unknown) => value, + deleteApp: () => Promise.resolve(), +})) + +vi.mock("firebase-admin/messaging", () => ({ + getMessaging: () => ({ + sendEachForMulticast: (message: { tokens: string[] }) => + Promise.resolve({ responses: message.tokens.map((_, i) => nextResponses[i]) }), + }), +})) + +const { makeFcmDispatcher } = await import("../../src/adapters/push-fcm.js") + +function recordingLogger(): { logger: PushLogger; warns: unknown[][] } { + const warns: unknown[][] = [] + return { + logger: { warn: (...args) => warns.push(args), error: () => {} }, + warns, + } +} + +const fcmConfig = { serviceAccountJson: "{}" } +const payload = { title: "New reply" } +const invalidArgument: FcmResponse = { + success: false, + error: { code: "messaging/invalid-argument", message: "Message is too big" }, +} + +beforeEach(() => { + nextResponses = [] +}) + +describe("FCM dispatcher invalid-argument handling", () => { + it("prunes nothing when every token in the slice fails with invalid-argument (payload fault)", async () => { + nextResponses = [invalidArgument, invalidArgument, invalidArgument] + const { logger, warns } = recordingLogger() + + const result = await makeFcmDispatcher(fcmConfig, logger)(["a", "b", "c"], payload) + + expect(result.invalidTokens).toEqual([]) + const payloadWarns = warns.filter(([, msg]) => String(msg).includes("payload")) + expect(payloadWarns).toHaveLength(1) + }) + + it("prunes nothing for a single-token slice rejected with invalid-argument", async () => { + nextResponses = [invalidArgument] + const { logger } = recordingLogger() + + const result = await makeFcmDispatcher(fcmConfig, logger)(["only"], payload) + + expect(result.invalidTokens).toEqual([]) + }) + + it("prunes an invalid-argument token when another token in the slice was accepted", async () => { + nextResponses = [{ success: true }, invalidArgument] + const { logger } = recordingLogger() + + const result = await makeFcmDispatcher(fcmConfig, logger)(["good", "malformed"], payload) + + expect(result.invalidTokens).toEqual(["malformed"]) + }) + + it("prunes unregistered tokens but keeps invalid-argument ones when nothing in the slice succeeded", async () => { + nextResponses = [ + { success: false, error: { code: "messaging/registration-token-not-registered" } }, + invalidArgument, + ] + const { logger } = recordingLogger() + + const result = await makeFcmDispatcher(fcmConfig, logger)(["gone", "other"], payload) + + expect(result.invalidTokens).toEqual(["gone"]) + }) +}) diff --git a/services/api/test/unit/push-sender.test.ts b/services/api/test/unit/push-sender.test.ts index 474620c8..42fde6fc 100644 --- a/services/api/test/unit/push-sender.test.ts +++ b/services/api/test/unit/push-sender.test.ts @@ -363,6 +363,7 @@ describe("per-user push rate cap (H15)", () => { counters: { incr: () => Promise.reject(new Error("redis down")), incrBy: () => Promise.reject(new Error("redis down")), + decrBy: () => Promise.reject(new Error("redis down")), }, logger: { warn: () => {}, error: () => {} }, }) diff --git a/services/api/test/unit/redis-incr-window.test.ts b/services/api/test/unit/redis-incr-window.test.ts new file mode 100644 index 00000000..1908c2e7 --- /dev/null +++ b/services/api/test/unit/redis-incr-window.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from "vitest" +import RedisMock from "ioredis-mock" +import type { RedisClient } from "../../src/adapters/redis.js" +import { attachAtomicIncr, attachAtomicIncrBy } from "../../src/adapters/redis-incr.js" + +const WINDOW_SECONDS = 60 +const ELAPSED_WINDOW_MS = 3_000 + +function freshRedis(): RedisClient { + return new RedisMock() as unknown as RedisClient +} + +describe("atomic counters keep a fixed window", () => { + beforeEach(async () => { + await freshRedis().flushall() + }) + + it("does not restart the window when a zero increment created the key", async () => { + const redis = freshRedis() + const incrBy = attachAtomicIncrBy(redis) + + await incrBy("budget", 0, WINDOW_SECONDS) + await redis.pexpire("budget", ELAPSED_WINDOW_MS) + await incrBy("budget", 5, WINDOW_SECONDS) + + const ttl = await redis.pttl("budget") + expect(ttl).toBeGreaterThan(0) + expect(ttl).toBeLessThanOrEqual(ELAPSED_WINDOW_MS) + }) + + it("gives an INCRBY counter that lost its expiry a fresh window instead of living forever", async () => { + const redis = freshRedis() + const incrBy = attachAtomicIncrBy(redis) + await redis.set("budget", "7") + + await expect(incrBy("budget", 2, WINDOW_SECONDS)).resolves.toBe(9) + expect(await redis.pttl("budget")).toBeGreaterThan(0) + }) + + it("gives an INCR counter that lost its expiry a fresh window instead of living forever", async () => { + const redis = freshRedis() + const incr = attachAtomicIncr(redis) + await redis.set("hits", "3") + + await expect(incr("hits", WINDOW_SECONDS)).resolves.toBe(4) + expect(await redis.pttl("hits")).toBeGreaterThan(0) + }) + + it("starts the window on the first increment and leaves it alone afterwards", async () => { + const redis = freshRedis() + const incr = attachAtomicIncr(redis) + + await expect(incr("hits", WINDOW_SECONDS)).resolves.toBe(1) + await redis.pexpire("hits", ELAPSED_WINDOW_MS) + await expect(incr("hits", WINDOW_SECONDS)).resolves.toBe(2) + + expect(await redis.pttl("hits")).toBeLessThanOrEqual(ELAPSED_WINDOW_MS) + }) +}) diff --git a/services/api/test/unit/report-chat-fanout-wiring.test.ts b/services/api/test/unit/report-chat-fanout-wiring.test.ts new file mode 100644 index 00000000..080fc21b --- /dev/null +++ b/services/api/test/unit/report-chat-fanout-wiring.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { FakePushSender } from "@civfix/shared/fakes" +import type { ChatMessageDTO } from "@civfix/shared" +import type { Container } from "../../src/di.js" + +const stub = vi.hoisted(() => ({ + notifRepo: undefined as unknown, + roster: [] as string[], + limits: [] as (number | undefined)[], + mutesFail: false, +})) + +vi.mock("../../src/services/notification-repository.drizzle.js", () => ({ + makeDrizzleNotificationRepository: () => stub.notifRepo, +})) + +vi.mock("../../src/services/report-chat-repository.drizzle.js", async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + makeReportChatRepository: () => ({ + listMemberIds: (_reportId: string, limit?: number) => { + stub.limits.push(limit) + return Promise.resolve(stub.roster.slice(0, limit ?? actual.REPORT_CHAT_MEMBER_SCAN_CAP)) + }, + insertSystemMessage: (input: { reportId: string }) => + Promise.resolve(systemMessage(input.reportId)), + }), + } +}) + +vi.mock("../../src/services/conversation-mutes-repository.drizzle.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../src/services/conversation-mutes-repository.drizzle.js") + >() + const lookup = (): Promise | undefined => + stub.mutesFail ? Promise.reject(new Error("mutes store down")) : undefined + return { + ...actual, + makeConversationMutesRepository: () => ({ + isMuted: () => lookup() ?? Promise.resolve(false), + setMuted: () => Promise.resolve(), + mutedRoomIdsFor: () => Promise.resolve(new Set()), + mutedUserIdsFor: () => lookup() ?? Promise.resolve(new Set()), + }), + } +}) + +const { makeContainerReportChatEmitter } = await import("../../src/services/report-chat-emitter.js") +const { makeContainerReportChatSendDeps } = + await import("../../src/services/report-chat-send-wiring.js") +const { makeReportChatNotifier, REPORT_CHAT_FANOUT_MEMBER_CAP } = + await import("../../src/services/report-chat-notifier.js") +const { makeNotificationService } = await import("../../src/services/notification-service.js") +const { InMemoryNotificationRepository } = await import("../helpers/notifications.js") + +const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const ACTOR = "dddddddd-dddd-dddd-dddd-dddddddddddd" +const OVERSIZED_ROSTER = REPORT_CHAT_FANOUT_MEMBER_CAP + 100 + +let notifRepo: InstanceType + +function systemMessage(reportId: string): ChatMessageDTO { + return { + id: "55555555-5555-5555-5555-555555555555", + cleanupId: reportId, + roomKind: "report", + from: null, + body: "Status changed to In progress", + kind: "system", + reactions: [], + mentions: [], + createdAt: new Date().toISOString(), + } as unknown as ChatMessageDTO +} + +function userMessage(): ChatMessageDTO { + return { + id: "66666666-6666-6666-6666-666666666666", + cleanupId: REPORT, + roomKind: "report", + from: { id: ACTOR, name: "Dana", followers: 0, following: 0, isFollowing: false }, + body: "hello", + kind: "text", + reactions: [], + mentions: [], + createdAt: new Date().toISOString(), + } +} + +function rosterOf(n: number): string[] { + return Array.from( + { length: n }, + (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`, + ) +} + +function containerFor(): Container { + return { + env: { USE_FAKE_CHAT: false }, + getDb: () => ({ sql: {} }), + pushSender: new FakePushSender(), + userChannel: undefined, + chatService: { broadcast: () => Promise.resolve() }, + getBlocksRepo: () => ({ isBlockedEitherWay: () => Promise.resolve(false) }), + } as unknown as Container +} + +function reportBells(): number { + return notifRepo.notifications.filter((n) => n.type === "report_chat").length +} + +function warnLogger(): { warn: ReturnType; error: ReturnType } { + return { warn: vi.fn(), error: vi.fn() } +} + +beforeEach(() => { + notifRepo = new InMemoryNotificationRepository() + stub.notifRepo = notifRepo + stub.roster = rosterOf(OVERSIZED_ROSTER) + stub.limits = [] + stub.mutesFail = false +}) + +describe("report-chat fan-out honours REPORT_CHAT_FANOUT_MEMBER_CAP on every wiring", () => { + it("the admin send wiring scans and bells at most the report cap", async () => { + const deps = makeContainerReportChatSendDeps(containerFor(), { + chatRepo: () => { + throw new Error("persist is not exercised here") + }, + }) + + await deps.notifyMembers!(REPORT, userMessage()) + + expect(stub.limits).toEqual([REPORT_CHAT_FANOUT_MEMBER_CAP]) + expect(reportBells()).toBe(REPORT_CHAT_FANOUT_MEMBER_CAP) + }) + + it("the timeline emitter scans and bells at most the report cap", async () => { + await makeContainerReportChatEmitter(containerFor()).emit({ + reportId: REPORT, + status: "in_progress", + }) + + expect(stub.limits).toEqual([REPORT_CHAT_FANOUT_MEMBER_CAP]) + expect(reportBells()).toBe(REPORT_CHAT_FANOUT_MEMBER_CAP) + }) + + it("the notifier bounds the recipients even when an adapter drops the limit", async () => { + const notify = makeReportChatNotifier({ + notificationService: makeNotificationService({ + repo: notifRepo, + pushSender: new FakePushSender(), + }), + reportChatRepo: { listMemberIds: () => Promise.resolve(rosterOf(OVERSIZED_ROSTER)) }, + isMuted: () => Promise.resolve(false), + roomKeyFor: (kind, id) => `${kind}:${id}`, + isBlockedEitherWay: () => Promise.resolve(false), + }) + + await notify(REPORT, userMessage()) + + expect(reportBells()).toBe(REPORT_CHAT_FANOUT_MEMBER_CAP) + }) +}) + +describe("a failed mute lookup notifies anyway and says so in the log", () => { + beforeEach(() => { + stub.roster = rosterOf(2) + stub.mutesFail = true + }) + + it("admin send wiring", async () => { + const logger = warnLogger() + const deps = makeContainerReportChatSendDeps(containerFor(), { + chatRepo: () => { + throw new Error("persist is not exercised here") + }, + logger: logger as never, + }) + + await deps.notifyMembers!(REPORT, userMessage()) + + expect(reportBells()).toBe(2) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ kind: "report" }), + expect.stringMatching(/mute lookup failed/), + ) + }) + + it("timeline emitter", async () => { + const logger = warnLogger() + + await makeContainerReportChatEmitter(containerFor(), logger).emit({ + reportId: REPORT, + status: "in_progress", + }) + + expect(reportBells()).toBe(2) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ kind: "report" }), + expect.stringMatching(/mute lookup failed/), + ) + }) +}) diff --git a/services/api/test/unit/report-chat-participant-joined-at.test.ts b/services/api/test/unit/report-chat-participant-joined-at.test.ts new file mode 100644 index 00000000..48f28fab --- /dev/null +++ b/services/api/test/unit/report-chat-participant-joined-at.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { + toReportParticipantDTO, + type ReportMemberRowSelect, +} from "../../src/services/report-chat-repository.drizzle.js" + +describe("toReportParticipantDTO joinedAt", () => { + it("serializes the timestamptz the driver returns as a Date into the contract's ISO string", () => { + const joinedAt = new Date("2026-07-31T08:15:00.000Z") + const row = { + user_id: "11111111-1111-4111-8111-111111111111", + role: "member", + joined_at: joinedAt, + display_name: "Ada", + handle: "ada", + bio: null, + avatar_url: null, + user_deleted_at: null, + is_following: false, + blocked_pair: false, + } as unknown as ReportMemberRowSelect + + const dto = toReportParticipantDTO(row) + + expect(typeof dto.joinedAt).toBe("string") + expect(dto.joinedAt).toBe("2026-07-31T08:15:00.000Z") + }) +}) diff --git a/services/api/test/unit/report-chat-participants.test.ts b/services/api/test/unit/report-chat-participants.test.ts index 272a08b9..219d38a7 100644 --- a/services/api/test/unit/report-chat-participants.test.ts +++ b/services/api/test/unit/report-chat-participants.test.ts @@ -22,7 +22,7 @@ function row(over: Partial = {}): ReportMemberRowSelect { return { user_id: "11111111-1111-4111-8111-111111111111", role: "member", - joined_at: "2026-07-31T00:00:00.000Z", + joined_at: new Date("2026-07-31T00:00:00.000Z"), display_name: "Ada Lovelace", handle: "ada", bio: "counts things", diff --git a/services/api/test/unit/report-forward-audit.test.ts b/services/api/test/unit/report-forward-audit.test.ts index 60e8c38e..9fc7ff2e 100644 --- a/services/api/test/unit/report-forward-audit.test.ts +++ b/services/api/test/unit/report-forward-audit.test.ts @@ -297,6 +297,7 @@ describe("makeCityForwardThrottle (F023: durable per-actor / per-geoid city-forw const broken: CounterStore = { incr: () => Promise.reject(new Error("redis down")), incrBy: () => Promise.reject(new Error("redis down")), + decrBy: () => Promise.reject(new Error("redis down")), } const gate = makeCityForwardThrottle(broken) await expect(gate(R2, GEO, ACTOR)).resolves.toBe(false) diff --git a/services/api/test/unit/report-owner-resolve-memory.test.ts b/services/api/test/unit/report-owner-resolve-memory.test.ts new file mode 100644 index 00000000..49aa2e75 --- /dev/null +++ b/services/api/test/unit/report-owner-resolve-memory.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest" +import { InMemoryReportRepository } from "../helpers/reports.js" + +const OWNER = "owner-1" +const RESOLVE = { status: "resolved" as const, note: "Marked resolved by the reporter" } +const REOPEN = { status: "published" as const, note: "Reopened by the reporter" } + +describe("InMemoryReportRepository.resolveByOwner mirrors the database transitions", () => { + it("resolving an already-resolved report is unchanged and writes no timeline row", async () => { + const repo = new InMemoryReportRepository() + const report = repo.seedReport({ reporterUserId: OWNER, status: "resolved" }) + + expect(await repo.resolveByOwner(report.id, OWNER, RESOLVE)).toBe("unchanged") + expect(repo.reports.get(report.id)!.status).toBe("resolved") + expect(repo.timeline).toHaveLength(0) + }) + + it("reopening a report that is not resolved keeps the city's progress status", async () => { + for (const status of ["in_progress", "acknowledged", "published"] as const) { + const repo = new InMemoryReportRepository() + const report = repo.seedReport({ reporterUserId: OWNER, status }) + + expect(await repo.resolveByOwner(report.id, OWNER, REOPEN)).toBe("unchanged") + expect(repo.reports.get(report.id)!.status).toBe(status) + expect(repo.timeline).toHaveLength(0) + } + }) + + it("still applies a real toggle and records it on the timeline", async () => { + const repo = new InMemoryReportRepository() + const report = repo.seedReport({ reporterUserId: OWNER, status: "in_progress" }) + + expect(await repo.resolveByOwner(report.id, OWNER, RESOLVE)).toBe("updated") + expect(await repo.resolveByOwner(report.id, OWNER, REOPEN)).toBe("updated") + expect(repo.reports.get(report.id)!.status).toBe("published") + expect(repo.timeline.map((t) => t.status)).toEqual(["resolved", "published"]) + }) + + it("a pre-publication report is still an invalid state", async () => { + const repo = new InMemoryReportRepository() + const report = repo.seedReport({ reporterUserId: OWNER, status: "held" }) + + expect(await repo.resolveByOwner(report.id, OWNER, RESOLVE)).toBe("invalid_state") + expect(repo.timeline).toHaveLength(0) + }) +}) diff --git a/services/api/test/unit/report-owner-resolve.test.ts b/services/api/test/unit/report-owner-resolve.test.ts new file mode 100644 index 00000000..d07baf9f --- /dev/null +++ b/services/api/test/unit/report-owner-resolve.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" +import type { ReportStatus } from "@civfix/shared" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import { makeReportService } from "../../src/services/report-service.js" +import type { ReportRepository } from "../../src/services/report-service.types.js" +import { InMemoryReportRepository } from "../helpers/reports.js" + +const REPORT_ID = "11111111-1111-1111-1111-111111111111" +const OWNER = "owner-1" + +function repoWithRow(status: ReportStatus) { + const fake = makeFakeSql([ + { + match: /SELECT reporter_user_id, deleted_at, status, visibility/, + rows: [{ reporter_user_id: OWNER, deleted_at: null, status, visibility: "public" }], + }, + ]) + const repo = makeDrizzleReportRepository(fake.sql as unknown as Sql) + const writes = () => fake.statements.filter((s) => /UPDATE reports|INSERT INTO/.test(s.sql)) + return { repo, writes } +} + +const RESOLVE = { status: "resolved" as const, note: "Marked resolved by the reporter" } +const REOPEN = { status: "published" as const, note: "Reopened by the reporter" } + +describe("resolveByOwner status transitions", () => { + it("resolving an already-resolved report is unchanged and writes nothing", async () => { + const { repo, writes } = repoWithRow("resolved") + + expect(await repo.resolveByOwner(REPORT_ID, OWNER, RESOLVE)).toBe("unchanged") + expect(writes()).toHaveLength(0) + }) + + it("reopening a report the city is working on keeps its progress status", async () => { + for (const status of ["in_progress", "acknowledged", "published"] as const) { + const { repo, writes } = repoWithRow(status) + + expect(await repo.resolveByOwner(REPORT_ID, OWNER, REOPEN)).toBe("unchanged") + expect(writes()).toHaveLength(0) + } + }) + + it("reopening a resolved report moves it back to published", async () => { + const { repo, writes } = repoWithRow("resolved") + + expect(await repo.resolveByOwner(REPORT_ID, OWNER, REOPEN)).toBe("updated") + expect(writes().map((s) => s.values[0])).toEqual(["published", REPORT_ID]) + }) + + it("resolving a report the city is working on still resolves it", async () => { + const { repo, writes } = repoWithRow("in_progress") + + expect(await repo.resolveByOwner(REPORT_ID, OWNER, RESOLVE)).toBe("updated") + expect(writes()).toHaveLength(2) + }) + + it("a pre-publication report is still an invalid state", async () => { + const { repo, writes } = repoWithRow("held") + + expect(await repo.resolveByOwner(REPORT_ID, OWNER, RESOLVE)).toBe("invalid_state") + expect(writes()).toHaveLength(0) + }) +}) + +describe("resolveReport on an unchanged status", () => { + it("returns the report without announcing a status change in the report chat", async () => { + const memory = new InMemoryReportRepository() + const seeded = memory.seedReport({ reporterUserId: OWNER, status: "resolved" }) + const repo: ReportRepository = Object.assign(Object.create(memory) as ReportRepository, { + resolveByOwner: () => Promise.resolve("unchanged" as const), + }) + const events: unknown[] = [] + const service = makeReportService({ + repo, + resolveJurisdictionGeoid: () => Promise.resolve(null), + presignMedia: (r2Key: string) => Promise.resolve({ url: `memory://${r2Key}` }), + reportChatEmitter: { + emit: (event: unknown) => { + events.push(event) + return Promise.resolve() + }, + }, + }) + + const dto = await service.resolveReport(OWNER, seeded.id, true) + + expect(dto.status).toBe("resolved") + expect(events).toHaveLength(0) + }) +}) diff --git a/services/api/test/unit/report-service-replay-and-meta.test.ts b/services/api/test/unit/report-service-replay-and-meta.test.ts new file mode 100644 index 00000000..cc4cb758 --- /dev/null +++ b/services/api/test/unit/report-service-replay-and-meta.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest" +import type { CreateReportRequest, ReportDTO } from "@civfix/shared" +import { makeReportService } from "../../src/services/report-service.js" +import { InMemoryReportRepository } from "../helpers/reports.js" + +const KEY = "11111111-1111-1111-1111-111111111111" +const OWNER = "u1" +const STALE_URL = "https://media.example/expired-signature" + +function createReq(): CreateReportRequest { + return { + idempotencyKey: KEY, + category: "trash", + type: "dump", + lat: 34.05, + lng: -118.25, + geomSource: "device", + mediaUploadIds: [], + } as CreateReportRequest +} + +function makeHarness(over: Partial[0]> = {}) { + const repo = new InMemoryReportRepository() + let issued = 0 + const warnings: { obj: unknown; msg?: string }[] = [] + const service = makeReportService({ + repo, + resolveJurisdictionGeoid: () => Promise.resolve(null), + presignMedia: (r2Key: string) => { + issued += 1 + return Promise.resolve({ url: `memory://${r2Key}?sig=${issued}` }) + }, + logger: { warn: (obj: unknown, msg?: string) => warnings.push({ obj, msg }) }, + ...over, + }) + return { repo, service, warnings } +} + +describe("createReport idempotent replay", () => { + it("re-reads the report so replayed media URLs are freshly signed, not the create-time snapshot", async () => { + const { repo, service } = makeHarness() + const report = repo.seedReport({ reporterUserId: OWNER }) + repo.seedMedia({ reportId: report.id, status: "ready", r2Key: "served/photo.jpg" }) + const snapshot = { + id: report.id, + category: "trash", + type: "dump", + status: "published", + visibility: "public", + lat: 34.1, + lng: -118.35, + geomSource: "device", + createdAt: report.createdAt.toISOString(), + mine: true, + gov: false, + following: false, + media: [{ id: "m-stale", kind: "image", url: STALE_URL, status: "ready" }], + mediaPending: 0, + timeline: [], + linkedEvents: [], + } as unknown as ReportDTO + repo.idempotency.set(`report_create:${KEY}:${OWNER}`, { + key: KEY, + scope: "report_create", + userOrAnon: OWNER, + snapshot, + }) + + const dto = await service.createReport(createReq(), { userId: OWNER }) + + expect(dto.id).toBe(report.id) + expect(dto.media).toHaveLength(1) + expect(dto.media[0]!.url).not.toBe(STALE_URL) + expect(dto.media[0]!.url).toMatch(/^memory:\/\/served\/photo\.jpg\?sig=/) + expect(repo.reports.size).toBe(1) + }) +}) + +describe("getReport optional meta loaders", () => { + it("logs a discussion-meta failure instead of dropping it silently", async () => { + const { repo, service, warnings } = makeHarness({ + loadDiscussionMeta: () => Promise.reject(new Error("discussion db down")), + }) + const report = repo.seedReport({ reporterUserId: OWNER }) + + const dto = await service.getReport(report.id, { userId: OWNER }) + + expect(dto.id).toBe(report.id) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.obj).toMatchObject({ reportId: report.id }) + expect(String((warnings[0]!.obj as { err: unknown }).err)).toMatch(/discussion db down/) + }) + + it("logs a chat-meta failure instead of dropping it silently", async () => { + const { repo, service, warnings } = makeHarness({ + loadReportChatMeta: () => Promise.reject(new Error("chat db down")), + }) + const report = repo.seedReport({ reporterUserId: OWNER }) + + const dto = await service.getReport(report.id, { userId: OWNER }) + + expect(dto.id).toBe(report.id) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.obj).toMatchObject({ reportId: report.id }) + }) +}) diff --git a/services/api/test/unit/seed-demo-la-purge.test.ts b/services/api/test/unit/seed-demo-la-purge.test.ts new file mode 100644 index 00000000..21303a0f --- /dev/null +++ b/services/api/test/unit/seed-demo-la-purge.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" +import { purgeDemo } from "../../src/db/seed-demo-la.js" +import type { TransactionSql } from "../../src/db/client.js" + +const REAL_USER = "11111111-1111-1111-1111-111111111111" +const REAL_POST = "22222222-2222-2222-2222-222222222222" + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ").trim() +} + +async function runPurge(handlers: SqlHandler[]) { + const fake = makeFakeSql(handlers) + const result = await purgeDemo(fake.sql as unknown as TransactionSql).then( + (counts) => ({ counts, error: null as Error | null }), + (error: Error) => ({ counts: null, error }), + ) + return { ...result, statements: fake.statements.map((s) => flat(s.sql)) } +} + +describe("seed-demo-la purge", () => { + it("refuses, before deleting anything, when real content depends on demo posts or events", async () => { + const { error, statements } = await runPurge([ + { match: /FROM volunteer_hours vh/, rows: [{ kind: "post", id: REAL_POST }] }, + ]) + expect(error?.message).toContain(REAL_POST) + expect(statements.some((s) => s.startsWith("DELETE"))).toBe(false) + }) + + it("recomputes the counters of real users and posts that interacted with demo accounts", async () => { + const { error, statements } = await runPurge([ + { match: /SELECT f\.follower_id AS id FROM follows_people/, rows: [{ id: REAL_USER }] }, + { match: /FROM post_likes l WHERE l\.user_id/, rows: [{ id: REAL_POST }] }, + ]) + expect(error).toBeNull() + const lastDelete = statements.map((s) => s.startsWith("DELETE FROM users")).lastIndexOf(true) + const userFix = statements.findIndex((s) => s.startsWith("UPDATE users u SET follower_count")) + const postFix = statements.findIndex((s) => s.startsWith("UPDATE posts p SET like_count")) + expect(userFix).toBeGreaterThan(lastDelete) + expect(postFix).toBeGreaterThan(lastDelete) + expect(statements[userFix]).toContain( + "following_count = (SELECT count(*) FROM follows_people f WHERE f.follower_id = u.id)", + ) + expect(statements[postFix]).toContain( + "repost_count = (SELECT count(*) FROM posts c WHERE c.repost_of_id = p.id AND c.kind = 'repost' AND c.deleted_at IS NULL)", + ) + }) +}) diff --git a/services/api/test/unit/seed-demo-la-time.test.ts b/services/api/test/unit/seed-demo-la-time.test.ts new file mode 100644 index 00000000..1c5841a8 --- /dev/null +++ b/services/api/test/unit/seed-demo-la-time.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" +import { laLocalToUtc } from "../../src/db/seed-demo-la.js" + +describe("seed-demo-la local time", () => { + it("places 9:00 local at 17:00 UTC in winter (PST)", () => { + expect(laLocalToUtc(Date.UTC(2026, 0, 17), 9, 0, 0).toISOString()).toBe( + "2026-01-17T17:00:00.000Z", + ) + }) + + it("places 9:00 local at 16:00 UTC in summer (PDT)", () => { + expect(laLocalToUtc(Date.UTC(2026, 6, 18), 9, 30, 0).toISOString()).toBe( + "2026-07-18T16:30:00.000Z", + ) + }) + + it("keeps an evening local time on its own local calendar day", () => { + expect(laLocalToUtc(Date.UTC(2026, 10, 7), 21, 15, 5).toISOString()).toBe( + "2026-11-08T05:15:05.000Z", + ) + }) +}) diff --git a/services/api/test/unit/seed-demo-la-write.test.ts b/services/api/test/unit/seed-demo-la-write.test.ts new file mode 100644 index 00000000..fc0c4422 --- /dev/null +++ b/services/api/test/unit/seed-demo-la-write.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { writeAll } from "../../src/db/seed-demo-la.js" +import type { TransactionSql } from "../../src/db/client.js" + +type SeedData = Parameters[1] + +const NOW = new Date("2026-09-20T12:00:00Z") +const USER_ID = "66666666-6666-6666-6666-666666666666" +const EVENT_ID = "77777777-7777-7777-7777-777777777777" +const REPORT_ID = "88888888-8888-8888-8888-888888888888" +const POST_ID = "99999999-9999-9999-9999-999999999999" + +function seedData(): SeedData { + const user = { + id: USER_ID, + displayName: "Demo", + handle: "demo", + email: "demo@demo.example", + bio: null, + locale: "en", + createdAt: new Date("2026-03-01T12:00:00Z"), + showVolunteerHours: null, + allowDirectMessages: true, + instagram: null, + followerCount: 0, + followingCount: 0, + } + const hood = { name: "Echo Park" } + return { + now: NOW, + users: [user], + follows: [], + events: [ + { + id: EVENT_ID, + organizer: user, + cohost: null, + title: "Park cleanup", + description: "Bring gloves", + lat: 34.07, + lng: -118.26, + address: "Echo Park Lake", + scheduledAt: new Date("2026-10-03T16:00:00Z"), + endsAt: new Date("2026-10-03T19:00:00Z"), + createdAt: new Date("2026-09-10T12:00:00Z"), + status: "upcoming", + bring: [], + capacity: null, + bags: 0, + members: [{ user, role: "organizer", joinedAt: new Date("2026-09-10T12:00:00Z") }], + slots: [], + claims: [], + hood, + }, + ], + reports: [ + { + id: REPORT_ID, + reporter: user, + type: "graffiti", + title: "Tag on the wall", + description: "Fresh tag", + addr: "1 Main St", + lat: 34.08, + lng: -118.27, + status: "published", + createdAt: new Date("2026-09-12T12:00:00Z"), + publishedAt: new Date("2026-09-12T12:00:00Z"), + geomSource: "device", + timeline: [], + hood, + }, + ], + posts: [ + { + id: POST_ID, + author: user, + kind: "post", + body: "Look at this", + replyTo: null, + threadRoot: null, + repostOf: null, + eventId: null, + reportId: REPORT_ID, + createdAt: new Date("2026-09-12T13:00:00Z"), + depth: 0, + likeCount: 0, + replyCount: 0, + repostCount: 0, + saveCount: 0, + mentions: [], + }, + ], + likes: [], + saves: [], + hours: [], + hashFor: (seatId: string) => `hash:${seatId}`, + } as unknown as SeedData +} + +function flat(sql: string): string { + return sql.replace(/\s+/g, " ").trim() +} + +describe("seed-demo-la writes rows the way the live paths do", () => { + it("adds registrations, post geometry, user activity and address provenance", async () => { + const fake = makeFakeSql([ + { match: /INSERT INTO reference_counters/, rows: [{ next_val: 1 }] }, + { match: /INSERT INTO cleanup_registrations/, rows: [{ id: "registration-1" }] }, + ]) + const tx = Object.assign(fake.sql, { unsafe: () => Promise.resolve([]) }) + await writeAll(tx as unknown as TransactionSql, seedData()) + const statements = fake.statements.map((s) => ({ sql: flat(s.sql), values: s.values })) + + const registration = statements.find((s) => + s.sql.startsWith("INSERT INTO cleanup_registrations"), + ) + expect(registration?.values).toContain(USER_ID) + expect(statements.some((s) => s.sql.startsWith("INSERT INTO cleanup_registration_seats"))).toBe( + true, + ) + + const postGeom = statements.find((s) => s.sql.startsWith("UPDATE posts p SET geom = COALESCE")) + expect(postGeom).toBeDefined() + + const activity = statements.filter((s) => s.sql.includes("SET last_activity_geom")) + expect(activity.length).toBeGreaterThanOrEqual(2) + + const report = statements.find((s) => s.sql.startsWith("INSERT INTO reports")) + expect(report?.sql).toContain("addr_source") + expect(report?.values).toContain("user") + const event = statements.find((s) => s.sql.startsWith("INSERT INTO cleanups")) + expect(event?.sql).toContain("address_source") + expect(event?.values).toContain("manual") + }) + + it("mints no seat for a member of an event that has already ended", async () => { + const data = seedData() + const [event] = data.events + event!.scheduledAt = new Date("2026-08-01T16:00:00Z") + event!.endsAt = new Date("2026-08-01T19:00:00Z") + event!.status = "done" + const fake = makeFakeSql([{ match: /INSERT INTO reference_counters/, rows: [{ next_val: 1 }] }]) + const tx = Object.assign(fake.sql, { unsafe: () => Promise.resolve([]) }) + await writeAll(tx as unknown as TransactionSql, data) + expect(fake.statements.some((s) => /INSERT INTO cleanup_registrations/.test(s.sql))).toBe(false) + }) +}) diff --git a/services/api/test/unit/server-background-jobs.test.ts b/services/api/test/unit/server-background-jobs.test.ts new file mode 100644 index 00000000..c6fb9fb8 --- /dev/null +++ b/services/api/test/unit/server-background-jobs.test.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance } from "fastify" +import { describe, expect, it, vi } from "vitest" + +const stubRegistration = vi.hoisted( + () => (exportName: string) => async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + [exportName]: vi.fn(() => Promise.resolve()), + }), +) + +vi.mock("../../src/services/admin/outreach-jobs.js", stubRegistration("registerOutreachJobs")) +vi.mock("../../src/services/admin/inbound-jobs.js", stubRegistration("registerInboundJobs")) +vi.mock("../../src/services/admin/discovery-jobs.js", stubRegistration("registerDiscoveryJobs")) +vi.mock("../../src/services/admin/autoforward-jobs.js", stubRegistration("registerAutoForwardJobs")) +vi.mock("../../src/services/data-export-jobs.js", stubRegistration("registerDataExportJobs")) +vi.mock("../../src/services/chat-fanout-jobs.js", stubRegistration("registerChatRoomFanoutJob")) +vi.mock("../../src/services/cleanup-jobs.js", stubRegistration("registerCleanupCancelFanoutJob")) +vi.mock("../../src/services/guest-jobs.js", stubRegistration("registerGuestJobs")) +vi.mock( + "../../src/services/host/registration-jobs.js", + stubRegistration("registerRegistrationJobs"), +) +vi.mock("../../src/services/host/comms-jobs.js", stubRegistration("registerCommsJobs")) + +const { startBackgroundJobs } = await import("../../src/server.js") +const { registerOutreachJobs } = await import("../../src/services/admin/outreach-jobs.js") +const { loadEnv } = await import("../../src/env.js") + +function fakeApp() { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } + const container = { + jobs: { start: vi.fn(() => Promise.resolve()), enqueue: vi.fn(() => Promise.resolve()) }, + } + return { app: { container, log } as unknown as FastifyInstance, container, log } +} + +describe("startBackgroundJobs", () => { + it("hands the app logger to the outreach job registration so a failed claim release is logged", async () => { + const { app, container, log } = fakeApp() + const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost/db" }) + + await startBackgroundJobs(app, env) + + expect(registerOutreachJobs).toHaveBeenCalledWith(container, log) + }) +}) diff --git a/services/api/test/unit/sms-twilio.test.ts b/services/api/test/unit/sms-twilio.test.ts index fe1c1c4a..6a7a52f0 100644 --- a/services/api/test/unit/sms-twilio.test.ts +++ b/services/api/test/unit/sms-twilio.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest" -import { TwilioSmsSender, classifyTwilioError } from "../../src/adapters/sms-twilio.js" +import { afterEach, describe, expect, it, vi } from "vitest" +import { + SMS_SEND_TIMEOUT_MS, + TwilioSmsSender, + classifyTwilioError, +} from "../../src/adapters/sms-twilio.js" import { smsFailureKind } from "../../src/errors/sms-failure.js" interface Recorded { @@ -138,4 +142,47 @@ describe("TwilioSmsSender", () => { expect(String((err as Error).message)).not.toContain("+15552223333") expect(String((err as Error).message)).toContain("provider code 21211") }) + + describe("response body", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("keeps the send deadline running while the body is read, and does not retry an accepted send", async () => { + vi.useFakeTimers() + const { sender } = senderWith( + () => new Response(new ReadableStream({ start() {} }), { status: 201 }), + ) + + const outcome = sender.send("+15552223333", "x").catch((e: unknown) => e) + await vi.advanceTimersByTimeAsync(SMS_SEND_TIMEOUT_MS + 1) + + const settled = await Promise.race([outcome, Promise.resolve("still pending")]) + expect(settled).not.toBe("still pending") + expect(smsFailureKind(settled)).toBe("permanent") + }) + + it("stops reading an error body without content-length once it passes the size cap", async () => { + const chunk = new Uint8Array(16 * 1024) + const maxChunks = 128 + let pulled = 0 + const { sender } = senderWith( + () => + new Response( + new ReadableStream({ + pull(controller) { + pulled += 1 + if (pulled > maxChunks) controller.close() + else controller.enqueue(chunk) + }, + }), + { status: 400 }, + ), + ) + + const err = await sender.send("+15552223333", "x").catch((e: unknown) => e) + expect(smsFailureKind(err)).toBe("permanent") + expect(pulled).toBeLessThan(maxChunks / 2) + }) + }) }) diff --git a/services/api/test/unit/social-follow-notify-failure.test.ts b/services/api/test/unit/social-follow-notify-failure.test.ts new file mode 100644 index 00000000..93607d45 --- /dev/null +++ b/services/api/test/unit/social-follow-notify-failure.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" +import { makeSocialService } from "../../src/services/social-service.js" +import { InMemorySocialRepository } from "../helpers/social.js" + +const A = "11111111-1111-1111-1111-111111111111" +const B = "22222222-2222-2222-2222-222222222222" + +describe("a failed new-follower notification is logged, not silently dropped", () => { + it("keeps the follow and records the failure with who followed whom", async () => { + const repo = new InMemorySocialRepository() + repo.seedUser({ id: A, displayName: "Alice" }) + repo.seedUser({ id: B, displayName: "Bob" }) + const failure = new Error("notifier down") + const warnings: Array<{ obj: unknown; msg: string }> = [] + const service = makeSocialService({ + repo, + notifier: { onNewFollower: () => Promise.reject(failure) }, + logger: { warn: (obj, msg) => warnings.push({ obj, msg }) }, + }) + + await expect(service.followPerson(A, B)).resolves.toEqual({ isFollowing: true, followers: 1 }) + expect(warnings).toHaveLength(1) + expect(warnings[0]?.obj).toEqual({ err: failure, viewerId: A, targetId: B }) + }) +}) diff --git a/services/api/test/unit/storage-r2-proxy.test.ts b/services/api/test/unit/storage-r2-proxy.test.ts index 60b3ff88..5cc2f0fa 100644 --- a/services/api/test/unit/storage-r2-proxy.test.ts +++ b/services/api/test/unit/storage-r2-proxy.test.ts @@ -43,6 +43,13 @@ function storage() { }) } +async function proxyOf(config: Record): Promise { + const handler = config.requestHandler as { + configProvider?: Promise<{ httpsAgent?: { proxy?: URL } }> + } + return (await handler.configProvider)?.httpsAgent?.proxy +} + beforeEach(() => { clientConfigs.length = 0 for (const key of ENV_KEYS) { @@ -59,11 +66,11 @@ afterEach(() => { }) describe("R2 client egress", () => { - it("builds no requestHandler when HTTPS_PROXY is unset (the API container)", async () => { + it("builds a direct requestHandler when HTTPS_PROXY is unset (the API container)", async () => { await storage().presignGet("uploads/a", 60, { forceSigned: true }) expect(clientConfigs).toHaveLength(1) - expect(clientConfigs[0]!.requestHandler).toBeUndefined() + expect(await proxyOf(clientConfigs[0]!)).toBeUndefined() }) it("builds a proxy-bearing requestHandler when HTTPS_PROXY is set (the worker)", async () => { @@ -87,7 +94,7 @@ describe("R2 client egress", () => { await storage().presignGet("uploads/a", 60, { forceSigned: true }) - expect(clientConfigs[0]!.requestHandler).toBeUndefined() + expect(await proxyOf(clientConfigs[0]!)).toBeUndefined() }) }) diff --git a/services/api/test/unit/storage-r2-timeouts.test.ts b/services/api/test/unit/storage-r2-timeouts.test.ts new file mode 100644 index 00000000..a93cdfeb --- /dev/null +++ b/services/api/test/unit/storage-r2-timeouts.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const clientConfigs: Record[] = [] +const sendOptions: ({ abortSignal?: AbortSignal; requestTimeout?: number } | undefined)[] = [] + +vi.mock("@aws-sdk/client-s3", () => { + class S3Client { + constructor(config: Record) { + clientConfigs.push(config) + } + send( + _command: unknown, + options?: { abortSignal?: AbortSignal; requestTimeout?: number }, + ): Promise { + sendOptions.push(options) + return Promise.resolve({}) + } + } + class Command { + constructor(readonly input: unknown) {} + } + return { + S3Client, + GetObjectCommand: Command, + PutObjectCommand: Command, + HeadObjectCommand: Command, + DeleteObjectCommand: Command, + ListObjectsV2Command: Command, + } +}) + +const { R2Storage, R2_RESPONSE_TIMEOUT_MS, R2_TRANSFER_OPERATION_TIMEOUT_MS } = + await import("../../src/adapters/storage.r2.js") + +const ENV_KEYS = ["HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"] as const +const saved: Record = {} + +interface HandlerConfig { + connectionTimeout?: number + socketTimeout?: number + requestTimeout?: number + throwOnRequestTimeout?: boolean + httpsAgent?: { proxy?: URL } +} + +function storage() { + return new R2Storage({ + accountId: "acct", + accessKeyId: "key", + secretAccessKey: "secret", + bucket: "civfix-media", + }) +} + +async function resolvedHandlerConfig(): Promise { + const handler = clientConfigs[0]?.requestHandler as + | { configProvider?: Promise } + | undefined + return handler?.configProvider +} + +beforeEach(() => { + clientConfigs.length = 0 + sendOptions.length = 0 + for (const key of ENV_KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } +}) + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } +}) + +describe("R2 client timeouts", () => { + it("bounds connect, idle socket and response wait, and makes the response deadline throw", async () => { + await storage().head("uploads/a") + + const config = await resolvedHandlerConfig() + expect(config?.connectionTimeout).toBeGreaterThan(0) + expect(config?.socketTimeout).toBeGreaterThan(0) + expect(config?.requestTimeout).toBeGreaterThan(0) + expect(config?.throwOnRequestTimeout).toBe(true) + expect(clientConfigs[0]!.maxAttempts).toBe(3) + }) + + it("keeps the same timeouts on the proxied handler", async () => { + process.env.HTTPS_PROXY = "http://media-egress-proxy:8888" + + await storage().head("uploads/a") + + const config = await resolvedHandlerConfig() + expect(String(config?.httpsAgent?.proxy)).toContain("media-egress-proxy:8888") + expect(config?.connectionTimeout).toBeGreaterThan(0) + expect(config?.socketTimeout).toBeGreaterThan(0) + expect(config?.throwOnRequestTimeout).toBe(true) + }) + + it("gives every network operation an abort deadline that also covers the body read", async () => { + const r2 = storage() + await r2.head("uploads/a") + await r2.delete("uploads/a") + await r2.put("uploads/a", new Uint8Array([1])) + await r2.list("uploads/") + await r2.getObject("uploads/a") + + expect(sendOptions).toHaveLength(5) + for (const options of sendOptions) { + expect(options?.abortSignal).toBeInstanceOf(AbortSignal) + expect(options?.abortSignal?.aborted).toBe(false) + } + }) + + it("gives object transfers the transfer budget for the upload itself, not the response wait", async () => { + const r2 = storage() + await r2.put("uploads/a", new Uint8Array([1])) + await r2.getObject("uploads/a") + await r2.head("uploads/a") + + const [putOptions, getOptions, headOptions] = sendOptions + expect(putOptions?.requestTimeout).toBe(R2_TRANSFER_OPERATION_TIMEOUT_MS) + expect(getOptions?.requestTimeout).toBe(R2_TRANSFER_OPERATION_TIMEOUT_MS) + expect(headOptions?.requestTimeout).toBeUndefined() + expect((await resolvedHandlerConfig())?.requestTimeout).toBe(R2_RESPONSE_TIMEOUT_MS) + }) +}) diff --git a/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts b/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts new file mode 100644 index 00000000..73ec580b --- /dev/null +++ b/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" +import { + LEADERBOARD_MAX_LIMIT, + LEADERBOARD_MAX_OFFSET, + makeVolunteerHoursService, + type CleanupHoursLookup, +} from "../../src/services/volunteer-hours-service.js" +import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" + +const GEOID = "0667000" + +/** A jurisdiction deeper than the offset ceiling: the repository always reports another page. */ +function bottomlessRepo(): InMemoryVolunteerHoursRepository { + const repo = new InMemoryVolunteerHoursRepository() + repo.leaderboard = (_geoid, limit, offset) => + Promise.resolve({ + jurisdictionName: "San Francisco", + entries: [], + nextOffset: offset + limit, + participantCount: null, + viewerRank: null, + viewerHours: null, + }) + return repo +} + +describe("leaderboard paging at the offset ceiling", () => { + it("ends the chain instead of handing back an offset that clamps onto the same page", async () => { + const service = makeVolunteerHoursService({ + repo: bottomlessRepo(), + cleanups: {} as CleanupHoursLookup, + }) + + const last = await service.leaderboard(GEOID, { + geoid: GEOID, + limit: LEADERBOARD_MAX_LIMIT, + offset: LEADERBOARD_MAX_OFFSET, + }) + expect(last.nextOffset).toBeNull() + + const beyond = await service.leaderboard(GEOID, { + geoid: GEOID, + limit: LEADERBOARD_MAX_LIMIT, + offset: LEADERBOARD_MAX_OFFSET + 20, + }) + expect(beyond.nextOffset).toBeNull() + }) + + it("keeps paging while the next offset is still reachable", async () => { + const service = makeVolunteerHoursService({ + repo: bottomlessRepo(), + cleanups: {} as CleanupHoursLookup, + }) + + const page = await service.leaderboard(GEOID, { + geoid: GEOID, + limit: LEADERBOARD_MAX_LIMIT, + offset: LEADERBOARD_MAX_OFFSET - LEADERBOARD_MAX_LIMIT, + }) + expect(page.nextOffset).toBe(LEADERBOARD_MAX_OFFSET) + }) +}) diff --git a/services/api/test/unit/ws-socket-lifecycle.test.ts b/services/api/test/unit/ws-socket-lifecycle.test.ts index e8a77397..b108324a 100644 --- a/services/api/test/unit/ws-socket-lifecycle.test.ts +++ b/services/api/test/unit/ws-socket-lifecycle.test.ts @@ -25,6 +25,8 @@ import { makeWsTicketStore } from "../../src/auth/ws-ticket.js" import { sha256Hex } from "../../src/auth/crypto.js" import { WS_CLOSE_POLICY_VIOLATION, + WS_FRAME_LIMIT, + WS_MAX_QUEUED_FRAMES, WS_REAUTH_INTERVAL_MS, WS_REAUTH_JITTER_MS, } from "../../src/ws/types.js" @@ -294,7 +296,7 @@ describe("frames sent during the async handshake are buffered, not dropped", () expect(chat.roomSize(ROOM)).toBe(0) }) - it("a join that fits under BOTH bounds is still applied (the caps are not off-by-one)", async () => { + it("a join that fits under BOTH bounds is still buffered and answered (the caps are not off-by-one)", async () => { const handler = captureGatewayHandler(baseOpts()) const socket = new MockSocket() handler(socket as unknown as WebSocket, authedRequest(ALICE)) @@ -302,8 +304,9 @@ describe("frames sent during the async handshake are buffered, not dropped", () socket.emit("message", JSON.stringify({ type: "join", cleanupId: ROOM })) await flush() - expect(socket.framesOfType("presence_snapshot")).toHaveLength(1) - expect(chat.roomSize(ROOM)).toBe(1) + const answers = socket.framesOfType("error") + expect(answers).toHaveLength(WS_HANDSHAKE_FRAME_BUFFER) + expect(answers.at(-1)).toMatchObject({ code: "RATE_LIMITED" }) }) it("frames arriving after the session is live still dispatch (the handover works)", async () => { @@ -318,6 +321,51 @@ describe("frames sent during the async handshake are buffered, not dropped", () }) }) +describe("the pre-auth buffer holds as many frames as the post-auth queue", () => { + const DRAIN_BUDGET_MS = 1_000 + const roomN = (n: number): string => + `bbbb${String(n).padStart(4, "0")}-bbbb-4bbb-8bbb-bbbbbbbbbbbb` + + function pipelineJoins(count: number): MockSocket { + const handler = captureGatewayHandler(baseOpts({ isMember: () => Promise.resolve(true) })) + const socket = new MockSocket() + handler(socket as unknown as WebSocket, authedRequest(ALICE)) + for (let i = 0; i < count; i += 1) { + socket.emit("message", JSON.stringify({ type: "join", cleanupId: roomN(i) })) + } + return socket + } + + it("sizes the handshake buffer from the post-auth frame cap", () => { + expect(WS_HANDSHAKE_FRAME_BUFFER).toBe(WS_MAX_QUEUED_FRAMES) + }) + + it("applies a full frame-bucket burst of joins pipelined before auth", async () => { + const socket = pipelineJoins(WS_FRAME_LIMIT.capacity) + await settleUntil( + () => socket.framesOfType("presence_snapshot").length >= WS_FRAME_LIMIT.capacity, + DRAIN_BUDGET_MS, + ).catch(() => undefined) + + expect(socket.framesOfType("presence_snapshot")).toHaveLength(WS_FRAME_LIMIT.capacity) + expect(socket.framesOfType("error")).toHaveLength(0) + }) + + it("answers every buffered frame past the burst instead of dropping it silently", async () => { + const socket = pipelineJoins(WS_MAX_QUEUED_FRAMES) + const answered = (): number => + socket.framesOfType("presence_snapshot").length + socket.framesOfType("error").length + await settleUntil(() => answered() >= WS_MAX_QUEUED_FRAMES, DRAIN_BUDGET_MS).catch( + () => undefined, + ) + + expect(socket.framesOfType("presence_snapshot")).toHaveLength(WS_FRAME_LIMIT.capacity) + const limited = socket.framesOfType("error") + expect(limited).toHaveLength(WS_MAX_QUEUED_FRAMES - WS_FRAME_LIMIT.capacity) + expect(limited.every((f) => f.code === "RATE_LIMITED")).toBe(true) + }) +}) + describe("per-user connection cap and slot release", () => { let opened = 0 async function open( diff --git a/services/media-worker/.env.example b/services/media-worker/.env.example index 294e6a7b..094628ab 100644 --- a/services/media-worker/.env.example +++ b/services/media-worker/.env.example @@ -68,7 +68,9 @@ MEDIA_SHARP_PIXEL_LIMIT= # [OPT] sharp limitInputPixels ceilin MEDIA_MAX_CHILD_OUTPUT_BYTES= # [OPT] max bytes a sandboxed child may emit (default MAX_VIDEO_BYTES+1MB) MEDIA_FFPROBE_TIMEOUT_MS= # [OPT] per-call ffprobe timeout (default 10000) MEDIA_FFMPEG_TIMEOUT_MS= # [OPT] per-call ffmpeg remux/thumbnail timeout (default 30000) -MEDIA_IMAGE_TIMEOUT_MS= # [OPT] per-call sharp pipeline timeout (default 15000) +MEDIA_IMAGE_TIMEOUT_MS= # [OPT] per-call sharp pipeline timeout (default 15000). The image + # lane is killed at 3x this + 5s, and boot FAILS unless that stays + # below MEDIA_JOB_TIMEOUT_MS (so at most 28333 at the default 90000). MEDIA_JOB_TIMEOUT_MS= # [OPT] per-PHASE wall-clock budget for a media.checks job (default # 90000). Sized ABOVE the worst-case sandbox tool sum on the video # path (ffprobe 10s + remux 30s + frame 30s + thumbnail 15s = 85s) so diff --git a/services/media-worker/src/config.ts b/services/media-worker/src/config.ts index 5afda1d7..c52e6abf 100644 --- a/services/media-worker/src/config.ts +++ b/services/media-worker/src/config.ts @@ -103,8 +103,32 @@ export const ALLOWED_VIDEO_CODECS: ReadonlySet = new Set(["h264", "hevc" const ONE_MB = 1024 * 1024 +const IMAGE_LANE_SPAWN_OVERHEAD_MS = 5_000 + +// The child bounds metadata, strip + thumbnail, and the perceptual hash by imageTimeoutMs EACH and runs them +// in sequence, so killing it at a single imageTimeoutMs rejected (and deleted) slow but legitimate photos. +const IMAGE_LANE_TIMED_PHASES = 3 + +export function imageLaneTimeoutMs(limits: Pick): number { + return IMAGE_LANE_TIMED_PHASES * limits.imageTimeoutMs + IMAGE_LANE_SPAWN_OVERHEAD_MS +} + +// The lane must be killed inside the processing phase's job budget: a lane still running when that budget +// fires surfaces as a JobTimeoutError, which media.checks retries as infra instead of rejecting the bytes, +// so a slow hostile image would be retried rather than refused. +function assertImageLaneFitsJobBudget(limits: WorkerLimits): void { + const laneMs = imageLaneTimeoutMs(limits) + if (laneMs < limits.jobTimeoutMs) return + throw new Error( + `media-worker: MEDIA_IMAGE_TIMEOUT_MS=${limits.imageTimeoutMs} gives an image lane budget of ` + + `${laneMs}ms (${IMAGE_LANE_TIMED_PHASES} phases + ${IMAGE_LANE_SPAWN_OVERHEAD_MS}ms spawn overhead), ` + + `which must stay below MEDIA_JOB_TIMEOUT_MS=${limits.jobTimeoutMs}. Lower the image timeout or ` + + "raise the job timeout.", + ) +} + export function loadLimits(source: NodeJS.ProcessEnv = process.env): WorkerLimits { - return { + const limits: WorkerLimits = { maxDownloadBytes: parsePosInt(source.MEDIA_MAX_DOWNLOAD_BYTES, MAX_VIDEO_BYTES), maxImagePixels: parsePosInt(source.MEDIA_MAX_IMAGE_PIXELS, 24_000_000), sharpPixelLimit: parsePosInt(source.MEDIA_SHARP_PIXEL_LIMIT, 32_000_000), @@ -133,6 +157,8 @@ export function loadLimits(source: NodeJS.ProcessEnv = process.env): WorkerLimit stuckSweepBatch: parsePosInt(source.MEDIA_STUCK_SWEEP_BATCH, 500), stuckSweepMaxAttempts: parsePosInt(source.MEDIA_STUCK_SWEEP_MAX_ATTEMPTS, 5), } + assertImageLaneFitsJobBudget(limits) + return limits } function clampUnit(raw: string | undefined, fallback: number): number { diff --git a/services/media-worker/src/download.ts b/services/media-worker/src/download.ts index 0d60fb46..5f967b0c 100644 --- a/services/media-worker/src/download.ts +++ b/services/media-worker/src/download.ts @@ -84,6 +84,7 @@ export function makeDownloader(storage: Storage): DownloadFn { throw new StorageUnavailableError(r2Key, err) } if (!res.ok) { + // Only frees the socket; the status error below is what the caller acts on. await res.body?.cancel().catch(() => {}) throw new StorageUnavailableError(r2Key, `HTTP ${res.status}`) } diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index 3e2f4526..6cb98564 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -8,6 +8,7 @@ import { settleWithin } from "../timeout.js" import { resolveJobObs, type JobObsDeps, type JobLogFn, type JobReportFn } from "./obs.js" import { readEtag } from "@civfix/api/media-repo" import { SandboxSpawnError } from "../sandbox/exec.js" +import { ScratchSetupError } from "../sandbox/tmp.js" import { servedKey, thumbnailKey } from "./media-keys.js" import { deleteRejectedObjects, deleteSupersededUpload } from "./reject-cleanup.js" import { processMedia, errNote, type MediaProcessResult } from "./media-pipeline.js" @@ -191,6 +192,14 @@ async function processAsset( }) throw new MediaInfraError("sandbox-spawn", err) } + if (err instanceof ScratchSetupError) { + report(err, { job: "media.checks", phase: "scratch", mediaId: asset.id }) + log("media.checks: the sandbox scratch dir could not be prepared, will retry", { + mediaId: asset.id, + err: String(err), + }) + throw new MediaInfraError("scratch", err) + } return persistRejection(asset, deps, errNote("process failed", err)) } diff --git a/services/media-worker/src/jobs/media-pipeline.ts b/services/media-worker/src/jobs/media-pipeline.ts index 691e88be..30a3531e 100644 --- a/services/media-worker/src/jobs/media-pipeline.ts +++ b/services/media-worker/src/jobs/media-pipeline.ts @@ -7,6 +7,7 @@ import { ALLOWED_VIDEO_CODECS } from "../config.js" import type { ExifGps } from "../sandbox/image.js" import { processImageLane } from "../sandbox/image-lane.js" import { SandboxSpawnError } from "../sandbox/exec.js" +import { ScratchSetupError } from "../sandbox/tmp.js" import { probeBytes } from "../sandbox/ffprobe.js" import { grabFrameJpeg, remuxStripMetadata } from "../sandbox/ffmpeg-remux.js" @@ -42,6 +43,12 @@ export interface ProcessDeps { findPhashDuplicate?: FindPhashDuplicateFn } +// A decoder that could not start or a scratch dir the worker could not build says nothing about the +// bytes: these escape so media.checks retries them as infrastructure instead of rejecting the upload. +export function isSandboxInfraFailure(err: unknown): err is SandboxSpawnError | ScratchSetupError { + return err instanceof SandboxSpawnError || err instanceof ScratchSetupError +} + export function rejected(note: string): MediaProcessResult { return { status: "rejected", @@ -131,7 +138,7 @@ async function processImageBytes( try { img = await processImageLane(bytes, deps.limits) } catch (err) { - if (err instanceof SandboxSpawnError) throw err + if (isSandboxInfraFailure(err)) throw err return rejected(errNote("image decode/guard failed", err)) } const phash = img.phash @@ -188,7 +195,7 @@ async function processVideoBytes( try { probe = await probeBytes(bytes, deps.limits) } catch (err) { - if (err instanceof SandboxSpawnError) throw err + if (isSandboxInfraFailure(err)) throw err return rejected(errNote("ffprobe failed", err)) } if (!probe.isVideo) { @@ -211,7 +218,7 @@ async function processVideoBytes( try { remuxed = await remuxStripMetadata(bytes, deps.limits) } catch (err) { - if (err instanceof SandboxSpawnError) throw err + if (isSandboxInfraFailure(err)) throw err return rejected(errNote("remux failed", err)) } @@ -220,7 +227,7 @@ async function processVideoBytes( const at = Math.min(1, probe.durationSec / 2) frameJpeg = await grabFrameJpeg(bytes, at, deps.limits) } catch (err) { - if (err instanceof SandboxSpawnError) throw err + if (isSandboxInfraFailure(err)) throw err frameJpeg = null } @@ -232,7 +239,7 @@ async function processVideoBytes( thumbnailBytes = thumb.thumbnailBytes thumbnailContentType = thumb.thumbnailContentType } catch (err) { - if (err instanceof SandboxSpawnError) throw err + if (isSandboxInfraFailure(err)) throw err thumbnailBytes = null thumbnailContentType = null } @@ -280,7 +287,7 @@ export async function processMedia( } return await processVideoBytes(input.bytes, deps) } catch (err) { - if (err instanceof SandboxSpawnError) throw err + if (isSandboxInfraFailure(err)) throw err return rejected(errNote("unexpected processing error", err)) } } diff --git a/services/media-worker/src/jobs/reject-cleanup.ts b/services/media-worker/src/jobs/reject-cleanup.ts index 989b0934..38de2cef 100644 --- a/services/media-worker/src/jobs/reject-cleanup.ts +++ b/services/media-worker/src/jobs/reject-cleanup.ts @@ -37,8 +37,12 @@ export async function deleteSupersededUpload( try { await deps.storage.delete(asset.r2Key) return - } catch (ignored) { - void ignored + } catch (err) { + log("media.checks: superseded-upload delete failed, tombstoning for retry", { + mediaId: asset.id, + key: asset.r2Key, + err: String(err), + }) } await deps.repo @@ -89,8 +93,13 @@ export async function deleteRejectedObjects( for (const key of keys) { try { await deps.storage.delete(key) - } catch { + } catch (err) { leaked.push(key) + log("media.checks: rejected-media delete failed, tombstoning for retry", { + mediaId: asset.id, + key, + err: String(err), + }) } } if (leaked.length === 0) return diff --git a/services/media-worker/src/jobs/stuck-sweep.ts b/services/media-worker/src/jobs/stuck-sweep.ts index b691c814..07be9049 100644 --- a/services/media-worker/src/jobs/stuck-sweep.ts +++ b/services/media-worker/src/jobs/stuck-sweep.ts @@ -1,5 +1,10 @@ import type { Jobs, Storage } from "@civfix/shared/interfaces" -import { MEDIA_CHECKS_JOB, type MediaWorkerRepo, type StuckMediaRow } from "@civfix/api/media-repo" +import { + MEDIA_CHECKS_JOB, + type MediaChecksJob, + type MediaWorkerRepo, + type StuckMediaRow, +} from "@civfix/api/media-repo" import type { WorkerLimits } from "../config.js" import { resolveJobObs, type JobObsDeps } from "./obs.js" import { deleteRejectedObjects } from "./reject-cleanup.js" @@ -78,7 +83,13 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise { - cached ??= await resolveMediaToolPaths(process.env) + try { + cached ??= await resolveMediaToolPaths(process.env) + } catch (err) { + throw new SandboxSpawnError(tool, err) + } return cached[tool] } diff --git a/services/media-worker/src/sandbox/exec.ts b/services/media-worker/src/sandbox/exec.ts index 7f42cd7f..2bab12e2 100644 --- a/services/media-worker/src/sandbox/exec.ts +++ b/services/media-worker/src/sandbox/exec.ts @@ -157,6 +157,7 @@ function killProcessGroup(pid: number | undefined): void { try { process.kill(-pid, "SIGKILL") } catch (ignored) { + // ESRCH: the group already exited, which is the state this call exists to reach. void ignored } } diff --git a/services/media-worker/src/sandbox/image-lane-main.ts b/services/media-worker/src/sandbox/image-lane-main.ts index 6fcf1654..f430204d 100644 --- a/services/media-worker/src/sandbox/image-lane-main.ts +++ b/services/media-worker/src/sandbox/image-lane-main.ts @@ -68,6 +68,7 @@ export async function runImageLane(req: ImageLaneRequest): Promise { - const dir = await mkdtemp(join(tmpdir(), SCRATCH_PREFIX)) + let dir: string + try { + dir = await mkdtemp(join(tmpdir(), SCRATCH_PREFIX)) + } catch (err) { + throw new ScratchSetupError("could not create the sandbox scratch dir", err) + } const safeExt = /^[a-z0-9]{1,8}$/i.test(ext) ? ext : "bin" const inputPath = join(dir, `input.${safeExt}`) - const identity = sandboxIdentity() - if (identity !== null) { - try { + try { + const identity = sandboxIdentity() + if (identity !== null) { await chown(dir, process.getuid?.() ?? -1, identity.gid) await chmod(dir, 0o2770) - } catch (err) { - await rm(dir, { recursive: true, force: true }).catch(() => {}) - throw err } - } - if (bytes !== undefined) { - try { + if (bytes !== undefined) { await writeFile(inputPath, bytes) if (identity !== null) await chmod(inputPath, 0o660) - } catch (err) { - await rm(dir, { recursive: true, force: true }).catch(() => {}) - throw err } + } catch (err) { + await rm(dir, { recursive: true, force: true }).catch(() => {}) + throw new ScratchSetupError("could not prepare the sandbox scratch dir", err) } let cleaned = false return { @@ -63,11 +74,16 @@ export async function makeScratch(bytes?: Uint8Array, ext = "bin"): Promise { - await chmod(dir, 0o700) + try { + await chmod(dir, 0o700) + } catch (err) { + throw new ScratchSetupError("could not seal the sandbox scratch dir", err) + } }, async cleanup(): Promise { if (cleaned) return cleaned = true + // A dir left behind is reaped by sweepStaleScratchDirs; cleanup must never mask the job's own outcome. await rm(dir, { recursive: true, force: true }).catch(() => {}) }, } @@ -128,6 +144,7 @@ export async function sweepStaleScratchDirs(maxAgeMs = 60 * 60 * 1000): Promise< removed++ } } catch (ignored) { + // A running job's cleanup can remove the dir between readdir and stat; the next sweep retries the rest. void ignored } } diff --git a/services/media-worker/src/seams.ts b/services/media-worker/src/seams.ts index d9159398..53f4f40d 100644 --- a/services/media-worker/src/seams.ts +++ b/services/media-worker/src/seams.ts @@ -10,6 +10,7 @@ import { LOCAL_STORAGE_DEV_SIGNING_KEY, LocalDiskStorage } from "@civfix/api/ada import { captureError, initErrorReporting, flushErrorReporting } from "@civfix/api/errors" import { assertRealSeamInProd, loadLimits, parseBool, type WorkerLimits } from "./config.js" import { makeDownloader, type DownloadFn } from "./download.js" +import type { JobLogFn } from "./jobs/obs.js" export interface WorkerSeams { storage: Storage @@ -31,8 +32,20 @@ function useFake(source: NodeJS.ProcessEnv, key: string): boolean { return parseBool(source[key], !isProd) } -export async function buildSeams(source: NodeJS.ProcessEnv = process.env): Promise { +export interface BuildSeamsOptions { + log?: JobLogFn +} + +// The worker has no pino instance of its own; adapters it builds write to this one channel so a caller +// can redirect every seam's output at once instead of each adapter choosing its own console fallback. +const defaultSeamLog: JobLogFn = (line, extra) => console.warn(line, extra ?? {}) + +export async function buildSeams( + source: NodeJS.ProcessEnv = process.env, + options: BuildSeamsOptions = {}, +): Promise { const limits = loadLimits(source) + const log = options.log ?? defaultSeamLog const fakeStorage = useFake(source, "USE_FAKE_STORAGE") const fakeAbuse = useFake(source, "USE_FAKE_ABUSE_NSFW") @@ -90,7 +103,7 @@ export async function buildSeams(source: NodeJS.ProcessEnv = process.env): Promi const abuseChecks: AbuseChecks = fakeAbuse ? new FakeAbuseChecks() - : await buildRealAbuseChecks(source, limits, findPhashDuplicate) + : await buildRealAbuseChecks(source, limits, findPhashDuplicate, log) const download = makeDownloader(storage) @@ -156,6 +169,7 @@ async function buildRealAbuseChecks( source: NodeJS.ProcessEnv, limits: WorkerLimits, findPhashDuplicate: FindPhashDuplicateFn | undefined, + log: JobLogFn, ): Promise { const { RealAbuseChecks } = await import("@civfix/api/adapters/abuse-checks") const { perceptualHash } = await import("./sandbox/phash.js") @@ -165,6 +179,7 @@ async function buildRealAbuseChecks( useRealNsfw: parseBool(source.USE_REAL_NSFW, false), perceptualHash: (bytes: Uint8Array) => perceptualHash(bytes, limits), ...(findPhashDuplicate ? { findPhashDuplicate } : {}), + log, }) } diff --git a/services/media-worker/test/helpers/in-memory-repo.ts b/services/media-worker/test/helpers/in-memory-repo.ts index bf25e8e2..e90001c5 100644 --- a/services/media-worker/test/helpers/in-memory-repo.ts +++ b/services/media-worker/test/helpers/in-memory-repo.ts @@ -17,6 +17,7 @@ export interface StoredWorkerMedia extends MediaWorkerAsset { finalizedAt: Date | null stuckCheckedAt: Date | null stuckCheckCount: number + uploadEtag: string | null } export interface RecordedFlag { @@ -64,6 +65,7 @@ export class InMemoryWorkerRepo implements MediaWorkerRepo { finalizedAt: row.finalizedAt ?? null, stuckCheckedAt: row.stuckCheckedAt ?? null, stuckCheckCount: row.stuckCheckCount ?? 0, + uploadEtag: row.uploadEtag ?? null, } this.byId.set(stored.id, stored) return stored @@ -198,6 +200,7 @@ export class InMemoryWorkerRepo implements MediaWorkerRepo { thumbKey: row.thumbKey, kind: row.kind, checkCount: row.stuckCheckCount, + uploadEtag: row.uploadEtag, } }), ) diff --git a/services/media-worker/test/unit/image-lane-budget.test.ts b/services/media-worker/test/unit/image-lane-budget.test.ts new file mode 100644 index 00000000..d9776bcb --- /dev/null +++ b/services/media-worker/test/unit/image-lane-budget.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { writeFile } from "node:fs/promises" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const runToolMock = vi.fn() + +vi.mock("../../src/sandbox/exec.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runTool: (...args: unknown[]) => runToolMock(...args), + } +}) + +const { loadLimits } = await import("../../src/config.js") +const { resetSandboxIdentity } = await import("../../src/sandbox/exec.js") +const { processImageLane } = await import("../../src/sandbox/image-lane.js") +const fx = await import("../fixtures/make.js") + +let saved: NodeJS.ProcessEnv +beforeEach(() => { + saved = { ...process.env } + runToolMock.mockReset() + process.env.MEDIA_SANDBOX_UID = String(process.getuid?.() ?? 1001) + process.env.MEDIA_SANDBOX_GID = String(process.getgid?.() ?? 1001) + process.env.MEDIA_IMAGE_LANE_ENTRY = fileURLToPath(import.meta.url) + resetSandboxIdentity() +}) +afterEach(() => { + for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key] + Object.assign(process.env, saved) + resetSandboxIdentity() +}) + +describe("image lane child budget", () => { + it("lets the child finish all three phases it bounds by imageTimeoutMs (metadata, strip + thumb, phash)", async () => { + const limits = loadLimits({ MEDIA_IMAGE_TIMEOUT_MS: "15000" }) + runToolMock.mockImplementation(async (_name: string, _bin: string, args: string[]) => { + const req = JSON.parse(args[2] as string) as { outDir: string } + await writeFile(join(req.outDir, "stripped.bin"), Buffer.from([1])) + await writeFile(join(req.outDir, "thumb.bin"), Buffer.from([2])) + return { + stdout: JSON.stringify({ + ok: true, + meta: { width: 1, height: 1, format: "png" }, + strippedContentType: "image/png", + thumbnailContentType: "image/jpeg", + exifGps: null, + phash: null, + }), + stderr: "", + stdoutBuffer: Buffer.alloc(0), + exitCode: 0, + } + }) + + await processImageLane(await fx.makeValidPng(), limits) + + const opts = runToolMock.mock.calls[0]![3] as { timeoutMs: number } + expect(opts.timeoutMs).toBeGreaterThanOrEqual(3 * limits.imageTimeoutMs) + expect(opts.timeoutMs).toBeLessThan(limits.jobTimeoutMs) + }) +}) diff --git a/services/media-worker/test/unit/image-lane-limits.test.ts b/services/media-worker/test/unit/image-lane-limits.test.ts new file mode 100644 index 00000000..2eb9170f --- /dev/null +++ b/services/media-worker/test/unit/image-lane-limits.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" +import { loadLimits } from "../../src/config.js" +import { imageLaneTimeoutMs } from "../../src/sandbox/image-lane.js" + +// A lane kill that lands after the job budget turns a slow hostile image into a JobTimeoutError, which +// media.checks retries as infra instead of rejecting the bytes. +describe("loadLimits keeps the image lane inside the job budget", () => { + it("boots with the defaults, whose lane budget sits below the job budget", () => { + const limits = loadLimits({}) + expect(imageLaneTimeoutMs(limits)).toBeLessThan(limits.jobTimeoutMs) + }) + + it("boots with the largest image timeout the default job budget allows", () => { + const limits = loadLimits({ MEDIA_IMAGE_TIMEOUT_MS: "28000" }) + expect(imageLaneTimeoutMs(limits)).toBe(89_000) + }) + + it("refuses to start when the image timeout pushes the lane past the job budget", () => { + expect(() => loadLimits({ MEDIA_IMAGE_TIMEOUT_MS: "30000" })).toThrow( + /media-worker: .*MEDIA_IMAGE_TIMEOUT_MS=30000.* 95000ms .*MEDIA_JOB_TIMEOUT_MS=90000/, + ) + }) + + it("refuses to start when the lane budget equals the job budget", () => { + expect(() => + loadLimits({ MEDIA_IMAGE_TIMEOUT_MS: "15000", MEDIA_JOB_TIMEOUT_MS: "50000" }), + ).toThrow(/MEDIA_JOB_TIMEOUT_MS=50000/) + }) + + it("boots when the job budget is raised along with the image timeout", () => { + const limits = loadLimits({ MEDIA_IMAGE_TIMEOUT_MS: "30000", MEDIA_JOB_TIMEOUT_MS: "100000" }) + expect(limits.imageTimeoutMs).toBe(30_000) + expect(limits.jobTimeoutMs).toBe(100_000) + }) +}) diff --git a/services/media-worker/test/unit/maintenance.test.ts b/services/media-worker/test/unit/maintenance.test.ts index 5db7e7cf..95a27058 100644 --- a/services/media-worker/test/unit/maintenance.test.ts +++ b/services/media-worker/test/unit/maintenance.test.ts @@ -488,6 +488,7 @@ describe("media.stuck.sweep", () => { uploadId: "u1", r2Key: "uploads/s1", kind: "image", + uploadEtag: null, }) expect(enqueued[0]!.opts).toEqual({ singletonKey: "u1" }) }) diff --git a/services/media-worker/test/unit/pg-boss-jobs.test.ts b/services/media-worker/test/unit/pg-boss-jobs.test.ts index a675b16b..235ea339 100644 --- a/services/media-worker/test/unit/pg-boss-jobs.test.ts +++ b/services/media-worker/test/unit/pg-boss-jobs.test.ts @@ -453,7 +453,8 @@ describe("buildJobs seam selection", () => { const handle = buildJobs({ NODE_ENV: "production", DATABASE_URL: "postgres://stub/civfix", - MEDIA_JOB_TIMEOUT_MS: "1000", + MEDIA_JOB_TIMEOUT_MS: "10000", + MEDIA_IMAGE_TIMEOUT_MS: "1000", } as NodeJS.ProcessEnv) expect(handle.jobs).toBeInstanceOf(PgBossWorkerJobs) @@ -461,6 +462,6 @@ describe("buildJobs seam selection", () => { await handle.stop() expect(lastBoss().connectionString).toBe("postgres://stub/civfix") - expect(lastBoss().stopCalls).toEqual([{ graceful: true, wait: true, timeout: 7_000 }]) + expect(lastBoss().stopCalls).toEqual([{ graceful: true, wait: true, timeout: 25_000 }]) }) }) diff --git a/services/media-worker/test/unit/reject-cleanup-logging.test.ts b/services/media-worker/test/unit/reject-cleanup-logging.test.ts new file mode 100644 index 00000000..9c048460 --- /dev/null +++ b/services/media-worker/test/unit/reject-cleanup-logging.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" +import { deleteRejectedObjects, deleteSupersededUpload } from "../../src/jobs/reject-cleanup.js" + +const R2_KEY = "uploads/2026/09/leak" + +function deps() { + const tombstones: { keys: string[] }[] = [] + return { + tombstones, + cleanup: { + repo: { + r2KeyReferencedByOthers: () => Promise.resolve(false), + recordLeakedObjects: (input: { keys: string[] }) => { + tombstones.push(input) + return Promise.resolve() + }, + }, + storage: { delete: () => Promise.reject(new Error("R2 403 AccessDenied")) }, + }, + } +} + +function capture() { + const lines: { line: string; extra?: Record }[] = [] + return { + lines, + log: (line: string, extra?: Record) => lines.push({ line, extra }), + } +} + +describe("storage delete failures keep their cause", () => { + it("logs why the superseded upload could not be deleted before tombstoning it", async () => { + const { tombstones, cleanup } = deps() + const { lines, log } = capture() + + await deleteSupersededUpload({ id: "m1", r2Key: R2_KEY }, cleanup, log, () => {}) + + expect(tombstones).toHaveLength(1) + const cause = lines.find((l) => String(l.extra?.err).includes("AccessDenied")) + expect(cause?.extra).toMatchObject({ mediaId: "m1", key: R2_KEY }) + }) + + it("logs why each rejected-media object could not be deleted", async () => { + const { tombstones, cleanup } = deps() + const { lines, log } = capture() + + await deleteRejectedObjects( + { id: "m2", r2Key: R2_KEY, servedKey: null, thumbKey: null }, + cleanup, + log, + () => {}, + ) + + expect(tombstones[0]!.keys.length).toBeGreaterThan(0) + const causes = lines.filter((l) => String(l.extra?.err).includes("AccessDenied")) + expect(causes.map((l) => l.extra?.key)).toEqual(tombstones[0]!.keys) + }) +}) diff --git a/services/media-worker/test/unit/scratch-infra-classification.test.ts b/services/media-worker/test/unit/scratch-infra-classification.test.ts new file mode 100644 index 00000000..357e3250 --- /dev/null +++ b/services/media-worker/test/unit/scratch-infra-classification.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const lane = vi.hoisted(() => ({ impl: null as null | ((...args: unknown[]) => unknown) })) +const video = vi.hoisted(() => ({ + probe: null as null | ((...args: unknown[]) => unknown), + remux: null as null | ((...args: unknown[]) => unknown), + grab: null as null | ((...args: unknown[]) => unknown), +})) + +vi.mock("../../src/sandbox/image-lane.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + processImageLane: (...args: Parameters) => + lane.impl ? lane.impl(...args) : actual.processImageLane(...args), + } +}) + +vi.mock("../../src/sandbox/ffprobe.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + probeBytes: (...args: Parameters) => + video.probe ? video.probe(...args) : actual.probeBytes(...args), + } +}) + +vi.mock("../../src/sandbox/ffmpeg-remux.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + remuxStripMetadata: (...args: Parameters) => + video.remux ? video.remux(...args) : actual.remuxStripMetadata(...args), + grabFrameJpeg: (...args: Parameters) => + video.grab ? video.grab(...args) : actual.grabFrameJpeg(...args), + } +}) + +const { FakeStorage, FakeAbuseChecks } = await import("@civfix/shared/fakes") +const { loadLimits } = await import("../../src/config.js") +const { resetSandboxIdentity, SandboxSpawnError } = await import("../../src/sandbox/exec.js") +const { makeScratch, ScratchSetupError } = await import("../../src/sandbox/tmp.js") +const { mediaToolPath, resetMediaToolPaths } = await import("../../src/sandbox/binaries.js") +const { processMedia } = await import("../../src/jobs/media-pipeline.js") +const { runMediaChecksJob, MediaInfraError } = await import("../../src/jobs/media-checks.js") +const { InMemoryWorkerRepo } = await import("../helpers/in-memory-repo.js") +const { makeDownloader } = await import("../../src/download.js") +const fx = await import("../fixtures/make.js") + +const limits = loadLimits({}) + +const VALID_PROBE = { + durationSec: 2, + codec: "h264", + width: 640, + height: 360, + fps: 30, + bitrateBps: 1_000_000, + isVideo: true, +} + +let saved: NodeJS.ProcessEnv +let root: string + +beforeEach(async () => { + saved = { ...process.env } + root = await mkdtemp(join(tmpdir(), "civfix-scratch-infra-")) + lane.impl = null + video.probe = null + video.remux = null + video.grab = null + resetSandboxIdentity() + resetMediaToolPaths() +}) + +afterEach(async () => { + for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key] + Object.assign(process.env, saved) + resetSandboxIdentity() + resetMediaToolPaths() + await rm(root, { recursive: true, force: true }) +}) + +function breakScratchRoot(): void { + process.env.TMPDIR = join(root, "does-not-exist") +} + +function withSandboxIdentity(): void { + process.env.MEDIA_SANDBOX_UID = String(process.getuid?.() ?? 1001) + process.env.MEDIA_SANDBOX_GID = String(process.getgid?.() ?? 1001) + process.env.MEDIA_IMAGE_LANE_ENTRY = fileURLToPath(import.meta.url) + resetSandboxIdentity() +} + +function scratchInfraError(): Error { + return new ScratchSetupError( + "could not prepare the sandbox scratch dir", + Object.assign(new Error("ENOSPC: no space left on device, write"), { + code: "ENOSPC", + syscall: "write", + }), + ) +} + +const deps = () => ({ abuseChecks: new FakeAbuseChecks(), limits }) + +describe("scratch setup failures are infrastructure, not a verdict on the bytes", () => { + it("makeScratch reports an unusable scratch root as ScratchSetupError", async () => { + breakScratchRoot() + + const err = await makeScratch(new Uint8Array([1]), "bin").catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ScratchSetupError) + expect(String(err)).toMatch(/ENOENT/) + }) + + it("an image whose sandbox scratch cannot be created escapes processMedia instead of being rejected", async () => { + withSandboxIdentity() + breakScratchRoot() + + await expect( + processMedia({ bytes: await fx.makeValidPng(), kind: "image" }, deps()), + ).rejects.toBeInstanceOf(ScratchSetupError) + }) + + it("a video whose probe scratch cannot be created escapes processMedia instead of being rejected", async () => { + breakScratchRoot() + + await expect( + processMedia({ bytes: Buffer.from("not inspected"), kind: "video" }, deps()), + ).rejects.toBeInstanceOf(ScratchSetupError) + }) + + it("a scratch failure in the remux step escapes instead of rejecting the video", async () => { + video.probe = () => Promise.resolve(VALID_PROBE) + video.remux = () => Promise.reject(scratchInfraError()) + + await expect( + processMedia({ bytes: Buffer.from("video"), kind: "video" }, deps()), + ).rejects.toBeInstanceOf(ScratchSetupError) + }) + + it("a scratch failure in the frame grab escapes instead of holding the video as unscorable", async () => { + video.probe = () => Promise.resolve(VALID_PROBE) + video.remux = () => Promise.resolve(Buffer.from("remuxed")) + video.grab = () => Promise.reject(scratchInfraError()) + + await expect( + processMedia({ bytes: Buffer.from("video"), kind: "video" }, deps()), + ).rejects.toBeInstanceOf(ScratchSetupError) + }) + + it("a scratch failure in the video thumbnail lane escapes instead of dropping the thumbnail", async () => { + video.probe = () => Promise.resolve(VALID_PROBE) + video.remux = () => Promise.resolve(Buffer.from("remuxed")) + video.grab = () => Promise.resolve(Buffer.from("frame")) + lane.impl = () => Promise.reject(scratchInfraError()) + + await expect( + processMedia({ bytes: Buffer.from("video"), kind: "video" }, deps()), + ).rejects.toBeInstanceOf(ScratchSetupError) + }) + + it("a media tool that cannot be resolved is a spawn failure, not a rejection", async () => { + process.env.NODE_ENV = "production" + delete process.env.FFPROBE_PATH + delete process.env.FFMPEG_PATH + + await expect(mediaToolPath("ffprobe")).rejects.toBeInstanceOf(SandboxSpawnError) + }) + + it("media.checks retries a scratch failure and keeps the asset validating with its upload intact", async () => { + lane.impl = () => Promise.reject(scratchInfraError()) + const storage = new FakeStorage() + const repo = new InMemoryWorkerRepo() + const r2Key = "uploads/2026/09/scratch" + repo.seed({ id: "m1", uploadId: "u1", kind: "image", r2Key }) + await storage.put(r2Key, Buffer.from(await fx.makeValidPng()), { contentType: "image/png" }) + + await expect( + runMediaChecksJob( + { mediaId: "m1", uploadId: "u1", r2Key, kind: "image" }, + { + repo, + storage, + abuseChecks: new FakeAbuseChecks(), + limits, + download: makeDownloader(storage), + report: () => {}, + log: () => {}, + }, + ), + ).rejects.toBeInstanceOf(MediaInfraError) + + expect(repo.get("m1")!.status).toBe("validating") + expect(storage.get(r2Key)).not.toBeNull() + }) +}) diff --git a/services/media-worker/test/unit/seams-abuse-log.test.ts b/services/media-worker/test/unit/seams-abuse-log.test.ts new file mode 100644 index 00000000..0b748a96 --- /dev/null +++ b/services/media-worker/test/unit/seams-abuse-log.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { buildSeams } from "../../src/seams.js" + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("media-worker abuse-checks seam logging", () => { + it("routes the real abuse checks' log lines to the worker's injected logger, not console", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const lines: { line: string; extra?: Record }[] = [] + const seams = await buildSeams( + { NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "0" }, + { log: (line, extra) => lines.push({ line, ...(extra ? { extra } : {}) }) }, + ) + try { + await expect(seams.abuseChecks.nsfwScore(new Uint8Array([1, 2, 3]))).resolves.toBe(0) + } finally { + await seams.close() + } + + expect(lines.map((l) => l.line)).toEqual([expect.stringMatching(/NSFW model not configured/)]) + expect(consoleWarn).not.toHaveBeenCalled() + }) +}) diff --git a/services/media-worker/test/unit/stuck-sweep-etag.test.ts b/services/media-worker/test/unit/stuck-sweep-etag.test.ts new file mode 100644 index 00000000..8e7515f1 --- /dev/null +++ b/services/media-worker/test/unit/stuck-sweep-etag.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest" +import { FakeStorage } from "@civfix/shared/fakes" +import { MEDIA_CHECKS_JOB } from "@civfix/api/media-repo" +import { loadLimits } from "../../src/config.js" +import { parsePayload } from "../../src/jobs/media-checks.js" +import { runStuckSweep } from "../../src/jobs/stuck-sweep.js" +import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" + +const limits = loadLimits({}) +const NOW = new Date("2026-06-01T12:00:00Z") +const STUCK_SINCE = new Date(NOW.getTime() - limits.stuckMediaTtlMs - 60_000) +const FINALIZED_ETAG = "5d41402abc4b2a76b9719d911017c592" + +async function sweepOnce(repo: InMemoryWorkerRepo): Promise { + const payloads: unknown[] = [] + await runStuckSweep({ + repo, + jobs: { + enqueue: (name: string, data: unknown) => { + if (name === MEDIA_CHECKS_JOB) payloads.push(data) + return Promise.resolve("job-id") + }, + }, + storage: new FakeStorage(), + limits, + now: () => NOW, + log: () => {}, + }) + return payloads +} + +function seedStuck(repo: InMemoryWorkerRepo, uploadEtag: string | null): void { + repo.now = () => NOW + repo.seed({ + id: "stuck-1", + uploadId: "u1", + kind: "image", + r2Key: "uploads/s1", + status: "validating", + createdAt: STUCK_SINCE, + finalizedAt: STUCK_SINCE, + uploadEtag, + }) +} + +describe("media.stuck.sweep keeps the finalize-time overwrite check", () => { + it("requeues media.checks with the upload etag recorded at finalize", async () => { + const repo = new InMemoryWorkerRepo() + seedStuck(repo, FINALIZED_ETAG) + + const payloads = await sweepOnce(repo) + + expect(payloads).toEqual([ + { + mediaId: "stuck-1", + uploadId: "u1", + r2Key: "uploads/s1", + kind: "image", + uploadEtag: FINALIZED_ETAG, + }, + ]) + expect(parsePayload(payloads[0])?.uploadEtag).toBe(FINALIZED_ETAG) + }) + + it("a row finalized before the etag was stored requeues with no etag, as before", async () => { + const repo = new InMemoryWorkerRepo() + seedStuck(repo, null) + + const payloads = await sweepOnce(repo) + + expect(payloads).toHaveLength(1) + expect(parsePayload(payloads[0])?.uploadEtag).toBeNull() + }) +})