Skip to content

Ranked home feed, event announcements, event analytics, chat media serving, street-level addresses (#100, #118, #122) - #53

Merged
theobong merged 58 commits into
mainfrom
feat/feed-ranking-batch
Sep 23, 2026
Merged

theobong merged 58 commits into
mainfrom
feat/feed-ranking-batch

Conversation

@theobong

@theobong theobong commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

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

  • Where: staging after this merges (civfix.dev, admin.civfix.dev, or the TestFlight build for mobile). Announcements, the analytics card and the live feed features need the app PR deployed too.
  • Sign in as: two citizens on two devices/browsers (account X and account Y); X follows Y. One of them hosts an event with at least one other person registered. Also an operator for the admin steps.
  • Also needed for the address steps: two map spots — one ordinary street address with a house number, one with no street address at all (the middle of a large park, a beach, open water) — plus, for the old-client steps, a device still on the previous mobile build.
  • Also needed for the guest and email steps: one event you host that a guest has RSVP'd to (open the event signed out and use "No account? RSVP as a guest"), and a real mailbox you can read that code and that confirmation in. Reading email only works on staging — a local API drops every outbound message.
  • Data: a few public posts from different accounts — dev/run.sh seed-demo locally, 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.
  • Related PRs: app Ranked feed, announcements, event analytics, org management, event addresses and covers, runtime iOS env, UI and swipe fixes (#100, #119, #120, #122, #123, #124) civfix-app#21 · admin Report list photos, image lightbox, announcement label (#118) civfix-admin#22
  • Size: 11,914 counted lines — single PR at the author's request.
  • After deploy (staging AND production): older posts rank by distance only after the one-time location backfill is run on the box — until then only newly created posts carry a location. The runbook note is in the repo docs.

⚠️ Merge order is a correctness gate — this PR FIRST, then app #21, then admin #22.

This is no longer only about features looking broken. The new app always sends the meeting-address field when an event is created or edited, and a server that has not taken this PR's contract rejects that request outright — so shipping the app first leaves creating and editing an event completely broken, not merely degraded. The analytics card and the announcements section have the same dependency, more gently. The same order applies to the eventual production release. The shared contract version all three PRs need is already on the registry, so this repo's CI and admin's resolve it either way.

Verify

Ranked feed + live updates — [Web] [Mobile]

  1. As X, open "Home". — Expect: the feed renders under "Your Feed", and the order is no longer strictly newest-first.
  2. Compare a ~10-minute-old post from Y (whom X follows, 0 likes) against a several-days-old stranger's post with many likes. — Expect: Y's fresh post sits above the old popular one.
  3. Have one followed account post 4 times in a row, then pull to refresh. — Expect: their posts do not occupy four consecutive top slots — other authors are interleaved.
  4. As Y, create a post attached to a report near X's own report pin, and another attached to one far away. As X, pull to refresh. — Expect: the nearby stranger-post ranks above the distant one of equal age.
  5. As X, stay on "Home" scrolled down a bit. As Y (followed), publish a new post. — Expect: within a couple of seconds a pill appears at the top of X's feed reading "1 new post" (count grows with more posts).
  6. Tap the pill. — Expect: the list scrolls to the top, refreshes, and the pill disappears.
  7. As Y, like a post that is currently visible in X's feed. — Expect: ~2 seconds later the like count on X's card ticks up in place, with no reshuffle or refetch flash. Unlike it. — Expect: the count ticks back down.
  8. As Y, reply to a post in X's feed. — Expect: the parent card's reply count updates on X's device.
  9. As X, scroll through 4–5 pages slowly. — Expect: no post appears twice and no gap; at the end, "You're all caught up".
  10. Stop scrolling for over 3 minutes, then continue. — Expect: the feed keeps paginating (no premature end, no duplicates).
  11. Sign out and open "Home" as a guest. — Expect: a ranked feed still loads and paginates.
  12. Create a brand-new account following nobody and open "Home". — Expect: a populated first page of recent public posts, not an empty state; page 2 may be short (expected for a cold account).

Sending an announcement — [Web] [Mobile]

  1. As the host, open your event and tap the "Host dashboard" row, landing on "Host mode". Tap "Make announcement" (the button at the top, or the row under "Communicate").
  2. — Expect: the composer opens with "Title", "Message" and a "Notify" row of choices: "Everyone registered", "Checked in", "Not checked in", "Waitlist", "By shift".
  3. Tap "Everyone registered". — Expect: a count appears beside it — "Counting…" briefly, then a line like "12 people" — and the send button reads "Send to 12 people".
  4. Tap "Checked in" before anyone has been checked in. — Expect: the count goes to zero and the note under the button reads "No one to notify yet - it will still appear on the event page."
  5. Switch back to "Everyone registered", type a title and a message, and tap the send button. — Expect: a toast "Announcement sent." and you land on the announcement's own page showing the title, the body, "Sent … ago" and a line like "Sent to Everyone registered · 12 notified".
  6. As the other account (registered for the event), open the event page and scroll down past the sign-up area. — Expect: an "Announcements" section shows the new announcement with the host's name and how long ago it was sent — and no delivery counts.
  7. On that account, open "Notifications". — Expect: a row carrying the announcement's title (or "Announcement · <the event's name>" when the host left the title blank); opening it lands on the announcement's own page.
  8. Copy that announcement page's link, sign out, and open it. — Expect: the announcement still renders for a signed-out visitor, without the delivery line.
  9. As the host, send a second announcement to "By shift" and pick one shift. — Expect: the count reflects only that shift's people, and only they get a notification; the announcement still appears on the event page for everyone.

The announcement daily limit — [Web] [Mobile]

  1. As the host, send nine announcements on the same event, then wait a full minute (there is a separate "too fast" limit of ten a minute that shows the same message, so pacing matters), then send a tenth.
  2. Try to send an eleventh. — Expect: an error under the composer reading "Announcement limit reached for today - use the group chat for ongoing discussion." and nothing new appears on the event page or in "Announcements you sent".
  3. [Web] Straight after that, open the host console for the same event (from "Host mode", the "Tickets" row), open "Messages", tap "New message", fill it in and send. — Expect: it sends normally — the announcement limit does not block host messages.
  4. Send a second host message on that event immediately after the first. — Expect: the existing 15-minute cooldown still bites: a red toast reading "That is a lot of activity at once. Wait a moment and try again."
  5. While that cooldown is still running, go back and send an announcement on the same event. — Expect: it sends ("Announcement sent.") — the two limits do not share state.
  6. Send one more announcement on a DIFFERENT event you host. — Expect: it sends — the limit is per event, not per host.

Host analytics: one summary across every event — [Web] [Mobile]

  1. As a host, open your profile, then "Event dashboard". — Expect: the "Analytics" card labeled "Last 30 days" sums every event you host — daily sign-up bars, a check-in ring reading "… of … sign-ups · … events held", volunteer hours per event, and impact rows.
  2. Tap "View full analytics". — Expect: the "Analytics" page answers in one read: the tiles, "Sign-ups over time" with dates along the bottom, and "By event" with one bar per event; an "All events" picker and a "7 days / 30 days / 90 days / Up to a year" range sit at the top, and the old lifecycle tabs are gone.
  3. Change the range. — Expect: the numbers and bars follow the window; "Up to a year" reaches back at most a year.
  4. Tap a "By event" row. — Expect: that event's own page opens. Two events sharing one name stay distinct — each opens its own page with its own numbers.
  5. On the single-event page, compare the "Sign-ups" tile and the "From sign-up to hours" funnel. — Expect: they start from the same number — the funnel no longer has a page-view step, so an event nobody web-viewed no longer shows an all-blank funnel next to a real sign-up count.
  6. Sign in as someone registered but not a host and open the analytics page address. — Expect: "Not a host here" / "Only the event's hosts and staff can see its analytics."
  7. Put the device in airplane mode and reopen the dashboard card. — Expect: "Numbers are unavailable" with "Try again"; the rest still works.

A chat photo while it is being checked — [Web] [Mobile]

  1. Open an event's group chat (from "Host mode", the "Open group chat" row under "Communicate") and tap "Attach a photo or video" → "Photo library", pick a photo and send it.
  2. On the SECOND account, open the same chat within a few seconds of the send — this is the half that changed. — Expect: the photo is in the message. Before this change the second account saw a bubble with no image until the checks finished.
  3. On the sending account, reload the page (web) or force-quit and reopen the app (mobile) straight away, and open the chat. — Expect: the photo is still in the message. (Without reloading, the sender sees their own photo either way, so the reload is what proves the server half.)
  4. Repeat steps 1–3 in a direct message, a group chat and a report's chat. — Expect: the same in all four.
  5. Wait a minute and reload again. — Expect: the photo is still there, now the processed version.

Photos everywhere else still render — [Web] [Mobile] [Admin]

  1. Open a published photo report on the map, an event with a cover image and a photo gallery, an organization page with a logo, and a profile with an avatar. — Expect: all render exactly as before; no image has gone missing.
  2. Open a report you filed yourself while its photo is still being checked. — Expect: you still see your own photo, and other people still see "Photos are still processing..." — unchanged.
  3. [Admin] Open Moderation and find an item with media. — Expect: operators still see the media thumb, including for anything the checks held or rejected.

A pin now gives back a street address — [Web] [Mobile]

  1. As the host, start "Host an event" and reach the "Where do you meet?" step. Drop the pin on an ordinary city address. — Expect: "Looking up the address for this pin..." for a moment, then "Meeting address" fills with a street line including a house number — not "Los Angeles, CA".
  2. Drop the pin mid-block on a road with no house numbers, near a corner. — Expect: a street line, and where two roads meet, both road names.
  3. Drop the pin beside a named public place — a park, a pier, a library. — Expect: "Near <the place's name>" rather than the place presented as a postal address. Drop it on a private house instead. — Expect: you never get the household's name; you get the street line or nothing.
  4. Drop the pin in open water or deep inside a large park. — Expect: nothing prefills and you are asked to type the address: "We couldn't find a street address for this pin in - enter the meeting address." The city in that sentence must be right even though no street was found.
  5. Drop the pin at the same coordinates from a SECOND account (or a fresh browser profile / a fresh app launch). — Expect: the address paints with no visible lookup pause — the server remembers the answer for a spot rather than asking again.
  6. Turn the network off, then drop a pin. — Expect: the field stays empty and asks you to type one — no spinner stuck forever, no error screen.

The address goes with the event, and stays with it — [Web] [Mobile]

  1. Publish an event with a confirmed address. Open it as someone else. — Expect: the address under the date, and "open in a maps app" searches for that text rather than dropping on bare coordinates.
  2. Open an event created BEFORE this change that already had a spot name. — Expect: that text is still its address, and it too now opens in a maps app by text.
  3. Open an event created before this change that had NO address. — Expect: the page still reads "Meeting point on the map" — nothing was invented for it.
  4. Duplicate an event whose address is very short (an old one-or-two-word spot name). — Expect: "Copy created" — the copy keeps that address verbatim and is not rejected for being too short.
  5. Add an event to your calendar from your ticket. — Expect: the downloaded calendar entry now carries the street line as its location, and your calendar app can find it.

An event published by an OLDER app build — [Mobile]

  1. From a device still on the previous TestFlight/store build, create an event, naming the spot as you always did. — Expect: it publishes — no error. Open it in the new web app: your text is its address.
  2. From that same old build, create an event and leave the spot name blank. — Expect: it publishes, and the event page shows a street address the server worked out for the pin, instead of just "Meeting point on the map".
  3. Do the same from a pin where nothing can be found. — Expect: it publishes and the page falls back to "Meeting point on the map" — a rough "City, ST" line is never passed off as a meeting address.

Where a report's address comes from — [Web] [Mobile]

  1. File a report and submit WITHOUT touching "Where is it?". — Expect: the report page shows a street address the server worked out for your pin.
  2. File a report after typing your own words into that field. — Expect: the report page shows your words, and they win over anything the server could have found.
  3. File a report from a spot nothing resolves. — Expect: it files, and the page simply has no address line.
  4. File one with the geocoder unreachable (network off mid-flow, or right after a burst of pin drags). — Expect: it still files. Filing must never fail because an address could not be found.
  5. Type something abusive into "Where is it?" and submit. — Expect: it is refused with "Please check the photo and location, then try again." — that field is now checked the same way the title and description always were. Check a normal street name is never caught by this.
  6. [Web] Sign out and file a report as a guest through the check-you-are-human step. — Expect: identical address behaviour to signing in.

A guest RSVP, start to finish — [Web] [Mobile]

  1. Sign out (or open a private window) and open an event that takes sign-ups. Under the sign-up area, tap "No account? RSVP as a guest". — Expect: the "RSVP to this event" sheet, offering "Sign in or create account" and "Continue as guest".
  2. Tap "Continue as guest", fill in "Your name" and an email under "Email address", and tap "Send code". — Expect: "Enter the 6-digit code we sent to ."
  3. Read the email. — Expect: subject "Your code to RSVP for "; the code sits in a large block of its own under a line ending in "is:", with the expiry sentence beneath it in small grey type. It is no longer one running sentence with the code buried in it.
  4. Enter the code and tap "Confirm RSVP". — Expect: "You're on the list" with a line saying the confirmation went to your address.
  5. Read the confirmation email. — Expect: subject "You are on the list for "; the event's name as a heading, a "When" / "Where" block under it, then "You are on the list. Check in by name when you arrive; there is no ticket to print." — it does NOT promise a ticket or a ticket page — then "Plans changed? Cancel your RSVP so someone else can take the place." and a "Cancel RSVP" button.
  6. Look at the bottom of that email. — Expect: the footer sits on its own lines rather than running together, any link in it is clickable, and it reads "This mailbox is not monitored." — the old "Reply to this email to respond." is gone.
  7. Tap "Cancel RSVP". — Expect: the web page "Cancel your RSVP?"; use "Cancel my RSVP" and you get "Your RSVP is cancelled".
  8. RSVP again as the same guest with the same email. — Expect: it works; a new code arrives and you are back on the list. The cancel link from the FIRST confirmation no longer works — the newest confirmation's button is the live one.
  9. As the host, open "Host mode" on that event and look at the roster, then run check-in. — Expect: the guest appears by name in both, tagged "Guest". Check them in by name, which is what the confirmation email tells the guest to expect.
  10. [Web] From the host console, export the roster. — Expect: the guest's row carries their email and phone alongside a column saying they are a guest; the file's own notes say member contact is never included and that guest contact goes blank once the 30-day scrub has run.
  11. Ask for the code many times in a row. — Expect: you are held off with "Too many requests. Wait a moment and try again." rather than the codes continuing.
  12. Enter a wrong code several times. — Expect: "That code isn't right, or it may have expired. Check it and try again.", then "Too many tries. Start over to get a new code."

Every other email got the same treatment — [Web] [Admin]

  1. Invite someone to an event's "Team" by email and read what arrives. — Expect: subject "You've been invited to help run ", the detail laid out in paragraphs, a "View the invitation" button, and a closing grey note that it expires in 14 days.
  2. Invite someone to an organization by email. — Expect: subject " invited you to join on civfix" and an "Accept the invitation" button.
  3. [Admin] Approve an organization's verification, then read the owner's mail. — Expect: subject " is now verified as a on civfix" with a "View the profile" button.
  4. [Admin] Reject one with a reason. — Expect: subject "Your verification application for was not approved", the reason shown under a "Reason" heading as a quoted block, and a "Re-apply for verification" button.
  5. Ask for your data export from Settings → "Account" and read the mail. — Expect: the branded shell with "Your civfix data export" as a heading, the JSON file attached, and what is and is not included spelled out — not one long paragraph.
  6. Send a host broadcast to yourself and read the footer. — Expect: each footer line on its own line, and the manage/unsubscribe links clickable rather than bare text.
  7. Open any of these on a phone mail client. — Expect: the card, the button and the footer all stay readable at phone width.

Refreshing reshuffles, paging never repeats — [Web] [Mobile]

  1. Open the feed and note the order of the first ten cards; pull to refresh three or four times, noting each order. — Expect: the order varies between refreshes among similar cards while the strongest stay near the top — never identical every time, never nonsense.
  2. After a refresh, scroll steadily to the very end. — Expect: no card repeats and the list ends with "You're all caught up". On the previous build a sparse feed stopped after one page while claiming more remained.

Distance outranks popularity — [Web] [Mobile]

  1. As account X (whose own pinned report fixes their location), find one post attached to a report near that pin and one attached to a far-away report with similar likes and age. — Expect: the nearby one ranks decisively higher — location is now the strongest single factor.

The second account on one phone gets pushes again — [Mobile]

  1. As A on a phone (TestFlight build), sign in, allow notifications, and confirm one arrives. Sign out. Sign in as B on the same phone. Have someone reply to B's post. — Expect: B's banner arrives. Before this fix the second account never received another notification until the app was reinstalled.

Posting has a human speed limit — [Web] [Mobile]

  1. Publish short posts in quick succession. — Expect: around the thirteenth within a minute, a friendly try-again-later error; a minute later posting works again. Nothing already published is lost.

Regression

Posting, liking, reposting, replying — [Web] [Mobile]

  1. Post, like, repost, reply, and undo each as before. — Expect: all actions behave unchanged on the actor's own device — one smooth count change, no double-flicker.
  2. Have a post by someone you follow that is a REPLY. — Expect: no "new post" pill for replies, and replies never appear as feed cards.

Blocks and visibility — [Web] [Mobile]

  1. As X, block Y, then have Y post and like things. — Expect: no pill, no count patches, and Y's posts absent from X's feed after refresh.
  2. Delete a post from another account after X loaded page 1, then have X scroll on. — Expect: the deleted post never appears; a slightly short page is fine.

Feed error and empty states — [Web] [Mobile]

  1. With the backend unreachable (or on a spotty connection), open "Home". — Expect: "The feed could not load" / "Check your connection and try again." with "Try again".
  2. Guest with no data: — Expect: "Nothing here yet" with the "Sign in" link line.

The existing host messages — [Web]

  1. From "Host mode" open the "Tickets" row to reach the host console for that event, open "Messages" and tap "New message". Fill "Subject" and "Message", pick a channel under "Channels" and "Send now". — Expect: it sends and lands in the list with its delivery counts, exactly as before.
  2. Save a draft, schedule a message, and cancel a scheduled one. — Expect: "Draft saved", "Message scheduled" and "Cancel this message?" all behave as before.
  3. Open "Deliveries" on a sent message. — Expect: the same statuses as before ("Sent", "Failed", "Suppressed" …) and the privacy note still shown.
  4. Register for the event as a new person after the announcement went out. — Expect: the confirmation email/notification still arrives as before.

Event page and event dashboard — [Web] [Mobile]

  1. Open an event with no announcements at all. — Expect: no "Announcements" section and no empty gap where it would be; sign-up, shifts, tickets and the donation card sit where the app PR puts them.
  2. Cancel an event and open it. — Expect: the cancellation notice arrives as before and announcing is not offered.
  3. Open "Event dashboard" from your profile and check that "Up next", the "Invitations" card, the analytics carousel, the "Upcoming"/"Past" filter, sign-up counts and the row menu all still work.

The map's own labels are unchanged — [Web] [Mobile]

  1. Long-press the map to drop a pin. — Expect: the label under it still reads the rough "City, ST" line, or the exact coordinates when nothing resolves. It must NOT start showing a street address — that surface was deliberately left alone.
  2. On the event "Where do you meet?" step and the report location step, look at the compact location button. — Expect: same rough label as before, sitting alongside (not instead of) the new address field.
  3. Read the "Where this goes" card on the report review step. — Expect: "Resolving where this routes..." then one of the usual routing sentences, exactly as before — it is a separate thing from the address and neither must block the other.
  4. Drag a pin around rapidly for a minute across the event and report flows. — Expect: the address, the map label, the routing line and the place search all keep working, and recover on their own if you briefly exhaust the shared limit.

Creating, editing and cancelling an event — [Web] [Mobile]

  1. Create a slot event, a ticketed event and a route event; edit each; cancel one. — Expect: every existing behaviour and every existing error message unchanged.
  2. Start "Host an event" from a report ("Host an event" on a report page). — Expect: the seeded location resolves its address the same way.
  3. Publish with the API down. — Expect: "We could not publish right now. Please try again." — unchanged.

Profile, feed and admin reads of an address — [Web] [Mobile] [Admin]

  1. Open a person's profile and their hosted events; open a shared event card in the feed. — Expect: the lists load and render as before.
  2. [Admin] Open Reports, select a recent report, and read its location line. — Expect: it shows the resolved address. A place-name answer shows bare there (the operator dashboard has no "Near" wording), and a report with no address still falls back to its jurisdiction name.
  3. [Admin] Open Events, select an event, and read its location line and its linked reports. — Expect: unchanged shape; older events read exactly as they did.

Guests a host already had — [Web] [Mobile] [Admin]

  1. On an event with guests from before this change, open the roster, the check-in list and the attendee list on the event page. — Expect: exactly as before — guests by name and tagged "Guest" on the roster and at check-in, and absent from the attendee list and the shift lists, where they only count towards the number going.
  2. Send a broadcast to "Everyone registered" on an event with both guests and account holders. — Expect: both get it, and the delivery counts read as they did before.
  3. Cancel a guest's RSVP from their cancel link, then broadcast again. — Expect: the cancelled guest is not counted and not written to.

Check-in, tickets and volunteer hours — [Web] [Mobile]

  1. Scan or check in an attendee, then log volunteer hours for the event. — Expect: unchanged; the analytics tiles pick the new numbers up on the next open.

Admin — reports, hosts and organizations — [Admin]

  1. Open admin → Reports and page with "Load more". — Expect: the list loads and pages as before (row visuals change only with the admin PR; without it, pins as before). List load may be marginally slower on photo-heavy pages.
  2. Open "Host messaging", pick a host and read "Broadcast log". — Expect: every existing kind still shows its own label and counts; with the admin PR deployed, announcements show as "Announcement".
  3. In Organizations, open an organization, change a member's role, invite someone and remove a member with a reason. — Expect: all unchanged.

Announcements in the web host console — [Web]

  1. Open the host console for an event that has both host messages and announcements, and open "Messages". — Expect: the announcements appear in the same list, tagged "Announcement", alongside the host messages tagged "Host message"; opening a host message and its "Deliveries" still works as before.

Session across the deploy — [Web] [Mobile]

  1. A session already scrolling the feed when the deploy lands keeps its old page cursor. Scroll on without reloading. — Expect: the next page loads (served chronologically); after a pull-to-refresh the ranked order takes over.

Not covered

⚠️ For the owner — a live config and spend decision this PR does not settle. The address ladder can be answered by either of two lookup services. The free one is always available and needs no key; the paid one is used only when a token is set on the server, and it is what produces a proper "123 Main St" instead of a corner or a place name. That token IS already set in both the staging and the production secrets, so both environments will start using the paid service the moment this deploys — and this change makes far more lookups than the old city-only label ever did. Two things follow: the cost of that account should be looked at before this reaches production, and if it is ever turned off, address quality drops noticeably rather than breaking. Nobody has priced the new call volume in this session.

  • 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 run pnpm email:gallery <a folder> and open the index.html it 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-geom in 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.

@theobong theobong changed the title Ranked home feed with live updates; admin report thumbnails (#100, #118) Ranked home feed, event announcements, event analytics, chat media serving (#100, #118, #122) Sep 17, 2026
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>
theobong and others added 7 commits September 16, 2026 22:07
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>
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

Not safe to merge until the outstanding feed-pagination and portfolio-total correctness issues are fixed, along with the required repository-rule items.

Fix All in Claude CodeFindings

  1. P1 Keep fallback pagination stable ▶
  2. P1 Report Complete Portfolio Totals ▶
  3. P2 Count active slot signups ▶
  4. P2 Use consistent attendance units ▶
  5. P2 Add deployment token entries ▶
  6. P2 Configure feed ranking secrets ▶
  7. P2 Remove migration comments ▶
Fix with agent prompt
### Issue 1
services/api/src/services/post-service.ts:333-335
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.

### Issue 2
services/api/src/services/host/analytics-service.ts:370-407
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.

### Issue 3
services/api/src/services/host/event-analytics-repository.drizzle.ts:121-124
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.

### Issue 4
services/api/src/services/host/event-analytics-repository.drizzle.ts:187-190
`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.

### Issue 5
services/api/.env.example:undefined-231
`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.

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!

### Issue 6
services/api/.env.example:undefined-118
`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.

### Issue 7
services/api/drizzle/0176_posts_geom.sql:1-41
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.

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!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (10) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

Comment thread services/api/src/services/post-service.ts Outdated
Comment thread services/api/src/services/host/announcement-service.ts Outdated
Comment on lines +121 to +124
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Artifacts

Evidence from the check

  • Authored and executed source that captures the SQL emitted by the slot-claims query.

Command output from the check

  • Captured SQL shows the query joins slot claims to slots without a registration-status filter, registration or seat join, or guest representation.

Command output from the check

  • Live PostgreSQL output shows a cancelled member claim counted in the slot panel while the only active registration was a guest.

Command output from the check

  • The focused analytics repository SQL suite completed successfully while not covering the cancelled-claim and active-guest discrepancy.

View artifacts

T-Rex 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.

Fix in Claude Code

Comment on lines +187 to +190
(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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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

Evidence from the check

  • Authored SQL creates one single-seat party and executes the current calculation shape, showing the control condition where party and seat units coincide.

Command output from the check

  • Executed PostgreSQL output for the single-seat control shows signups 1, check-in rate 1, and fill rate 0.5, establishing the consistent baseline.

Evidence from the check

  • Authored and executed PostgreSQL script reproduces the repository query and compares it with seat-based metrics for identical two-person-party data.

Command output from the check

  • 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.

View artifacts

T-Rex 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.

Fix in Claude Code

@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Comments Outside Diff

These 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.

  • P1 Protect final administrator services/api/src/services/host/organization-repository.drizzle.ts:1194 ▶

    The operator role-change path updates an admin to member without applying the remaining-administrator check used by the ordinary role-change path. The reproduced flow demoted the sole admin and left an organization with no owner or admin seat. Apply the same locked seat-count check and return the existing last-administrator outcome before updating the role.

  • P2 Keep overview ranges consistent services/api/src/services/host/analytics-service.ts:150 ▶

    When a host selects a 7-, 30-, or 90-day window, this call obtains unbounded event KPIs while page views, donation clicks, and the registration series use the selected window. A reproduced 7-day response returned 100 lifetime registrations and 80 lifetime check-ins alongside 7 in-window page views and 2 donation clicks. The overview and its derived rates therefore describe incompatible periods, which can mislead attendance and engagement decisions. Apply the selected window to the KPI query or expose these as clearly labeled lifetime totals.

Comment thread services/api/.env.example
# ===== 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)

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!

Fix in Claude Code

Comment thread services/api/.env.example
# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Comment on lines +333 to +335
const { ranked } = await rankCandidateSet(viewerId, query, location, false)
persistSnapshot(viewerId, query.filter, ranked)
return pageFrom(viewerId, sliceAfterCursor(ranked, cursor), limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Evidence from the check

  • Authored TypeScript harness that invokes the current service for an in-bucket control and cross-bucket continuation, showing the exact test setup.

Command output from the check

  • Captured execution output for the harness, showing no control overlap and eight posts skipped after the bucket changed; the claim is confirmed.

Command output from the check

  • Captured command output containing the exact authored harness source used for the deterministic reproduction.

View artifacts

T-Rex 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.

Fix in Claude Code

Comment on lines +1 to +41
-- =============================================================================
-- 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).
-- =============================================================================

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)

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!

Fix in Claude Code

@greptile-apps

greptile-apps Bot commented Sep 22, 2026

Copy link
Copy Markdown

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.

Comment on lines +372 to +409
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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Evidence from the check

  • 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.

Command output from the check

  • The executed command captured the authored harness source used to exercise the real summary service, ending with the assertions for complete and truncated portfolios.

Command output from the check

  • The baseline command ran the harness with 200 seeded events and returned complete 200-event aggregate values with exit code 0.

Command output from the check

  • 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.

Command output from the check

  • The targeted Vitest command ran the host analytics test file and passed all 43 tests after the reproduction harness was added.

View artifacts

T-Rex 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.

Fix in Claude Code

@theobong
theobong merged commit 0232bb4 into main Sep 23, 2026
3 checks passed
@theobong
theobong deleted the feat/feed-ranking-batch branch September 23, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant