Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/erasure-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ response can be answered truthfully. It is the source of record for the

An account closure must not cancel events other people are running, so
`softDeleteAndAnonymize` walks a ladder before it cancels anything
(`transferHostedEvents` / `releaseOrganizations` in `pg-stores.ts`, all inside the
one erasure transaction):
(`transferHostedEvents` / `releaseOrganizations` in `erasure-repository.drizzle.ts`,
called from `pg-stores.ts`, all inside the one erasure transaction):

1. **The owning organization's owner** takes over any `upcoming`/`active` event
whose `organization_id` points at a live organization with a live owner. A
Expand Down Expand Up @@ -149,7 +149,7 @@ Two rungs of that scrub exist for a reason:
- **A `cohost`, `coordinator` or `staff` row on someone else's event is stepped
down to `member`.** The organizer rung already demotes the departing organizer;
without this one a tombstone stays on the team roster as a "Deleted User" cohost and
keeps receiving the realtime host-team signals (`hostTeamUserIds`). Each
keeps receiving the realtime host-team signals (`HostTeamRepository.listTeamUserIds`). Each
step-down writes an `event.team_role_changed` audit row with a **null actor**:
nobody performed it; the erasure did.

Expand Down Expand Up @@ -249,8 +249,8 @@ Verified call sites (all public projections):
| Report **discussion** comments | `services/api/src/services/discussion-service.ts` (`toAuthorDTO`) | Renders "Deleted User", no handle, `deleted: true`. |
| Cleanup group **chat** | `services/api/src/services/chat-repository.drizzle.ts` (`toMessageDTO`) | Renders "Deleted User", no handle/avatar, bio nulled, `deleted: true`. |
| **Direct messages** | `services/api/src/services/dm-repository.drizzle.ts` | Uses `publicAuthorIdentity` ("Deleted User"). The surviving party KEEPS the thread in their inbox (the thread list no longer filters the peer on `deleted_at IS NULL`), with the peer rendered as "Deleted User", no handle/avatar/bio, `deleted: true`. |
| **Profiles / people directory / follow lists** | `services/api/src/services/social-repository.drizzle.ts` | Soft-deleted users are **excluded** (`deleted_at IS NULL`): the profile read returns *not found*, and they never appear in the directory, follower/following lists, search, or @-mention pickers. |
| @-mention resolution | `services/api/src/services/social-repository.drizzle.ts` | Excludes soft-deleted users. |
| **Profiles / people directory / follow lists** | `services/api/src/services/social-repository.drizzle.ts` (search: `user-search-repository.drizzle.ts`) | Soft-deleted users are **excluded** (`deleted_at IS NULL`): the profile read returns *not found*, and they never appear in the directory, follower/following lists, search, or @-mention pickers. |
| @-mention resolution | `services/api/src/services/mention-targets-repository.drizzle.ts` | Excludes soft-deleted users. |
| Cleanup report galleries | `services/api/src/services/cleanup-repository.drizzle.ts` | Joins exclude `deleted_at IS NOT NULL` rows. |

**Linked-report reconcile is visibility-scoped.** Because a host's
Expand Down
2 changes: 1 addition & 1 deletion docs/inbound-mail-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ The verdict is: a hard `failed` on the newest attempt → failed; a `deadline` f
in flight; any other `failed` → failed; no event and younger than the stale window → in flight; no event
and older than the stale window → crashed claim.

`sendInFlightExpr` (`services/api/src/services/admin/outbound-send-sql.ts`) is the in-flight half of that
`sendInFlightExpr` (`services/api/src/services/admin/mail-repository.drizzle.ts`) is the in-flight half of that
verdict, read from the same newest attempt: no `sent` event, and either a `deadline` failure inside the
window or no `failed` event at all while the outbound row is younger than the stale window. The outbound
row is inserted before transmission starts, so an attempt with no outcome yet is a send still on the
Expand Down
4 changes: 2 additions & 2 deletions docs/report-takedown.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ auth + csrf, rate-limited). The handler:

1. Files a `user_report` moderation item into the existing admin queue
(`moderation_items`, the same queue operators already read).
2. **Detects ownership server-side** (`reportOwnedBy`): when the `report`
2. **Detects ownership server-side** (`isReportOwnedBy`): when the `report`
subject's `reporter_user_id` equals the caller, the item is marked distinctly
so an operator can fast-track an owner-consented removal:
- `flag = "Owner takedown request"` (vs. `"User report"` for third-party reports),
Expand All @@ -42,7 +42,7 @@ spam the queue.

## Offline / no-DB behavior

`reportOwnedBy` is DB-gated: with no `DATABASE_URL` (all-fakes boot) it returns
The owner check is DB-gated: with no `DATABASE_URL` (all-fakes boot) the route returns
`false`, so the route degrades to the ordinary user-report path (the request is
still filed, just not flagged as an owner takedown). A query error is not caught:
it propagates and the request fails with 500 (not filed), so a transient DB error
Expand Down
9 changes: 6 additions & 3 deletions docs/retention-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ so a backlog drains over several daily runs rather than one long-locking DELETE.
(`runRetentionSweep`): pure deps, injectable clock/log/report, **never throws**
(a per-table failure is counted + reported to GlitchTip; the other tables still
run). Mirrors the orphan-sweep job shape exactly.
- Statements: `services/api/src/services/retention-repository.drizzle.ts` (imported by the worker as
`@civfix/api/retention-repo`).
- Registration: `services/media-worker/src/worker.ts`: `RETENTION_SWEEP_JOB`
queue + worker + `jobs.schedule(RETENTION_SWEEP_JOB, RETENTION_SWEEP_CRON)`.
- Schedule: `services/media-worker/src/config.ts`: `RETENTION_SWEEP_CRON`
Expand Down Expand Up @@ -336,9 +338,10 @@ retention rule and that is deliberate**:
**Indexing is the real constraint.** Every predicate above must be reachable
through `mail_events_thread_idx` (`thread_id`); the table has no `message_id`
index, so a per-attempt subquery filtered only on `message_id` seq-scans it.
`services/api/src/services/admin/outbound-send-sql.ts` is the single place those
expressions are built, and every `mail_events` subquery there carries the thread
filter, asserted offline by
The send-state fragments (`sendInFlightExpr`, `sendFailedExpr`, `attemptEventExists`
in `services/api/src/services/admin/mail-repository.drizzle.ts`) are the single place
those expressions are built, and every `mail_events` subquery in them carries the
thread filter, asserted offline by
`services/api/test/unit/outbound-send-sql.test.ts`.

**Pending decision (not taken here):** if outbound volume ever makes the table
Expand Down
1 change: 1 addition & 0 deletions services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./db": "./src/db/worker-db.ts",
"./media-repo": "./src/services/media-worker-repo.ts",
"./inbound-retention-repo": "./src/services/admin/inbound-retention-repository.drizzle.ts",
"./retention-repo": "./src/services/retention-repository.drizzle.ts",
"./geocode-cache": "./src/services/geocode-cache.ts",
"./anon-hold-release": "./src/services/anon-hold-release.ts",
"./anon-hold-repo": "./src/services/anon-hold-release-repo.drizzle.ts",
Expand Down
30 changes: 16 additions & 14 deletions services/api/src/adapters/geocoder.tiger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
import { AppError } from "@civfix/shared"
import type { Geocoder } from "@civfix/shared/interfaces"
import type { Sql } from "../db/client.js"
import { resolveJurisdiction } from "../db/sql/jurisdiction.js"
import {
makeDrizzleJurisdictionRepository,
type JurisdictionRepository,
} from "../services/jurisdiction-repository.drizzle.js"

/** A fixed public Census code list, so it lives in-process rather than in a lookup table. */
const STATE_FIPS_TO_USPS: Readonly<Record<string, string>> = {
Expand Down Expand Up @@ -105,29 +108,28 @@ export class TigerGeocoder implements Geocoder {
this.getSql = options.getSql
}

/** Note the argument order flip: this takes (lat, lng) but resolveJurisdiction takes (lng, lat). */
/** Note the argument order flip: this takes (lat, lng) but the jurisdiction repository takes (lng, lat). */
async cityStateLabel(lat: number, lng: number): Promise<string | null> {
const sql = this.getSql()
const jurisdictions = makeDrizzleJurisdictionRepository(this.getSql())

const resolved = await resolveJurisdiction(sql, lng, lat)
const resolved = await jurisdictions.resolveContaining(lng, lat)
if (!resolved) return null

// The geoid prefix is trusted only on FIPS-hierarchical layers; a federal/tribal numeric id (e.g. a
// BIA/ArcGIS OBJECTID) would yield a valid-but-WRONG state (see file header).
const fipsLayer = FIPS_HIERARCHICAL_LAYERS.has(resolved.layer)
const usps =
(fipsLayer ? uspsFromGeoid(resolved.geoid) : null) ?? (await this.stateAbbrFor(sql, lng, lat))
(fipsLayer ? uspsFromGeoid(resolved.geoid) : null) ??
(await this.stateAbbrFor(jurisdictions, lng, lat))
return formatCityStateLabel(resolved.name, usps)
}

private async stateAbbrFor(sql: Sql, lng: number, lat: number): Promise<string | null> {
const rows = await sql<{ geoid: string }[]>`
SELECT geoid
FROM jurisdictions
WHERE layer = 'state'
AND ST_Contains(geom, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326))
LIMIT 1
`
return uspsFromGeoid(rows[0]?.geoid ?? "") ?? null
private async stateAbbrFor(
jurisdictions: JurisdictionRepository,
lng: number,
lat: number,
): Promise<string | null> {
const geoid = await jurisdictions.containingStateGeoid(lng, lat)
return uspsFromGeoid(geoid ?? "") ?? null
}
}
Loading
Loading