diff --git a/docs/location-coarsening-assessment.md b/docs/location-coarsening-assessment.md index 08aa8fa3..4707020f 100644 --- a/docs/location-coarsening-assessment.md +++ b/docs/location-coarsening-assessment.md @@ -22,6 +22,24 @@ The owner's own detail/list (`mine: true`) and the create-time idempotency snapshot also carry full precision; routing to a jurisdiction uses the precise `geom`. +**A second stored copy exists as of migration 0176 (issue #100).** `posts.geom` +denormalises the linked report's or event's point onto the post row so the +ranked home feed's proximity pool is one bounded KNN scan. It is NOT a new +class of data and NOT a new exposure: the value is copied server-side from a +coordinate this platform already publishes at full precision on the linked +report or event, it is never populated from a client-supplied coordinate, and +it is never projected into any DTO — it only orders the feed, and the feed +emits `PostDTO`, which carries no post-level coordinate at all. The only +derived value that leaves the server is the ranking score. + +Consequence for any future coarsening decision: rounding the projected +`lat`/`lng` would NOT cover `posts.geom`, which would keep ordering the feed at +full precision. That is almost certainly the desired behaviour (proximity +ranking is the point), but it must be a stated decision rather than an +oversight, and a policy that requires coarsening at REST — not just in +transit — has to cover this column, `reports.geom`, `cleanups.geom` and +`users.last_activity_geom` together. + ## Is a backend-only coarsening possible without a shared-contract change? **Technically yes.** `ReportDTO.lat/lng` and `ReportPinDTO.lat/lng` are diff --git a/docs/out-of-band-indexes.md b/docs/out-of-band-indexes.md index 8e9c88ba..f9db8263 100644 --- a/docs/out-of-band-indexes.md +++ b/docs/out-of-band-indexes.md @@ -104,6 +104,71 @@ statement through the exported `explainSuggestFollows`. The offline half — that the emitted SQL really is a `CROSS JOIN LATERAL` and not a same-level cross join — is `test/unit/social-suggest-sql.test.ts`. +### `posts.geom` backfill (migration 0176, issue #100) — data, not an index + +`posts` is NOT a hot table, so `posts_geom_gist` and +`posts_author_public_recent_idx` are built inline by migrations 0176 and 0177 +and need nothing here. What DOES need an out-of-band run is the **backfill**: +0176 adds the column but deliberately populates no rows, because one `UPDATE` +over the whole table inside the migration's single transaction is a lock +hazard. The migration RAISEs a `WARNING` when any backfillable post is still +unpopulated. + +Run it once the deploy is healthy — keyset-paged, idempotent, safe to re-run +and safe while the API serves traffic: + +```sh +sudo -n docker exec compose-api-1 node dist/db/backfill-post-geom.js +``` + +Nothing breaks without it: a `NULL` `posts.geom` yields a `NULL` `distance_km`, +the ranker simply scores no proximity term for that post, and the in-network +and recent-public pools still fill the feed. Only the *nearby* pool is degraded. + +Verify: + +```sql +SELECT count(*) + FROM posts p + LEFT JOIN reports r ON r.id = p.report_id + LEFT JOIN cleanups c ON c.id = p.event_id + WHERE p.geom IS NULL AND COALESCE(r.geom, c.geom) IS NOT NULL; +``` + +must return `0`. (The `COALESCE(...) IS NOT NULL` term matters: a post linked to +a row whose own `geom` is null is not backfillable, and counting it would make +this check permanently unsatisfiable. The migration's `DO` block uses the same +predicate.) + +**`posts.geom` is an insert-time snapshot, not a live mirror.** It is written +once by `createPost` and never updated: there is no trigger and no relocation +hook, and the backfill only touches rows where `geom IS NULL`. If a report or +cleanup is later moved to a new coordinate, every post already linked to it +keeps ranking against the OLD point indefinitely. That is acceptable for feed +proximity (the post was about the place as it was), but it is a deliberate +property, not an oversight — if live tracking is ever wanted, the relocation +paths must update the derived posts explicitly. + +If `posts` has grown large enough that an inline `CREATE INDEX` would be +disruptive, build both indexes with `CONCURRENTLY` BEFORE deploying — the +migrations' `IF NOT EXISTS` guards then no-op: + +```sql +CREATE INDEX CONCURRENTLY IF NOT EXISTS posts_geom_gist + ON posts USING gist (geom) + WHERE geom IS NOT NULL AND deleted_at IS NULL AND reply_to_id IS NULL + AND visibility = 'public'; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS posts_author_public_recent_idx + ON posts (author_id, created_at DESC, id DESC) + WHERE deleted_at IS NULL AND reply_to_id IS NULL AND visibility = 'public'; +``` + +The nearby pool's plan is asserted by +`test/integration/feed-ranked-pg.test.ts`, which `EXPLAIN`s the real statement +through the exported `explainFeedCandidates` and requires +`posts_geom_gist` with no `Seq Scan on posts`. + ### `media_assets_orphan_sweep_idx` (migration 0098, audit H13) Back the hourly orphan sweep's candidate scan (`findOrphans` /`deleteOrphan` in diff --git a/docs/retention-cleanup.md b/docs/retention-cleanup.md index e9b98042..8ccd86c6 100644 --- a/docs/retention-cleanup.md +++ b/docs/retention-cleanup.md @@ -12,7 +12,8 @@ previously accumulated forever (no existing path deleted them). A `retention.sweep` cron in the **media-worker** (the same process that runs `orphan.sweep` and `chat.partition.maintenance`) deletes expired rows from the tables below. Every lane but `inbound_emails` deletes already-expired rows only -and needs no schema of its own. +and needs no schema of its own (`geocode_cache` brings its own index, created +with the table). | Table | Rows deleted | Source schema | |---|---|---| @@ -21,6 +22,7 @@ and needs no schema of its own. | `sessions` | `expires_at < cutoff` | `services/api/src/db/schema/sessions.ts` | | `idempotency_keys` | `created_at < now - 48h` (`RETENTION_IDEMPOTENCY_MS`) | `services/api/src/db/schema/idempotency.ts` | | `notifications` | `created_at < now - 90d` (see F088 below) | `services/api/src/db/schema/notifications.ts` | +| `geocode_cache` | `resolved_at < now - 180d` (`GEOCODE_CACHE_TTL_MS`, see below) | `services/api/src/db/schema/geocode_cache.ts` | | `inbound_emails` | `archived_at < now - 180d` (see H10 below) | `services/api/src/db/schema/inbound_emails.ts` | `cutoff = now - grace`, where `grace` defaults to **1 hour** past expiry (so a @@ -141,6 +143,34 @@ lane (`src/routes/chat-gateway-wiring.ts`) is the remaining step. --- +## `geocode_cache` — bounding the reverse-geocode cache (0179) + +`geocode_cache` (migration `0179_address_resolution.sql`) is written by the +address ladder on every resolve, and the write path is reachable by an +UNAUTHENTICATED caller (`POST /map/resolve-address`, 30/min/IP). Its TTLs are +applied on READ — an expired row is served as a miss and OVERWRITTEN in place — +so nothing in `services/api/src/services/geocode-cache.ts` ever deletes a row, +and a point that is resolved once and never visited again stays forever. Rows are +tiny and derived (a public coordinate → a public address line, no user, report or +event reference, outside the erasure lane by construction), but "tiny × forever" +is still unbounded, so the sweep owns the deletion side. + +| What | Value | +|---|---| +| Table | `geocode_cache` | +| Predicate | `resolved_at < now() - 180 days` (`GEOCODE_CACHE_TTL_MS`, the POSITIVE TTL — imported from `@civfix/api/geocode-cache` so the two can never drift) | +| Index | `geocode_cache_resolved_at_idx` (created by 0179) | +| Cron | the existing `retention.sweep` (`37 4 * * *`, daily) | +| Batching | `DELETE … WHERE point_key IN (SELECT point_key … LIMIT n)`, drained page-wise like the other lanes | +| Tuning | `runRetentionSweep({ geocodeCacheRetentionMs })` | + +The cutoff is the POSITIVE TTL deliberately, even though a non-chain row (a chain +miss) stops being SERVED after 15 minutes: once past 180 days a row cannot be +served on any path, so deleting it loses nothing, and one cutoff keeps the lane a +single index scan. + +--- + ## F106 — `inbound/failed/` poison-message store (adminmail-a) The inbound-mail processor parks parse-poisoned or over-cap `.eml` objects under 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 e40cfa65..a51a5ae5 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 **158 files**, `0000_extensions.sql` … `0175_forward_template_settings.sql`. The nine rows +`drizzle/` holds **162 files**, `0000_extensions.sql` … `0179_address_resolution.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 @@ -563,6 +563,14 @@ foreign key into `organizations` from `0105`. | `0174_mail_messages_outbound_snapshot.sql` | adds `mail_messages.html` and `mail_messages.kind`, both nullable `text`, with `ADD COLUMN IF NOT EXISTS` and nothing else: no CHECK, no index, no backfill, so it is catalog-only on a cold table and takes no scan. `html` stores the exact HTML part handed to the mailer so a resend replays the message the city actually received instead of a bare-text, 64KB-truncated copy; `kind` records what an outbound row is (`packet` | `discussion` | `followup` | `digest` | `compose` | `reply` | `resend`) and is NULL on every inbound row and on every row written before this file. No CHECK is deliberate and matches the other free-text discriminators on this table: a value the code stops writing must never turn an old row un-readable. The operator send gate reads `COALESCE(kind, 'packet') = 'packet'`: a NULL kind — every row written before this file — counts AS a packet, deliberately, so every report routed before the deploy keeps its "already routed" gate, while a citizen's `@city` discussion forward (written as `discussion`) stops counting as one | Routing a report 500s on `column "html" does not exist` — the outbound INSERT names both columns unconditionally — so Approve & send, operator replies, resends and the outreach digest all fail; the packet gate itself still refuses correctly, because a missing `kind` column errors before it can misread | | `0175_forward_template_settings.sql` | new `forward_template_settings`, the singleton row (`id smallint PRIMARY KEY CHECK (id = 1)`) holding the platform-wide default forwarding email template. It sits between a jurisdiction's own `forward_*_template` (0050) and the built-in default in `@civfix/shared`, so a packet resolves jurisdiction → this row → built-in. Both template columns are nullable (NULL = fall through) and `updated_by` is deliberately not a FK to `users`, since the authoritative trail is the `mail.forward_template_set` audit row written in the same transaction. `CREATE TABLE IF NOT EXISTS` on a table with no rows: no lock of consequence | The operator Mail page 500s on its default-template read and save (`relation "forward_template_settings" does not exist`), and every report forwarded to a jurisdiction without its own template 500s too, because the packet builder reads the row before it renders | +| `0176_posts_geom.sql` | adds `posts.geom` (nullable `geometry(Point,4326)`, no default, so a catalog-only ADD COLUMN with no table rewrite) plus the partial GIST `posts_geom_gist` over live public rows, denormalising the linked report's or event's point onto the post so the ranked home feed's proximity pool is one bounded KNN scan instead of two joins. `posts` is NOT on the hot-table list in `docs/out-of-band-indexes.md`, so the index builds inline; build it with `CREATE INDEX CONCURRENTLY` first if `posts` has grown, and the `IF NOT EXISTS` guard makes the migration a no-op. No backfill in the file - a single UPDATE over the whole table inside one transaction is a lock hazard - so a DO block RAISEs a WARNING while attached rows are still unpopulated and `node dist/db/backfill-post-geom.js` (keyset-paged, idempotent, safe under live traffic) fills them after the deploy is healthy. PRIVACY: no new class of data, a copy of a coordinate already published at full precision on the linked report or event, never client-supplied, never served to clients | Nothing breaks: `p.geom IS NULL` simply yields a NULL `distance_km`, the ranker scores no proximity term, and the feed falls back to the in-network and recent-public pools. Skipping only the BACKFILL means existing attached posts score no proximity until it is run | + +| `0177_posts_author_public_recent_idx.sql` | adds the partial index `posts_author_public_recent_idx (author_id, created_at DESC, id DESC) WHERE deleted_at IS NULL AND reply_to_id IS NULL AND visibility = 'public'` - the author-keyed twin of `0071_posts_toplevel_recent_idx`, backing the ranked feed's in-network candidate pool (self + followees). Neither `posts_author_created_idx` (0051, not partial on reply_to_id/visibility, no id tiebreak) nor `posts_toplevel_recent_idx` (0071, not author-keyed) serves that shape. Same not-a-hot-table reasoning as 0176 | The in-network pool sorts instead of stopping at its LIMIT. Correct, slower; no functional change | + +| `0178_event_announcements.sql` | widens `broadcasts_kind_check` to accept the new `announcement` kind (event announcements ride the existing host-broadcast pipeline rather than a parallel table), adds the partial `broadcasts_announcement_public_idx (cleanup_id, created_at DESC, id DESC) WHERE kind = 'announcement'` that backs the public per-event announcements list, and re-creates `broadcasts_scrub_idx` with `kind <> 'announcement'` so the retention scrub can never NULL an announcement body - an announcement is permanent public event content rendered on the event page, not a one-shot email. `broadcasts` is NOT on the hot-table list in `docs/out-of-band-indexes.md`, so both indexes build inline; `IF NOT EXISTS` makes an out-of-band CONCURRENTLY build a no-op | Every `createEventAnnouncement` fails on the CHECK constraint. Applying the CHECK but not the scrub-index swap still works (the repository query carries the same predicate), it just scans more rows | +| `0179_address_resolution.sql` | the address-resolution overhaul: creates `geocode_cache` (a read-through cache of reverse geocodes keyed by the shared 5-decimal point key, with its `geocode_cache_resolved_at_idx`; derived public data only, no user/report/event reference, TTL applied on read rather than by a cron), adds `cleanups.address_source` (resolved | edited | manual) and backfills it to `manual` for every event that already has an address, and adds `reports.addr_source` + `reports.addr_precision`. All three CHECK constraints are `NOT VALID` (new columns, so they hold by construction) and are listed in the outstanding-VALIDATE set below. No new index on a hot table: both `reports` columns are plain row columns | `POST /map/resolve-address` and every create path still answer, but uncached (the cache swallows its own errors) and with no provenance persisted; a new client's event create fails on the missing `cleanups.address_source` column | + + **Deferred to the NEXT release** (expand/contract, `docs/migrations-expand-contract.md`): 0.43.0 stops every code path from reading `user_verification` but does NOT drop it — a `DROP TABLE` in the same release would make the previous api color raise `relation does not exist` on the feed, the @@ -590,9 +598,10 @@ either way — `org_verifications` documents are claimed with it. **Deferred to a later release, out of band** (record them in the expand/contract ledger): the six `NOT VALID` CHECKs `0107` adds on `cleanups` and `0110`'s `media_assets_purpose_expanded` still need `ALTER TABLE … VALIDATE CONSTRAINT`, and so do `0164`'s -`users_primary_organization_fk` and `posts_organization_fk`. They are correct as `NOT VALID` — the -constraint is enforced for every new row — and validating takes a scan that does not belong in a -deploy transaction. +`users_primary_organization_fk` and `posts_organization_fk`, and `0179`'s +`cleanups_address_source_chk`, `reports_addr_source_chk` and `reports_addr_precision_chk`. They are +correct as `NOT VALID` — the constraint is enforced for every new row — and validating takes a scan +that does not belong in a deploy transaction. | `0096_cleanup_guests.sql` | guest event RSVP (contract 0.38.0): `cleanup_guests` (event-scoped, contact-bearing attendance rows; SHA-256 manage-token hash only; partial unique on the active `(cleanup_id, contact_key)`), `guest_otps` (event-scoped one-time codes, hash only), `sms_opt_outs` (STOP suppression list, kept indefinitely). Three brand-new empty tables; no existing table touched | `POST /v1/cleanups/:id/guest-rsvp/*` and `GET /v1/cleanups/:id/guests` 500 on a missing relation, and `going` cannot include guests | | `0097_media_assets_served_key.sql` | adds `media_assets.served_key` (audit C1): the worker-owned key the processed object is published to, so the client-writable upload key is never served after `ready`. Catalog-only nullable `ADD COLUMN` on a hot table. **Post-deploy:** run `node dist/db/backfill-served-key.js` immediately and again ~20 min later; pre-existing `ready` media reads as not-found until it completes | Every existing photo 404s until the backfill runs; new uploads work | | `0098_sweep_predicate_indexes.sql` | idempotent guard that warns while `media_assets_orphan_sweep_idx` is missing (hot table; build it out of band with `CREATE INDEX CONCURRENTLY`, see `docs/out-of-band-indexes.md`) plus two small inline `email_otps` indexes (`expires_at`, partial `consumed_at`) backing the OTP retention lane (audit H13/M) | Both sweeps sequential-scan; correctness unaffected | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60ea3f27..389e1231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,8 @@ importers: specifier: ^3.700.0 version: 3.1057.0 '@civfix/shared': - specifier: ^0.53.0 - version: 0.53.0 + specifier: ^0.55.0 + version: 0.55.0 '@fastify/cookie': specifier: ^11.0.1 version: 11.0.2 @@ -210,8 +210,8 @@ importers: specifier: workspace:* version: link:../api '@civfix/shared': - specifier: ^0.53.0 - version: 0.53.0 + specifier: ^0.55.0 + version: 0.55.0 '@sentry/node': specifier: ^10.73.0 version: 10.73.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) @@ -414,8 +414,8 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} - '@civfix/shared@0.53.0': - resolution: {integrity: sha512-TvY+VHFkg3G7GNNNjhk9kwSrvXVsEDq9NAUbNohmN2X29JAC2uLaUS3FfIJIeew6Qc710+Zr7Ik2+C7Cbs0YkQ==} + '@civfix/shared@0.55.0': + resolution: {integrity: sha512-f24XyngBWMV4L7ipARvX8oATSObFKkrEvCBQxJDobbpenHduZ3LkU6sC5uZfN/ZrsySiH0xcInvxgit966S3gA==} engines: {node: '>=20'} '@derhuerst/http-basic@8.2.4': @@ -4440,7 +4440,7 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@civfix/shared@0.53.0': + '@civfix/shared@0.55.0': dependencies: zod: 3.25.76 diff --git a/services/api/.env.example b/services/api/.env.example index f84cb94f..922043c2 100644 --- a/services/api/.env.example +++ b/services/api/.env.example @@ -99,6 +99,24 @@ HOME_REGION_LAT= # [OPT] home-region latitude, -90..90 HOME_REGION_LNG= # [OPT] home-region longitude, -180..180 (default -118.2437) HOME_REGION_RADIUS_KM= # [OPT] home-region accuracy radius in km, > 0 (default 40) +# ===== ranked home feed (GET /feed/home) ===== +# FEED_RANKING is a JSON object of ranking overrides, validated at boot against the contract's +# FeedRankingConfigSchema (@civfix/shared). EVERY key is optional and defaulted, so leaving this +# blank yields the full default profile below - nothing breaks when it is unset. The schema is +# .strict(): a typo'd key or an out-of-range value is a NAMED boot failure, never a silently +# ignored knob. Override only what you are tuning, e.g. +# FEED_RANKING={"halfLifeHours":12,"followWeight":70} +# Changing a weight is a redeploy (edit the SOPS dotenv, merge to main for staging), never a +# client release. +# +# Defaults: baseWeight 10, followWeight 100, selfWeight 60, mentionWeight 40, nearbyWeight 50, +# nearbyRadiusKm 40, orgVerifiedWeight 30, attachEventWeight 30, attachReportWeight 30, +# imageWeight 5, likeWeight 3, replyWeight 8, repostWeight 5, halfLifeHours 36, decayFloor 0.15, +# diversityFloor 0.25, diversityDecay 0.5, seenDiscount 0.7, minScore 12, minPageItems 5, +# candidateWindowDays 30, candidateCap 400, clockBucketSeconds 60, snapshotTtlSeconds 180, +# servedTtlSeconds 900, viewerFanoutMax 500, newPostFanoutMax 1000 +FEED_RANKING= # [OPT] JSON feed-ranking overrides (default: the profile above) + # ===== mailer: OCI SMTP (bypassed by USE_FAKE_MAILER) ===== OCI_EMAIL_SMTP_HOST= # [BOOT] unless USE_FAKE_MAILER - OCI Email Delivery SMTP host OCI_EMAIL_SMTP_PORT=587 # [BOOT] unless USE_FAKE_MAILER - SMTP port (default 587) @@ -207,6 +225,11 @@ VAPID_PUBLIC_KEY= # [OPT] VAPID public key VAPID_PRIVATE_KEY= # [OPT] VAPID private key VAPID_SUBJECT= # [OPT] VAPID subject (mailto: or https URL) +# ===== push: Expo (bypassed by USE_FAKE_PUSH) [OPT] ===== +# Only needed when the Expo project has Enhanced Push Security enabled; without it Expo rejects +# every send from this server with 401/"Unauthorized" and iOS/Android pushes silently stop. +EXPO_ACCESS_TOKEN= # [OPT] Expo access token; required only with Enhanced Push Security + # ===== error reporting: GlitchTip [OPT] ===== GLITCHTIP_DSN= # [OPT] Sentry-compatible DSN; reporting is no-op if absent GLITCHTIP_DATABASE_URL= # [OPT] separate DB URL for a self-hosted GlitchTip diff --git a/services/api/drizzle/0176_posts_geom.sql b/services/api/drizzle/0176_posts_geom.sql new file mode 100644 index 00000000..abd66cae --- /dev/null +++ b/services/api/drizzle/0176_posts_geom.sql @@ -0,0 +1,64 @@ +-- ============================================================================= +-- 0176_posts_geom.sql +-- ----------------------------------------------------------------------------- +-- ISSUE #100 (ranked home feed): the ranker scores a "nearby" term, but `posts` +-- carried no geometry at all. A post's only location was indirect — through +-- posts.report_id -> reports.geom or posts.event_id -> cleanups.geom — so a +-- proximity pool would need two joins and could not use a KNN order at all. +-- +-- This denormalises that point onto the post row so the nearby candidate pool +-- is ONE bounded GIST/KNN scan (see feedCandidates in +-- services/post-repository.drizzle.ts). NULL for a post with no attachment. +-- +-- NOT A HOT TABLE: `posts` is absent from the hot-table list in +-- docs/out-of-band-indexes.md (users, reports, chat_messages, dm_messages, +-- media_assets, notifications, sessions), so the partial GIST is built inline +-- here rather than out of band. If `posts` has grown large by the time this +-- deploys, build it with CREATE INDEX CONCURRENTLY first — the IF NOT EXISTS +-- guard below then turns this statement into a no-op. +-- +-- NO BACKFILL HERE: one UPDATE over the whole table inside this file's single +-- transaction is a lock hazard. Existing rows are populated after the deploy is +-- healthy by `pnpm db:backfill:post-geom` (src/db/backfill-post-geom.ts), which +-- is keyset-paged, idempotent and safe to run while the API serves traffic. The +-- DO block raises a WARNING while rows remain unpopulated so a forgotten +-- backfill is loud instead of silent; the feed is correct without it, those +-- posts simply score no proximity term. +-- +-- PRIVACY: no new class of data. The value is a copy of a coordinate this +-- platform already publishes at full precision on the linked report or event +-- DTO. It is never populated from a client-supplied coordinate, carries no +-- EXIF, and is never returned to any client — it only orders the feed. Any +-- future location-coarsening decision must cover this column too +-- (docs/location-coarsening-assessment.md). +-- +-- CANONICAL DDL: hand-authored source of truth. Mirror: schema/posts.ts. +-- +-- Conventions: additive ADD COLUMN IF NOT EXISTS; one concern per file; one +-- transaction per file. Forward-only, no down. +-- +-- Ordering rules: requires 0051_social_posts.sql (posts). +-- ============================================================================= + +ALTER TABLE posts ADD COLUMN IF NOT EXISTS geom geometry(Point, 4326); + +COMMENT ON COLUMN posts.geom IS + 'Denormalised from the linked report or event at insert time; NULL for a post with no attachment. Feed-ranking proximity only - never a user-supplied coordinate, never served to clients. Backfill: pnpm db:backfill:post-geom.'; + +CREATE INDEX IF NOT EXISTS posts_geom_gist + ON posts USING gist (geom) + WHERE geom IS NOT NULL AND deleted_at IS NULL AND reply_to_id IS NULL AND visibility = 'public'; + +DO $$ +BEGIN + PERFORM 1 + FROM posts p + LEFT JOIN reports r ON r.id = p.report_id + LEFT JOIN cleanups c ON c.id = p.event_id + WHERE p.geom IS NULL AND COALESCE(r.geom, c.geom) IS NOT NULL + LIMIT 1; + + IF FOUND THEN + RAISE WARNING 'posts.geom is unpopulated for at least one attached post - run pnpm db:backfill:post-geom after this deploy is healthy; those posts score no feed proximity term until then'; + END IF; +END $$; diff --git a/services/api/drizzle/0177_posts_author_public_recent_idx.sql b/services/api/drizzle/0177_posts_author_public_recent_idx.sql new file mode 100644 index 00000000..e996a4bc --- /dev/null +++ b/services/api/drizzle/0177_posts_author_public_recent_idx.sql @@ -0,0 +1,33 @@ +-- ============================================================================= +-- 0177_posts_author_public_recent_idx.sql +-- ----------------------------------------------------------------------------- +-- ISSUE #100 (ranked home feed): the ranked feed's in-network candidate pool +-- selects top-level PUBLIC posts by the viewer plus everyone the viewer +-- follows, newest-first, bounded by LIMIT. +-- +-- Neither existing index serves that shape: +-- posts_author_created_idx (0051) - author + created_at, but not partial on +-- reply_to_id / visibility, and no id tiebreak +-- posts_toplevel_recent_idx (0071) - the right ordering, but not author-keyed, +-- so an author-scoped pool scans it whole +-- +-- This is the author-keyed twin of 0071: the same live top-level keyset, +-- narrowed to public rows and led by author_id so the IN (followees) probe stops +-- at the pool LIMIT instead of sorting. +-- +-- NOT A HOT TABLE: `posts` is absent from the hot-table list in +-- docs/out-of-band-indexes.md, so this builds inline. If `posts` has grown large +-- by the time this deploys, build it with CREATE INDEX CONCURRENTLY first and +-- the IF NOT EXISTS guard turns this into a no-op. +-- +-- CANONICAL DDL: hand-authored source of truth. Mirror: schema/posts.ts. +-- +-- Conventions: one concern per file; one transaction per file. Forward-only, +-- no down. +-- +-- Ordering rules: requires 0051_social_posts.sql. +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS posts_author_public_recent_idx + ON posts (author_id, created_at DESC, id DESC) + WHERE deleted_at IS NULL AND reply_to_id IS NULL AND visibility = 'public'; diff --git a/services/api/drizzle/0178_event_announcements.sql b/services/api/drizzle/0178_event_announcements.sql new file mode 100644 index 00000000..4b6cb791 --- /dev/null +++ b/services/api/drizzle/0178_event_announcements.sql @@ -0,0 +1,58 @@ +-- ============================================================================= +-- 0178_event_announcements.sql +-- ----------------------------------------------------------------------------- +-- Event announcements ride the existing host-broadcast pipeline as a new +-- broadcasts.kind rather than a parallel table: segment resolution, delivery +-- fan-out, suppression, unsubscribes and counting already exist and are tested. +-- +-- Three concerns, all on `broadcasts`: +-- +-- 1. kind CHECK widening. `broadcasts_kind_check` (0130) enumerates the seven +-- kinds that existed then; 'announcement' has to join them or every insert +-- fails. Dropped and recreated because a CHECK cannot be widened in place. +-- +-- 2. Public listing index. listEventAnnouncements reads an event's sent +-- announcements newest-first with a keyset cursor. The existing +-- broadcasts_cleanup_created_idx has the right ordering but spans every +-- kind, so an event whose hosts also send reminders/confirmations scans +-- rows the announcements list can never return. This partial twin is +-- keyed the same way and carries only announcement rows. +-- +-- 3. Scrub exemption. The retention lane +-- (broadcast-repository.scrubBroadcastContent) NULLs subject/body_md on +-- finished broadcasts past the cutoff -- correct for a one-shot email, +-- wrong for an announcement, which is permanent PUBLIC event content +-- rendered on the event page forever. The scrub's driving index gets the +-- matching `kind <> 'announcement'` predicate so exempt rows are not even +-- candidates, and the repository query carries the same predicate. +-- +-- NOT A HOT TABLE: `broadcasts` is absent from the hot-table list in +-- docs/out-of-band-indexes.md, so these build inline. IF NOT EXISTS keeps the +-- file a no-op if an index was built out of band with CONCURRENTLY first. +-- +-- CANONICAL DDL: hand-authored source of truth. Mirror: schema/broadcasts.ts. +-- Enum mirrors: schema/types-broadcast.ts (BROADCAST_KIND_VALUES) and shared +-- BroadcastKindSchema -- 'announcement' is APPENDED LAST in all three. +-- +-- Conventions: one concern per file; one transaction per file. Forward-only, +-- no down. +-- +-- Ordering rules: requires 0130_broadcasts.sql. +-- LOCK ORDER: broadcasts +-- ============================================================================= + +ALTER TABLE broadcasts DROP CONSTRAINT IF EXISTS broadcasts_kind_check; + +ALTER TABLE broadcasts ADD CONSTRAINT broadcasts_kind_check CHECK (kind IN ( + 'host_broadcast','confirmation','waitlist_promoted','reminder', + 'event_updated','event_cancelled','thank_you','announcement')); + +CREATE INDEX IF NOT EXISTS broadcasts_announcement_public_idx + ON broadcasts (cleanup_id, created_at DESC, id DESC) + WHERE kind = 'announcement'; + +DROP INDEX IF EXISTS broadcasts_scrub_idx; + +CREATE INDEX IF NOT EXISTS broadcasts_scrub_idx + ON broadcasts (finished_at) + WHERE content_scrubbed_at IS NULL AND body_md IS NOT NULL AND kind <> 'announcement'; diff --git a/services/api/drizzle/0179_address_resolution.sql b/services/api/drizzle/0179_address_resolution.sql new file mode 100644 index 00000000..70f0b07c --- /dev/null +++ b/services/api/drizzle/0179_address_resolution.sql @@ -0,0 +1,160 @@ +-- ============================================================================= +-- 0179_address_resolution.sql +-- ----------------------------------------------------------------------------- +-- The address-resolution overhaul. Three concerns, one theme: a pin now yields a +-- HONEST address line, and every stored address carries where it came from. +-- +-- 1. `geocode_cache` (new). Reverse geocodes are stable per point, and the +-- same point is resolved several times over one creation flow (the client +-- previews it while the pin settles, then the create path resolves it +-- again server-side). This read-through cache collapses all of that onto +-- one row keyed by the SHARED 5-decimal point key +-- (@civfix/shared `geocodePointKey`, ~1.1 m), which is also the key the +-- client rounds its preview query on -- so a pin fine-tune hits one row +-- rather than minting a new provider call per micro-drag. +-- +-- TTLs are enforced in SQL-adjacent code (services/geocode-cache.ts), not +-- by a cron: a row past its TTL is simply read as a MISS and overwritten by +-- the fresh resolve. 180 days for a resolved address; 15 minutes when the +-- resolve produced nothing. The short negative TTL is the compromise the +-- design spec asks for from both ends: caching nulls at all is what stops a +-- dragged pin from hammering a provider that is currently down, and +-- expiring them in minutes is what stops a provider OUTAGE from poisoning +-- those points for half a year. +-- +-- DEVIATION from the design spec's sketch, deliberate: the column is +-- `address_precision`, not `precision` (`precision` is a Postgres keyword; +-- non-reserved, so it would parse, but this table is read by hand during +-- incidents and there is no upside to the ambiguity), and `address` / +-- `address_precision` are NULLABLE to carry a negative entry. +-- +-- NOT user data. Every row is a public coordinate -> public address line, +-- derived from a third-party geocoder. It holds no reference to the user, +-- report or event that caused the lookup, so it is outside the erasure lane +-- by construction (docs/erasure-behavior.md). +-- +-- 2. `cleanups.address_source`. Events were NEVER reverse-geocoded: the column +-- held whatever the host typed into "name the spot", or NULL. New clients +-- now confirm a resolved line, so the column needs provenance -- +-- 'resolved' (shown to the host unchanged), 'edited' (host amended the +-- resolved line) or 'manual' (host typed it because resolution failed or +-- was locality-grade). Backfilled to 'manual' for every existing event that +-- has an address, which is the closest truth: a host typed it. +-- +-- 3. `reports.addr_source` + `reports.addr_precision`. Reports already +-- snapshot an address at creation, either the reporter's own text or the +-- server's reverse geocode, and nothing recorded WHICH. 'user' vs +-- 'resolved' plus the precision rung the geocoder actually reached lets the +-- display layer be honest (a `landmark` line renders with a "Near " prefix +-- instead of posing as a postal address) and leaves a future +-- location-coarsening policy implementable as a display rule +-- (docs/location-coarsening-assessment.md) rather than a re-geocode. +-- +-- NO NEW INDEX ON A HOT TABLE. `reports` IS on the hot-table list in +-- docs/out-of-band-indexes.md; both of its new columns are plain row columns +-- read only alongside the row itself, so nothing here needs an index. The one +-- index created below is on `geocode_cache`, a table this file creates empty. +-- +-- CHECK constraints are added NOT VALID (`reports` is hot; `cleanups` follows +-- the same shape for consistency, as 0170 did). Every existing row has NULL in +-- these columns -- the columns are created by this file -- so each constraint +-- holds by construction from the instant it exists, and every INSERT/UPDATE is +-- checked from now on. +-- +-- STILL OUTSTANDING (a later release, out of band, SHARE UPDATE EXCLUSIVE): +-- ALTER TABLE cleanups VALIDATE CONSTRAINT cleanups_address_source_chk; +-- ALTER TABLE reports VALIDATE CONSTRAINT reports_addr_source_chk; +-- ALTER TABLE reports VALIDATE CONSTRAINT reports_addr_precision_chk; +-- +-- CANONICAL DDL: hand-authored source of truth. Mirrors: schema/geocode_cache.ts +-- (new), schema/cleanups.ts, schema/reports.ts. Enum mirrors: shared +-- AddressPrecisionSchema / EventAddressSourceSchema / ReportAddressSourceSchema. +-- +-- Conventions: additive ADD COLUMN IF NOT EXISTS; one concern per file; one +-- transaction per file. Forward-only, no down. +-- +-- Ordering rules: requires 0001_core.sql (reports, cleanups). +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS geocode_cache ( + point_key text PRIMARY KEY, + address text, + address_precision text, + city_state_label text NOT NULL DEFAULT '', + provider text, + resolved_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE geocode_cache IS + 'Read-through cache of reverse geocodes, keyed by the shared 5-decimal point key (@civfix/shared geocodePointKey). Derived public data only - no user, report or event reference. Rows past their TTL are read as misses and overwritten in place; see services/geocode-cache.ts.'; + +COMMENT ON COLUMN geocode_cache.address IS + 'The resolved one-line address, or NULL for a negative entry (the providers returned nothing). A landmark line is stored RAW - the "Near " prefix is a localized display concern.'; + +COMMENT ON COLUMN geocode_cache.address_precision IS + 'How far down the ladder the resolve got: street | intersection | landmark | locality. NULL on a negative entry. Named address_precision, not precision, to keep the column unambiguous in hand-written incident queries.'; + +COMMENT ON COLUMN geocode_cache.provider IS + 'Which adapter answered: mapbox | photon | tiger. NULL on a negative entry. Diagnostics only - never served to a client.'; + +CREATE INDEX IF NOT EXISTS geocode_cache_resolved_at_idx ON geocode_cache (resolved_at); + +ALTER TABLE cleanups ADD COLUMN IF NOT EXISTS address_source text; + +COMMENT ON COLUMN cleanups.address_source IS + 'Provenance of cleanups.address: resolved (host confirmed the reverse-geocoded line) | edited (host amended it) | manual (host typed it). NULL only for legacy rows with no address at all.'; + +ALTER TABLE reports ADD COLUMN IF NOT EXISTS addr_source text; +ALTER TABLE reports ADD COLUMN IF NOT EXISTS addr_precision text; + +COMMENT ON COLUMN reports.addr_source IS + 'Provenance of reports.addr: user (the reporter typed it) | resolved (the server reverse-geocoded the pin at creation). NULL for legacy rows and for reports with no address.'; + +COMMENT ON COLUMN reports.addr_precision IS + 'Ladder rung the geocoder reached for a resolved addr: street | intersection | landmark | locality. Always NULL when addr_source is user - the reporter''s own text carries no provider precision.'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'cleanups_address_source_chk' AND conrelid = 'cleanups'::regclass + ) THEN + ALTER TABLE cleanups + ADD CONSTRAINT cleanups_address_source_chk + CHECK (address_source IS NULL OR address_source IN ('resolved', 'edited', 'manual')) NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'reports_addr_source_chk' AND conrelid = 'reports'::regclass + ) THEN + ALTER TABLE reports + ADD CONSTRAINT reports_addr_source_chk + CHECK (addr_source IS NULL OR addr_source IN ('user', 'resolved')) NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'reports_addr_precision_chk' AND conrelid = 'reports'::regclass + ) THEN + ALTER TABLE reports + ADD CONSTRAINT reports_addr_precision_chk + CHECK ( + addr_precision IS NULL + OR addr_precision IN ('street', 'intersection', 'landmark', 'locality') + ) NOT VALID; + END IF; +END $$; + +-- Backfill, inline and deliberately: `cleanups` is small (one row per event, not +-- per interaction) and is absent from the hot-table list, so this single UPDATE +-- inside the runner's transaction is not the lock hazard the same statement +-- would be on `reports` or `posts`. Idempotent via the IS NULL predicate. +-- +-- Existing events with address IS NULL get NOTHING here on purpose: host +-- verification is the whole feature, and the server cannot verify on a host's +-- behalf. Those events keep the client's "Meeting point" fallback and the host +-- is nudged to add one. Likewise `reports.addr_source` is NOT backfilled - for a +-- legacy row there is no way to tell the reporter's text from the server's +-- snapshot, and guessing would be worse than NULL. +UPDATE cleanups SET address_source = 'manual' WHERE address IS NOT NULL AND address_source IS NULL; diff --git a/services/api/package.json b/services/api/package.json index 91a97a3f..f095cd11 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -12,6 +12,7 @@ "./db": "./src/db/worker-db.ts", "./media-repo": "./src/services/media-worker-repo.ts", "./inbound-retention-repo": "./src/services/admin/inbound-retention-repository.drizzle.ts", + "./geocode-cache": "./src/services/geocode-cache.ts", "./anon-hold-release": "./src/services/anon-hold-release.ts", "./anon-hold-repo": "./src/services/anon-hold-release-repo.drizzle.ts", "./adapters/storage": "./src/adapters/storage.r2.ts", @@ -32,6 +33,7 @@ "start:backfill:served-key": "node dist/db/backfill-served-key.js", "start:backfill:user-activity": "node dist/db/backfill-user-activity.js", "start:backfill:signup-seats": "node dist/db/backfill-signup-seats.js", + "start:backfill:post-geom": "node dist/db/backfill-post-geom.js", "start:seed:demo": "node dist/db/seed-demo-la.js", "typecheck": "tsc --noEmit", "lint": "eslint .", @@ -48,6 +50,8 @@ "db:backfill:served-key": "tsx src/db/backfill-served-key.ts", "db:backfill:user-activity": "tsx src/db/backfill-user-activity.ts", "db:backfill:signup-seats": "tsx src/db/backfill-signup-seats.ts", + "db:backfill:post-geom": "tsx src/db/backfill-post-geom.ts", + "email:gallery": "tsx scripts/render-email-gallery.ts", "db:certificate:revoke": "tsx scripts/revoke-certificate.ts", "db:prepare-boundaries": "tsx scripts/prepare-boundaries.ts", "db:boundaries:refresh": "tsx scripts/refresh-boundaries.ts", @@ -56,7 +60,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", - "@civfix/shared": "^0.53.0", + "@civfix/shared": "^0.55.0", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", "@fastify/helmet": "^12.0.1", diff --git a/services/api/scripts/render-email-gallery.ts b/services/api/scripts/render-email-gallery.ts new file mode 100644 index 00000000..66dcbd66 --- /dev/null +++ b/services/api/scripts/render-email-gallery.ts @@ -0,0 +1,333 @@ +import { mkdirSync, writeFileSync } from "node:fs" +import { join, resolve } from "node:path" +import { renderOtp, renderTemplate } from "../src/adapters/mailer.oci.js" +import { renderBroadcast } from "../src/services/host/broadcast-render.js" +import { + buildDiscussionForwardPacket, + buildEventPacket, + buildReportPacket, +} from "../src/services/admin/mail-format.js" +import { buildConfirmationEmail, buildNotificationEmail } from "../src/routes/forms.routes.js" +import { buildDataExportEmail } from "../src/services/data-export-service.js" +import { + orgInviteEmailVars, + orgVerificationDecisionEmailVars, +} from "../src/services/host/organization-service.js" +import { teamInviteEmailVars } from "../src/services/host/host-team-service.js" +import type { AdminReportRecord } from "../src/services/admin/admin-report-types.js" + +interface GalleryEntry { + name: string + label: string + subject: string + html: string + text: string +} + +const WEB = "https://civfix.org" +const EVENT_TITLE = "Ballona Creek Cleanup" +const EVENT_WHEN = "Saturday, October 3 at 9:00 AM PDT" +const EVENT_WHERE = "Ballona Creek Trailhead, 13500 Jefferson Blvd, Los Angeles" +const CANCEL_URL = `${WEB}/guest?token=k3v9pT2wXane4Yr8LqZs0dHc7fGbJmQu` +const UNSUB_URL = `${WEB}/v1/broadcasts/unsubscribe?t=9fJ2mQx7Lw4TzAb1` + +function sampleReport(): AdminReportRecord { + return { + id: "11111111-2222-3333-4444-555555555555", + category: "graffiti", + status: "submitted", + flagged: false, + title: "Tagging on the Jefferson Blvd underpass", + place: "Del Rey", + reporter: { + id: "u1", + name: "Dana Reporter", + handle: "dana", + emailVerified: true, + hasOauth: false, + joinedAt: new Date("2025-01-01T00:00:00Z"), + }, + confirmations: 3, + address: "13500 Jefferson Blvd", + desc: "Fresh tags along the north wall of the underpass.\nAbout 20 feet of coverage, appeared this week.", + lat: 33.9812, + lng: -118.4185, + hasPhoto: true, + previewMedia: null, + createdAt: new Date("2026-09-01T17:20:00Z"), + referenceCode: "LA-4F7K2", + verificationVerdict: null, + verifiedAt: null, + reporterReportVerified: null, + } +} + +function collect(): GalleryEntry[] { + const entries: GalleryEntry[] = [] + const push = (name: string, label: string, r: { subject: string; html?: string; text: string }): void => { + entries.push({ name, label, subject: r.subject, html: r.html ?? `
${r.text}`, text: r.text })
+ }
+
+ push("signin-otp", "Sign-in passcode (Mailer.sendOtp)", renderOtp("482913", "en"))
+
+ push(
+ "guest-otp",
+ "Guest RSVP code (template guest_otp)",
+ renderTemplate("guest_otp", { title: EVENT_TITLE, code: "738201", minutes: "5" }),
+ )
+
+ push(
+ "guest-confirmed",
+ "Guest RSVP confirmation (template guest_confirmed)",
+ renderTemplate("guest_confirmed", {
+ title: EVENT_TITLE,
+ when: EVENT_WHEN,
+ place: EVENT_WHERE,
+ cancelUrl: CANCEL_URL,
+ }),
+ )
+
+ push(
+ "guest-promoted",
+ "Guest waitlist promotion (template guest_promoted)",
+ renderTemplate("guest_promoted", {
+ title: EVENT_TITLE,
+ when: EVENT_WHEN,
+ eventUrl: `${WEB}/cleanups/e7a1c9d2-5b34-4f68-9a21-8c3d5e7f0a1b`,
+ }),
+ )
+
+ push(
+ "org-invite",
+ "Organization member invite (template action)",
+ renderTemplate(
+ "action",
+ orgInviteEmailVars({
+ inviterName: "Maria Chen",
+ orgName: "Ballona Creek Trust",
+ role: "admin",
+ link: `${WEB}/manage/org-invites/accept#token=q8Zw2Xv7Kp4Nr9Ty`,
+ }),
+ ),
+ )
+
+ push(
+ "org-verification-approved",
+ "Org verification approved (template action)",
+ renderTemplate(
+ "action",
+ orgVerificationDecisionEmailVars({
+ orgName: "Ballona Creek Trust",
+ kindLabel: "nonprofit",
+ approved: true,
+ reason: "",
+ orgUrl: `${WEB}/orgs/ballona-creek-trust`,
+ verifyUrl: `${WEB}/manage/orgs/1/verification`,
+ }),
+ ),
+ )
+
+ push(
+ "org-verification-rejected",
+ "Org verification rejected (template action)",
+ renderTemplate(
+ "action",
+ orgVerificationDecisionEmailVars({
+ orgName: "Ballona Creek Trust",
+ kindLabel: "nonprofit",
+ approved: false,
+ reason:
+ "The determination letter you uploaded names a different legal entity than the organization profile.",
+ orgUrl: `${WEB}/orgs/ballona-creek-trust`,
+ verifyUrl: `${WEB}/manage/orgs/1/verification`,
+ }),
+ ),
+ )
+
+ push(
+ "team-invite",
+ "Event team invite (template action)",
+ renderTemplate(
+ "action",
+ teamInviteEmailVars({
+ title: EVENT_TITLE,
+ role: "co-host",
+ link: `${WEB}/cleanups/e7a1c9d2#teamInvite=m2Pq8Rv5Tx7Zw1Ka`,
+ }),
+ ),
+ )
+
+ push(
+ "generic-fallback",
+ "Generic fallback (template generic)",
+ renderTemplate("generic", {
+ subject: "A civfix notification",
+ message: "You have a new civfix notification.",
+ }),
+ )
+
+ const announcement = renderBroadcast(
+ {
+ subject: "This Saturday: {event_title} - what to bring",
+ bodyMd:
+ "Hi {first_name},\n\n" +
+ "We are all set for {event_when} at the trailhead. A few notes before the day:\n\n" +
+ "- We provide grabbers, gloves and bags\n" +
+ "- Wear closed-toe shoes and bring a refillable water bottle\n" +
+ "- Parking is free in the lot off Jefferson after 8:30 AM\n\n" +
+ "**Meet at the blue canopy** near the bike path entrance. See the full plan at [the event page](https://civfix.org/e/ballona-creek-cleanup).",
+ ctaLabel: "View event details",
+ ctaUrl: `${WEB}/e/ballona-creek-cleanup`,
+ },
+ {
+ eventTitle: EVENT_TITLE,
+ vars: {
+ first_name: "Jordan",
+ event_title: EVENT_TITLE,
+ event_when: EVENT_WHEN,
+ event_where: EVENT_WHERE,
+ },
+ unsubscribeUrl: UNSUB_URL,
+ manageUrl: `${WEB}/e/ballona-creek-cleanup`,
+ replyTo: "hosts@ballonacreektrust.org",
+ },
+ )
+ push("broadcast-announcement", "Host announcement (broadcast pipeline)", announcement)
+
+ const critical = renderBroadcast(
+ {
+ subject: "{event_title} moved to 10:00 AM",
+ bodyMd:
+ "Hi {first_name},\n\nCity crews are resurfacing the parking lot early Saturday, so we are pushing the start to **10:00 AM**. Same meeting point at the blue canopy. If you can no longer make it, you can cancel from the event page.",
+ },
+ {
+ eventTitle: EVENT_TITLE,
+ vars: {
+ first_name: "Jordan",
+ event_title: EVENT_TITLE,
+ event_when: EVENT_WHEN,
+ },
+ manageUrl: `${WEB}/e/ballona-creek-cleanup`,
+ critical: true,
+ },
+ )
+ push("broadcast-event-updated", "Event updated notice (critical broadcast)", critical)
+
+ const form = {
+ coachName: "Sam Alvarez",
+ role: "Head coach",
+ school: "Venice High School",
+ city: "Los Angeles",
+ teamSize: "18",
+ email: "coach.alvarez@venicehigh.edu",
+ phone: "(310) 555-0142",
+ notes: "Season runs through November; weekday afternoons work best.",
+ turnstileToken: "x",
+ honeypot: "",
+ }
+ push(
+ "home-turf-notification",
+ "Home Turf sign-up (to staff)",
+ buildNotificationEmail(form, "no-reply@civfix.org", "events@civfix.org"),
+ )
+ push(
+ "home-turf-confirmation",
+ "Home Turf confirmation (to submitter)",
+ buildConfirmationEmail(form, "no-reply@civfix.org", "events@civfix.org"),
+ )
+
+ push(
+ "data-export",
+ "Data export ready (DSAR)",
+ buildDataExportEmail("support@civfix.org", ["messages", "comments"]),
+ )
+
+ push(
+ "admin-report-packet",
+ "Admin report forward (to city office)",
+ buildReportPacket(
+ sampleReport(),
+ { geoid: "0644000", dept: "Public Works", place: "Los Angeles", contact: null, routed: true },
+ [`${WEB}/m/a?t=1`, `${WEB}/m/b?t=2`],
+ "Second report at this location this month; residents flagged it as recurring.",
+ ),
+ )
+
+ push(
+ "admin-discussion-forward",
+ "Admin discussion forward (to city office)",
+ buildDiscussionForwardPacket(
+ { reportId: "11111111-2222-3333-4444-555555555555", category: "graffiti", place: "Del Rey", org: null },
+ "The tags are back again this week - is there a schedule for abatement on this wall?",
+ ),
+ )
+
+ push(
+ "admin-event-packet",
+ "Admin event resource request (to city office)",
+ buildEventPacket(
+ {
+ title: EVENT_TITLE,
+ host: "Ballona Creek Trust",
+ place: "Del Rey",
+ address: "13500 Jefferson Blvd, Los Angeles",
+ lat: 33.9812,
+ lng: -118.4185,
+ referenceCode: "LA-4F7K2",
+ },
+ "We expect around 60 volunteers and would appreciate a dumpster drop-off on Friday plus pickup Monday.",
+ ),
+ )
+
+ return entries
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+}
+
+function indexHtml(entries: GalleryEntry[]): string {
+ const rows = entries
+ .map(
+ (e) =>
+ `