Repository navigation
Ranked home feed, event announcements, event analytics, chat media serving, street-level addresses (#100, #118, #122) - #53
Conversation
…out, feed counts endpoint
Moves both services onto ^0.51.0, which carries the address-resolution surface: AddressPrecision / EventAddressSource / ReportAddressSource, CleanupDTO.addressSource, ReportDTO.addrSource + addrPrecision, the resolveAddress endpoint definition, and the geocodePointKey helper the new cache keys on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The OTP now lands in the styled code block instead of mid-sentence, the confirmation shows the event title, when and where (the loaded event is passed through joinAsGuest instead of just its title) with a Cancel RSVP button in place of a raw token URL, and the waitlist promotion gets a See-the-event button. SMS paths are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Org invites, org verification decisions and event team invites move off hand-glued sentences onto the action template via exported vars builders, so the accept link becomes a button and the expiry a muted note; a rejection quotes its reason. The DSAR export mail trades bare plain text for the standard branded shell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pnpm email:gallery <outdir> renders all seventeen outbound email types with realistic sample data plus an index page, straight from the exported renderers, no DB or mailer. The Home Turf builders are exported so the gallery shows the real thing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
| SELECT sl.title AS key, count(*)::int AS n | ||
| FROM cleanup_slot_claims sc | ||
| JOIN cleanup_slots sl ON sl.id = sc.slot_id | ||
| WHERE sc.cleanup_id = ${cleanupId} |
There was a problem hiding this comment.
The signups-by-slot query groups durable member slot claims instead of active registrations. A cancelled member's claim can remain and be counted, while an active guest cannot have a claim and is omitted. The reproduced fixture returned a slot count for a cancelled member while its only active registration was a guest. Build this metric from active registrations or seats, with an explicit unassigned guest bucket, or relabel it as staffing claims.
This is a non-blocking analytics accuracy concern, but it makes the slot panel disagree with active event signups.
Artifacts
- Authored and executed source that captures the SQL emitted by the slot-claims query.
- Captured SQL shows the query joins slot claims to slots without a registration-status filter, registration or seat join, or guest representation.
- Live PostgreSQL output shows a cancelled member claim counted in the slot panel while the only active registration was a guest.
- The focused analytics repository SQL suite completed successfully while not covering the cancelled-claim and active-guest discrepancy.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/src/services/host/event-analytics-repository.drizzle.ts
Line: 121-124
Comment:
**Count active slot signups**
The signups-by-slot query groups durable member slot claims instead of active registrations. A cancelled member's claim can remain and be counted, while an active guest cannot have a claim and is omitted. The reproduced fixture returned a slot count for a cancelled member while its only active registration was a guest. Build this metric from active registrations or seats, with an explicit unassigned guest bucket, or relabel it as staffing claims.
This is a non-blocking analytics accuracy concern, but it makes the slot panel disagree with active event signups.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| (SELECT count(*) FROM cleanup_registrations r | ||
| WHERE r.cleanup_id = c.id AND r.status = 'registered')::float8 AS signups, | ||
| (SELECT count(*) FROM cleanup_registration_seats s | ||
| WHERE s.cleanup_id = c.id AND s.checked_in_at IS NOT NULL)::float8 AS checked_in, |
There was a problem hiding this comment.
Use consistent attendance units
comparisonMedians counts registrations as parties but counts check-ins and capacity as individual seats. The reproduced query returned a 200% check-in rate and a 50% fill rate for fully checked-in two-person parties. Calculate signups in seats, such as by summing registered party_size, or derive every metric from the same seat-level data.
This is a non-blocking analytics accuracy concern, but it can show impossible attendance rates and understate event fill.
Artifacts
- Authored SQL creates one single-seat party and executes the current calculation shape, showing the control condition where party and seat units coincide.
- Executed PostgreSQL output for the single-seat control shows signups 1, check-in rate 1, and fill rate 0.5, establishing the consistent baseline.
- Authored and executed PostgreSQL script reproduces the repository query and compares it with seat-based metrics for identical two-person-party data.
- Executed PostgreSQL output shows the current query returns check-in rate 2 and fill rate 0.5 while seat-based metrics return 1 and 1, confirming the unit mismatch.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/src/services/host/event-analytics-repository.drizzle.ts
Line: 187-190
Comment:
**Use consistent attendance units**
`comparisonMedians` counts registrations as parties but counts check-ins and capacity as individual seats. The reproduced query returned a 200% check-in rate and a 50% fill rate for fully checked-in two-person parties. Calculate signups in seats, such as by summing registered `party_size`, or derive every metric from the same seat-level data.
This is a non-blocking analytics accuracy concern, but it can show impossible attendance rates and understate event fill.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Comments Outside DiffThese findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.
|
| # ===== 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 |
There was a problem hiding this comment.
EXPO_ACCESS_TOKEN is accepted by the API and documented in .env.example, but the production and staging secret configuration do not provide it. This violates the repository requirement that each environment variable be added to env.ts, .env.example, and both deployment secret files. The repository requirement must be satisfied before merging so Enhanced Push Security can be configured consistently.
Rule Used: # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for correctness, security and performance. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/.env.example
Line: 231
Comment:
**Add deployment token entries**
`EXPO_ACCESS_TOKEN` is accepted by the API and documented in `.env.example`, but the production and staging secret configuration do not provide it. This violates the repository requirement that each environment variable be added to `env.ts`, `.env.example`, and both deployment secret files. The repository requirement must be satisfied before merging so Enhanced Push Security can be configured consistently.
**Rule Used:** # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for **correctness, security and performance**. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... ([source](https://app.greptile.com/civfix/-/custom-context?memory=39a53925-3d93-4c82-980e-27b67393717d))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| # 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) |
There was a problem hiding this comment.
Configure feed ranking secrets
FEED_RANKING is accepted by the API and documented here, but production and staging deployment secrets do not define it. This violates the repository requirement that every environment variable be added to both deployment secret files. The repository requirement must be satisfied before merging.
Rule Used: # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for correctness, security and performance. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/.env.example
Line: 118
Comment:
**Configure feed ranking secrets**
`FEED_RANKING` is accepted by the API and documented here, but production and staging deployment secrets do not define it. This violates the repository requirement that every environment variable be added to both deployment secret files. The repository requirement must be satisfied before merging.
**Rule Used:** # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for **correctness, security and performance**. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... ([source](https://app.greptile.com/civfix/-/custom-context?memory=39a53925-3d93-4c82-980e-27b67393717d))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const { ranked } = await rankCandidateSet(viewerId, query, location, false) | ||
| persistSnapshot(viewerId, query.filter, ranked) | ||
| return pageFrom(viewerId, sliceAfterCursor(ranked, cursor), limit) |
There was a problem hiding this comment.
Keep fallback pagination stable
When snapshot storage is unavailable, a continuation requested after the ranking clock bucket changes recomputes a different ranked order and applies the prior page’s score cursor to it. The next page can therefore skip posts or repeat posts already served; the reproduced 60-post flow skipped eight posts after the bucket changed. Preserve the ranking epoch or seed in the cursor, or use an equivalent stable fallback snapshot for the cursor lifetime.
Artifacts
- Authored TypeScript harness that invokes the current service for an in-bucket control and cross-bucket continuation, showing the exact test setup.
- Captured execution output for the harness, showing no control overlap and eight posts skipped after the bucket changed; the claim is confirmed.
- Captured command output containing the exact authored harness source used for the deterministic reproduction.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/src/services/post-service.ts
Line: 333-335
Comment:
**Keep fallback pagination stable**
When snapshot storage is unavailable, a continuation requested after the ranking clock bucket changes recomputes a different ranked order and applies the prior page’s score cursor to it. The next page can therefore skip posts or repeat posts already served; the reproduced 60-post flow skipped eight posts after the bucket changed. Preserve the ranking epoch or seed in the cursor, or use an equivalent stable fallback snapshot for the cursor lifetime.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| -- ============================================================================= | ||
| -- 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). | ||
| -- ============================================================================= |
There was a problem hiding this comment.
This migration adds a large explanatory comment block, which violates the repository directive that new code have no comments. The same newly added pattern is present in migrations 0177, 0178, and 0179. Remove the new comments or move necessary operational guidance into existing documentation. This repository requirement must be satisfied before merging.
Rule Used: # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for correctness, security and performance. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/drizzle/0176_posts_geom.sql
Line: 1-41
Comment:
**Remove migration comments**
This migration adds a large explanatory comment block, which violates the repository directive that new code have no comments. The same newly added pattern is present in migrations 0177, 0178, and 0179. Remove the new comments or move necessary operational guidance into existing documentation. This repository requirement must be satisfied before merging.
**Rule Used:** # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for **correctness, security and performance**. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... ([source](https://app.greptile.com/civfix/-/custom-context?memory=39a53925-3d93-4c82-980e-27b67393717d))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Want your agent to iterate on Greptile's feedback? Start a greploop in Claude Code and it will work through the open comments and keep going until this PR reviews clean. |
| const cleanupIds = await deps.analytics.hostedEventIds( | ||
| userId, | ||
| organizationId, | ||
| PORTFOLIO_EVENT_LIMIT, | ||
| ) | ||
| const envelope = { | ||
| generatedAt: now().toISOString(), | ||
| range, | ||
| k: ANALYTICS_SUPPRESSION_K, | ||
| window, | ||
| } | ||
| if (cleanupIds.length === 0) return emptySummary(envelope, window) | ||
| const [activity, held, signups, metricRows] = await Promise.all([ | ||
| deps.analytics.activityTotals(cleanupIds, bounds.from, bounds.to), | ||
| deps.analytics.heldEventTotals(cleanupIds, bounds.from, bounds.to), | ||
| deps.analytics.signupsByDayAcross(cleanupIds, window.from, window.to), | ||
| deps.metrics.readMany(cleanupIds, ["donation_clicks"], window.from, window.to), | ||
| ]) | ||
| return { | ||
| ...envelope, | ||
| activity: { | ||
| signups: activity.registrations, | ||
| cancellations: activity.cancellations, | ||
| hoursTotal: round2(activity.hoursTotal), | ||
| hoursVolunteers: activity.hoursVolunteers, | ||
| reportsLinked: activity.reportsLinked, | ||
| reportsResolved: activity.reportsResolved, | ||
| postsCreated: activity.postsCreated, | ||
| donationClicks: sumMetric(metricRows, "donation_clicks"), | ||
| }, | ||
| eventsHeld: { | ||
| count: held.events, | ||
| registered: held.registered, | ||
| checkIns: held.checkedIn, | ||
| noShows: held.noShow, | ||
| checkInRate: exactRate(held.checkedIn, held.registered), | ||
| }, | ||
| totals: { events: cleanupIds.length }, |
There was a problem hiding this comment.
Report Complete Portfolio Totals
When a host or organization has more than 200 accessible events, this summary selects only the 200 most recently scheduled events and calculates every aggregate from that subset. It then returns those values as portfolio-wide activity, attendance, signup, donation, and event totals without indicating that results were truncated. A 201-event execution returned 200 for every one-per-event aggregate and omitted one event, so hosts will see understated portfolio metrics.
Artifacts
- The authored TypeScript harness invokes the real analytics summary service with configurable 200- and 201-event repository fixtures, ending with assertions that capture the coverage difference.
- The executed command captured the authored harness source used to exercise the real summary service, ending with the assertions for complete and truncated portfolios.
- The baseline command ran the harness with 200 seeded events and returned complete 200-event aggregate values with exit code 0.
- The comparison command ran the same harness with 201 seeded events and returned only 200 aggregate values, omitted one event, and exposed no truncation field.
- The targeted Vitest command ran the host analytics test file and passed all 43 tests after the reproduction harness was added.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/api/src/services/host/analytics-service.ts
Line: 372-409
Comment:
**Report Complete Portfolio Totals**
When a host or organization has more than 200 accessible events, this summary selects only the 200 most recently scheduled events and calculates every aggregate from that subset. It then returns those values as portfolio-wide activity, attendance, signup, donation, and event totals without indicating that results were truncated. A 201-event execution returned 200 for every one-per-event aggregate and omitted one event, so hosts will see understated portfolio metrics.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.# Conflicts: # services/api/test/unit/email-format.test.ts
Resolves civfix/issue-tracker#100
Resolves civfix/issue-tracker#122
Part of civfix/issue-tracker#118
What changed
Four server-side things. (1) The home feed is now ranked instead of newest-first, with a "new posts" pill and like/reply counts that update while you sit on the feed. (2) Event hosts can send announcements: a one-way note that goes out by email, push and in-app to the audience the host picks, stays on the event page for everyone, and is capped at ten per event per day. (3) One request now answers both the analytics card on host tools and the full event analytics page. (4) A photo sent in a chat is now served while it is still being checked — so it stops vanishing from the message — while a photo the checks hold or reject is no longer served anywhere public. Plus a guard so an organization cannot be left with nobody who can administer it, and admin report list responses that carry each report's photo thumbnail. Visible on web, mobile and admin — each needs its sibling PR deployed too.
A fifth, larger thing landed after the above: real street addresses for a map pin. Until now a pin could only be turned into "Los Angeles, CA". The server now works down a ladder — a street address with a house number, else a street corner, else a named place, else the city — and remembers the answer so the same pin is not looked up over and over. There is a new request the apps make while a host is dropping a pin, so the host can see and confirm the address their attendees will navigate to; an event created by a NEW app now has to carry an address a human confirmed, while an event created by an app still in someone's pocket keeps working and gets its address filled in server-side. A report now also records where its address came from — the server working it out, or the reporter typing it.
A sixth batch went over the guest mechanism — the way someone with no account RSVPs to an event — end to end, and rewrote the emails. The guest half is mostly hardening with tests behind it (RSVP, the code, cancelling, RSVPing again, what a host sees on the roster and at check-in, what lands in the roster export, what the retention scrub wipes), plus a new email to a guest who gets promoted off a waitlist. The email half is visible to anyone who receives one: the guest code now arrives as a large code block instead of a sentence, a guest confirmation carries an event card and a "Cancel RSVP" button, the organization invite, verification decision, team invite and data-export emails are laid out with headings and a button instead of one bare paragraph, broadcast footers break onto their own lines with clickable links, and every unmonitored mailbox now says so instead of inviting a reply.
A follow-up pass in this session reworked the feed scoring and fixed notifications: the ranked feed's score is now an explicit everyone-sees-it part (popularity, attachments, freshness) plus a personal part in which physical distance dominates everything else; every refresh applies a seeded reshuffle so two refreshes never look identical, while pages already fetched stay consistent; a feed with few strong items used to stop after one page with a dead "load more" — it now pages to the end; open-ended and cancelled events are scored correctly. Push notifications recover when a phone changes hands between accounts: a registration released by sign-out can be claimed by the next account (a still-active registration stays protected), and re-registering a long-lived device no longer knocks it straight back out. Announcements can no longer exceed the daily cap when two hosts send at the same moment, posting is rate-limited to a human speed, and the paid address lookup no longer mislabels businesses with digit-leading names as street addresses. A final pass adds one host-wide analytics read: everything a host runs, summed over a chosen window (30 days by default) with per-day sign-ups and per-event breakdowns keyed so same-named events stay distinct — it powers the redesigned dashboard card and the all-events analytics page — and the per-event funnel now starts at sign-ups instead of web page views, so it can never contradict the tiles beside it.
Before you start
dev/run.sh seed-demolocally, or post by hand on staging. For the "nearby" step, account X needs one own report with a pinned location. For the announcement steps, one event with registered attendees and at least one shift. For the media steps, one report with a photo and one chat you can post a photo into.Verify
Ranked feed + live updates — [Web] [Mobile]
Sending an announcement — [Web] [Mobile]
The announcement daily limit — [Web] [Mobile]
Host analytics: one summary across every event — [Web] [Mobile]
A chat photo while it is being checked — [Web] [Mobile]
Photos everywhere else still render — [Web] [Mobile] [Admin]
A pin now gives back a street address — [Web] [Mobile]
The address goes with the event, and stays with it — [Web] [Mobile]
An event published by an OLDER app build — [Mobile]
Where a report's address comes from — [Web] [Mobile]
A guest RSVP, start to finish — [Web] [Mobile]
Every other email got the same treatment — [Web] [Admin]
Refreshing reshuffles, paging never repeats — [Web] [Mobile]
Distance outranks popularity — [Web] [Mobile]
The second account on one phone gets pushes again — [Mobile]
Posting has a human speed limit — [Web] [Mobile]
Regression
Posting, liking, reposting, replying — [Web] [Mobile]
Blocks and visibility — [Web] [Mobile]
Feed error and empty states — [Web] [Mobile]
The existing host messages — [Web]
Event page and event dashboard — [Web] [Mobile]
The map's own labels are unchanged — [Web] [Mobile]
Creating, editing and cancelling an event — [Web] [Mobile]
Profile, feed and admin reads of an address — [Web] [Mobile] [Admin]
Guests a host already had — [Web] [Mobile] [Admin]
Check-in, tickets and volunteer hours — [Web] [Mobile]
Admin — reports, hosts and organizations — [Admin]
Announcements in the web host console — [Web]
Session across the deploy — [Web] [Mobile]
Not covered
How a real lookup actually answers. The street / corner / place / city-only rungs and the wording each produces were built and tested against canned responses only; no live lookup service was called in this session. How often each rung fires in a real city, and how the composed corner and place lines read in practice, is unverified.
The database change this PR carries applies on the staging deploy; it was not run locally in this session. Its one new index is on a table the same file creates empty, so it locks nothing; the two report columns are plain additions with no index. Three of its checks are deliberately left to be confirmed out of band later.
The daily cleanup of the remembered-address table was not run against a real database here — only its logic is covered. Its only trace is a count in the job's log; nothing in any app or dashboard shows it.
The remembered-address table is not observable from any app. The nearest honest check is the one in the plan above (a second account resolving the same pin with no visible pause), and even that is muddied by the app's own short-term memory — to see the server half you need a genuinely fresh session.
Nothing anywhere shows which rung an address came from, which service answered, or whether an answer was remembered — those exist only in the data.
The server's own refusal of a blank or one-character confirmed address is not reachable from the apps: the app blocks it first. Covered by tests.
The minimum address length is written down separately in the app and on the server rather than in the shared contract, so the two could drift apart in future without anything catching it.
One shared per-visitor limit now covers four map lookups instead of three, and the creation flows spend two of them on every pin settle — so a very pin-heavy session burns that allowance roughly twice as fast as before. The plan provokes it by hand; it was not load-tested.
Also not settled here: a host at a pin with no findable address can no longer publish, and the same now applies to editing an old event that never had one. Intended, but how often that bites real hosts was not measured.
Real announcement emails and real push notifications, and tapping a push to land on the announcement — no mailer and no device in this session. The email's button says "View announcement" and points at the announcement's page; that has to be checked against a real mailbox.
The full lifecycle of a chat photo the checks HOLD or REJECT. A chat photo in that state was never served and still is not, so there is nothing new to see there; where the behaviour actually changed is that a held or rejected avatar, organization logo or event gallery image used to be served from the raw upload and now is not (the avatar falls back to initials, the gallery image disappears). Forcing the checks to hold or reject a file on demand is not something a tester can do from the app, so this is covered by tests, not by a step.
Migration 0174 applies on the staging deploy; it is not run locally in this session. Its index and its scrub exemption have no UI at all — the exemption (an announcement keeps its text forever, unlike a one-off host message) only differs once the retention sweep's window passes.
The last-admin guard can only fire on an organization that has no owner left — an owner's seat counts as an admin seat, so a normal organization can never reach it. The only real route there is an owner deleting their account, which a tester cannot conveniently arrange. When it does fire, the operator dashboard is the one place that shows the real sentence "An organization needs at least one admin."; the two in-app surfaces show their generic validation copy instead. Covered by unit tests.
The operator "change role" path in the admin dashboard does NOT carry the same guard — only removal does. Deliberate for now, and not exercised here.
Whether the daily announcement limit or the separate "ten a minute" limit produced a given refusal cannot be told apart on screen — they share one message.
Reading a real email is a staging-only step. A local API drops every outbound message on the floor, so nothing in the email section above can be checked against
dev/run.sh. The offline substitute is the new gallery: from the API service directory runpnpm email:gallery <a folder>and open theindex.htmlit writes — it renders every template with sample data and needs no database, no network and no credentials. It is a desktop browser view, not a mail client, so how Gmail, Outlook and Apple Mail actually render these is still unverified.The guest waitlist promotion email is dormant. The code that sends it is wired and tested, but nothing today puts a guest on a waitlist in the first place — a guest RSVP is made with waitlist-joining switched off, so the promotion can never fire for one. Turning it on needs a way for a guest to claim the place they are offered, which does not exist yet. That is an open product decision, not an oversight; until it is made, treat this email as unreachable and do not write a test step for it.
Guests and the announcement flow: a guest gets announcements by email, but there is no place a guest can read the announcements list, because the event page's guest surfaces are the RSVP sheet and nothing else.
The retention scrub that blanks guest contact after 30 days was not run here — only its logic is covered, and the roster export's own note is the only place the behaviour is written down for a host.
Docker-backed integration tests (SQL plans, migrations, paging against real PostGIS/Redis) run in CI, not locally.
The feed's "events"/"fixes" filters have no UI today — verified by API call and unit tests only.
Ranking weight tuning (a server env knob) and the invalid-config boot failure — server logs only, no UI.
The old-posts location backfill is a one-time operator command after deploy (
pnpm db:backfill:post-geomin the API container); until it runs, posts created before this deploy don't get the "nearby" boost. Feed order is the only symptom.Redis-off degradation (feed still serves, live features pause) — verified by unit tests; not exercised on staging.
New-posts fanout caps (very popular authors/posts) — bounded by design, verified in tests only.
The same-moment double-send announcement race is closed in code and covered by tests; provoking it by hand needs two hosts tapping within milliseconds and was not attempted.
The per-refresh shuffle was verified live for "orders differ, strong items persist, pagination never repeats"; its statistical fairness beyond that was reviewed in code only.
The one-time location backfill was run against a full local database in this session (every report- and event-linked post gained a location); staging and production still need the run noted above.
The host-wide summary's donation-tap count needs the production web beacons; everywhere else it is a real zero and the row hides.