From 4b48fc95426c621513871ee276d1f3ae83e0bb13 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 15:43:46 +0000 Subject: [PATCH 01/20] one cursor module: admin pagination folds into the db cursor helpers --- services/api/src/db/cursor-helpers.ts | 72 +++++++++++++++++ .../admin/activity-repository.drizzle.ts | 12 +-- .../admin/activity-repository.memory.ts | 2 +- .../admin/admin-event-repository.drizzle.ts | 10 +-- .../admin/admin-event-repository.memory.ts | 2 +- .../admin/admin-report-repository.drizzle.ts | 10 +-- .../admin/admin-report-repository.memory.ts | 2 +- .../admin/admin-user-repository.drizzle.ts | 18 ++--- .../admin/admin-user-repository.memory.ts | 8 +- .../admin/audit-repository.drizzle.ts | 10 +-- .../services/admin/audit-repository.memory.ts | 18 +---- .../admin/discovery-repository.drizzle.ts | 9 ++- .../admin/discovery-repository.memory.ts | 4 +- .../admin/gov-claims-repository.drizzle.ts | 8 +- .../admin/gov-claims-repository.memory.ts | 2 +- .../admin/inbound-repository.drizzle.ts | 8 +- .../admin/inbound-repository.memory.ts | 21 ++--- ...urisdiction-contacts-repository.drizzle.ts | 3 +- ...jurisdiction-contacts-repository.memory.ts | 3 +- .../services/admin/mail-repository.drizzle.ts | 8 +- .../services/admin/mail-repository.memory.ts | 26 ++---- .../admin/moderation-repository.drizzle.ts | 8 +- .../admin/moderation-repository.memory.ts | 23 ++---- services/api/src/services/admin/pagination.ts | 81 ++----------------- .../api/src/services/dm-repository.drizzle.ts | 11 +-- .../host/host-team-repository.memory.ts | 14 ++-- .../host/organization-repository.memory.ts | 17 ++-- .../host/registration-repository.memory.ts | 41 +++++----- .../services/threads-repository.drizzle.ts | 18 +---- services/api/src/services/threads-service.ts | 9 +-- .../volunteer-hours-repository.memory.ts | 9 +-- .../api/test/unit/admin-discovery.test.ts | 4 +- .../test/unit/admin-keyset-precision.test.ts | 12 +-- .../api/test/unit/admin-pagination.test.ts | 2 +- .../test/unit/keyset-cursor-forged.test.ts | 3 +- 35 files changed, 220 insertions(+), 288 deletions(-) diff --git a/services/api/src/db/cursor-helpers.ts b/services/api/src/db/cursor-helpers.ts index d6f75b59..d436f496 100644 --- a/services/api/src/db/cursor-helpers.ts +++ b/services/api/src/db/cursor-helpers.ts @@ -160,6 +160,22 @@ export function keysetPredicate( : sql`(${ts}, ${id}) < (${anchorAt}, ${anchorId})` } +// `activity` must already be truncated to milliseconds, the precision of the Date cursor; the two clauses +// together are `(activity, id) < cursor`. +export function msKeysetFilter( + sql: Queryable, + activity: postgres.Fragment, + id: postgres.Fragment, + cursor: TimeCursor | null | undefined, +): postgres.Fragment { + if (cursor === null || cursor === undefined) return sql`` + const msCeiling = new Date(cursor.at.getTime() + 1) + return sql` + AND ${activity} < ${msCeiling} + AND (${activity} < ${cursor.at} OR ${id} < ${cursor.id}::uuid) + ` +} + export interface NameCursor { name: string id: string @@ -198,6 +214,36 @@ export function encodeNearCursor(c: NearCursor): string { return `${c.dist}|${c.id}` } +/** + * Offset paging serves the jurisdictions directory: static reference data browsed under an arbitrary sort + * (population / reports / name), where keyset's drift-immunity buys nothing and a free choice of ORDER BY + * is worth more. The cursor stays opaque so the wire `nextCursor` contract is unchanged. + */ +export function encodeOffsetCursor(offset: number): string { + return Buffer.from(JSON.stringify({ o: Math.max(0, Math.floor(offset)) }), "utf8").toString( + "base64url", + ) +} + +/** + * The cursor is opaque but not authenticated, so a caller can mint one carrying any integer, and an + * unbounded OFFSET makes Postgres walk and discard that many rows per request. Mirrors clampOffset in + * services/volunteer-hours-service.ts. The directory is in the low thousands of rows, so a legitimate + * deep page is never near this. + */ +export const ADMIN_MAX_OFFSET = 100_000 + +export function decodeOffsetCursor(cursor: string | null | undefined): number { + if (!cursor) return 0 + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { o?: unknown } + const o = typeof parsed.o === "number" && Number.isFinite(parsed.o) ? Math.floor(parsed.o) : 0 + return Math.min(Math.max(0, o), ADMIN_MAX_OFFSET) + } catch { + return 0 + } +} + export function pageWith( rows: readonly T[], limit: number, @@ -238,3 +284,29 @@ export function paginateKeyset( return anchor.atText !== null ? encodeKeysetCursor(anchor.atText, anchor.id) : null }) } + +export function isBeforeTimeCursor(atMs: number, id: string, anchor: TimeCursor | null): boolean { + if (anchor === null) return true + const anchorMs = anchor.at.getTime() + return atMs < anchorMs || (atMs === anchorMs && id < anchor.id) +} + +/** + * Pages rows pre-sorted (at DESC, id DESC). An anchor row that has since left the list does not end + * paging, unlike pageInMemoryById: the page continues from the anchor's instant. + */ +export function pageBeforeTimeCursor( + rows: readonly T[], + anchor: TimeCursor | null, + limit: number, + keyOf: (row: T) => TimeCursor, +): { items: T[]; nextCursor: string | null } { + return pageWith( + rows.filter((row) => { + const key = keyOf(row) + return isBeforeTimeCursor(key.at.getTime(), key.id, anchor) + }), + limit, + (last) => encodeTimeCursor(keyOf(last)), + ) +} diff --git a/services/api/src/services/admin/activity-repository.drizzle.ts b/services/api/src/services/admin/activity-repository.drizzle.ts index df1bccfc..cdd7cdd0 100644 --- a/services/api/src/services/admin/activity-repository.drizzle.ts +++ b/services/api/src/services/admin/activity-repository.drizzle.ts @@ -21,14 +21,14 @@ */ import type { Sql, SqlFragment } from "../../db/client.js" +import { clampLimit } from "./pagination.js" import { - clampLimit, - decodeCursor, keysetInstant, keysetPredicate, paginateKeyset, - type KeysetAnchor, -} from "./pagination.js" + parseKeysetCursor, + type KeysetCursor, +} from "../../db/cursor-helpers.js" import { AUDIT_READ_ACTIONS } from "./audit.js" import { likePrefix } from "./like.js" import { anyOf, ilikeAnyOf } from "./sql-fragments.js" @@ -71,7 +71,7 @@ function toRecord(r: ActivityRowSelect): ActivitySourceRecord { } interface PageSpec { - anchor: KeysetAnchor | null + anchor: KeysetCursor | null desc: boolean limit: number } @@ -216,7 +216,7 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { // requireUuid is false: the keyset compares `id::text`, never `${id}::uuid`, so a non-uuid id in a // hand-made cursor cannot raise a 22P02; it simply anchors past every real row. const page: PageSpec = { - anchor: decodeCursor(args.cursor), + anchor: parseKeysetCursor(args.cursor, { requireUuid: false }), desc: args.sort === "newest", limit, } diff --git a/services/api/src/services/admin/activity-repository.memory.ts b/services/api/src/services/admin/activity-repository.memory.ts index 6071e5d5..c139233e 100644 --- a/services/api/src/services/admin/activity-repository.memory.ts +++ b/services/api/src/services/admin/activity-repository.memory.ts @@ -57,6 +57,6 @@ export class InMemoryActivityRepository implements ActivityRepository { // The same (ts, id) total order the SQL keyset uses, so a page boundary lands identically. return byTs !== 0 ? byTs : a.id < b.id ? dir : a.id > b.id ? -dir : 0 }) - return pageInMemoryById(sorted, args.cursor, args.limit, (r) => ({ createdAt: r.ts, id: r.id })) + return pageInMemoryById(sorted, args.cursor, args.limit, (r) => ({ at: r.ts, id: r.id })) } } diff --git a/services/api/src/services/admin/admin-event-repository.drizzle.ts b/services/api/src/services/admin/admin-event-repository.drizzle.ts index ba0d3f35..5d5598a3 100644 --- a/services/api/src/services/admin/admin-event-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-event-repository.drizzle.ts @@ -1,12 +1,12 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" -import { isUuid } from "../../db/cursor-helpers.js" +import { clampLimit } from "./pagination.js" import { - decodeCursor, - clampLimit, + isUuid, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { writeAudit } from "./audit.js" import { adminEventStatusExpr } from "../cleanup-sql.js" import { personSelect } from "./admin-person-sql.js" @@ -152,7 +152,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository args: ListEventsArgs, ): Promise<{ records: AdminEventRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor, true) + const anchor = parseKeysetCursor(args.cursor) const conds: SqlFragment[] = [] if (args.status !== null) { diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/src/services/admin/admin-event-repository.memory.ts index da78e27b..92c54b2c 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -252,7 +252,7 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { // The anchor is the row's scheduled_at (the list's sort key), carried in the cursor's createdAt slot // exactly as the Drizzle repo does. return pageInMemoryById(rows, args.cursor, args.limit, (r) => ({ - createdAt: r.scheduledAt, + at: r.scheduledAt, id: r.id, })) } diff --git a/services/api/src/services/admin/admin-report-repository.drizzle.ts b/services/api/src/services/admin/admin-report-repository.drizzle.ts index c18eeadf..77d863e6 100644 --- a/services/api/src/services/admin/admin-report-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-repository.drizzle.ts @@ -1,13 +1,13 @@ import type { FastifyBaseLogger } from "fastify" import type { Queryable, Sql, SqlFragment } from "../../db/client.js" +import { clampLimit } from "./pagination.js" import { - decodeCursor, - clampLimit, + isUuid, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" -import { isUuid } from "../../db/cursor-helpers.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { writeAudit } from "./audit.js" import { andAll, @@ -208,7 +208,7 @@ export function makeDrizzleAdminReportRepository( args: ListReportsArgs, ): Promise<{ records: AdminReportRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor, true) + const anchor = parseKeysetCursor(args.cursor) const conds: SqlFragment[] = [] if (args.statuses !== null && args.statuses.length > 0) { diff --git a/services/api/src/services/admin/admin-report-repository.memory.ts b/services/api/src/services/admin/admin-report-repository.memory.ts index c7db6551..1a09c51a 100644 --- a/services/api/src/services/admin/admin-report-repository.memory.ts +++ b/services/api/src/services/admin/admin-report-repository.memory.ts @@ -172,7 +172,7 @@ export class InMemoryAdminReportRepository implements AdminReportRepository { }) return pageInMemoryById(rows, args.cursor, args.limit, (r) => ({ - createdAt: r.createdAt, + at: r.createdAt, id: r.id, })) } diff --git a/services/api/src/services/admin/admin-user-repository.drizzle.ts b/services/api/src/services/admin/admin-user-repository.drizzle.ts index 7438917c..1b260244 100644 --- a/services/api/src/services/admin/admin-user-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-user-repository.drizzle.ts @@ -1,14 +1,13 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { writeAudit } from "./audit.js" import { assertTargetIsNotOperatorRole } from "../../auth/operator-target.js" +import { clampLimit } from "./pagination.js" import { - clampLimit, - decodeCursor, keysetInstant, keysetPredicate, paginateKeyset, - type KeysetAnchor, -} from "./pagination.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { andAll, ilikeAnyOf } from "./sql-fragments.js" import type { AdminUserOrganizationRecord, @@ -186,7 +185,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { args: ListUsersArgs, ): Promise<{ records: AdminUserRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeKeyset(args.cursor) + const anchor = parseKeysetCursor(args.cursor) const conds: SqlFragment[] = [] if (args.status !== null) { @@ -272,7 +271,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { limit: number, ): Promise<{ records: UserReportRecord[]; nextCursor: string | null }> { const lim = clampLimit(limit) - const anchor = decodeKeyset(cursor) + const anchor = parseKeysetCursor(cursor) const cursorFilter = anchor !== null ? sql`AND ${keysetPredicate(sql, sql`r.created_at`, sql`r.id`, anchor)}` @@ -320,7 +319,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { limit: number, ): Promise<{ records: UserEventRecord[]; nextCursor: string | null }> { const lim = clampLimit(limit) - const anchor = decodeKeyset(cursor) + const anchor = parseKeysetCursor(cursor) const cursorFilter = anchor !== null ? sql`AND ${keysetPredicate(sql, sql`cm.joined_at`, sql`c.id`, anchor)}` @@ -374,7 +373,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { limit: number, ): Promise<{ records: UserMessageRecord[]; nextCursor: string | null }> { const lim = clampLimit(limit) - const anchor = decodeKeyset(cursor) + const anchor = parseKeysetCursor(cursor) const branchCursor = (createdAt: SqlFragment, id2: SqlFragment): SqlFragment => anchor !== null ? sql`AND ${keysetPredicate(sql, createdAt, id2, anchor)}` : sql`` const probe = lim + 1 @@ -624,9 +623,6 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { } } -const decodeKeyset = (cursor: string | null | undefined): KeysetAnchor | null => - decodeCursor(cursor, true) - function threadFallback(source: "chat" | "group" | "dm" | "report"): string { switch (source) { case "group": diff --git a/services/api/src/services/admin/admin-user-repository.memory.ts b/services/api/src/services/admin/admin-user-repository.memory.ts index de5ecd01..8420c50c 100644 --- a/services/api/src/services/admin/admin-user-repository.memory.ts +++ b/services/api/src/services/admin/admin-user-repository.memory.ts @@ -132,7 +132,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { }) return pageInMemoryById(rows, args.cursor, args.limit, (r) => ({ - createdAt: r.joinedAt ?? new Date(0), + at: r.joinedAt ?? new Date(0), id: r.id, })) } @@ -178,7 +178,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { ), cursor, limit, - (r) => ({ createdAt: r.createdAt, id: r.id }), + (r) => ({ at: r.createdAt, id: r.id }), ) } @@ -191,7 +191,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { [...(this.events.get(id) ?? [])].sort((a, b) => b.whenAt.getTime() - a.whenAt.getTime()), cursor, limit, - (r) => ({ createdAt: r.whenAt, id: r.id }), + (r) => ({ at: r.whenAt, id: r.id }), ) } @@ -206,7 +206,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { ), cursor, limit, - (r) => ({ createdAt: r.createdAt, id: r.id }), + (r) => ({ at: r.createdAt, id: r.id }), ) } diff --git a/services/api/src/services/admin/audit-repository.drizzle.ts b/services/api/src/services/admin/audit-repository.drizzle.ts index ed6a1422..7725c6a2 100644 --- a/services/api/src/services/admin/audit-repository.drizzle.ts +++ b/services/api/src/services/admin/audit-repository.drizzle.ts @@ -1,12 +1,12 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" +import { clampLimit } from "./pagination.js" import { - clampLimit, - decodeCursor, + isUuid, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" -import { isUuid } from "../../db/cursor-helpers.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" import type { WriteAuditInput } from "./audit.js" import { likeContains } from "./like.js" @@ -51,7 +51,7 @@ export function makeDrizzleAuditRepository(sql: Sql): AuditRepository { args: ListAuditArgs, ): Promise<{ records: AuditRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor, true) + const anchor = parseKeysetCursor(args.cursor) const cursorFilter = anchor !== null ? sql`AND ${keysetPredicate(sql, sql`a.created_at`, sql`a.id`, anchor)}` diff --git a/services/api/src/services/admin/audit-repository.memory.ts b/services/api/src/services/admin/audit-repository.memory.ts index f2f1f29b..afa97a54 100644 --- a/services/api/src/services/admin/audit-repository.memory.ts +++ b/services/api/src/services/admin/audit-repository.memory.ts @@ -1,5 +1,5 @@ import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" -import { decodeCursor, paginate, type CursorAnchor } from "./pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" export interface SeedAuditInput { id?: string @@ -32,7 +32,7 @@ export class InMemoryAuditRepository implements AuditRepository { } async list(args: ListAuditArgs): Promise<{ records: AuditRecord[]; nextCursor: string | null }> { - const anchor = decodeCursor(args.cursor) + const anchor = parseKeysetCursor(args.cursor, { requireUuid: false }) const actor = args.actor?.toLowerCase() ?? null const action = args.action?.toLowerCase() ?? null const target = args.target?.toLowerCase() ?? null @@ -49,10 +49,8 @@ export class InMemoryAuditRepository implements AuditRepository { }) const sorted = [...filtered].sort(compareDesc) - const windowed = anchor !== null ? sorted.filter((r) => beforeAnchor(r, anchor)) : sorted - - const { items, nextCursor } = paginate(windowed, args.limit, (r) => ({ - createdAt: r.createdAt, + const { items, nextCursor } = pageBeforeTimeCursor(sorted, anchor, args.limit, (r) => ({ + at: r.createdAt, id: r.id, })) return { records: items, nextCursor } @@ -64,11 +62,3 @@ function compareDesc(a: AuditRecord, b: AuditRecord): number { if (at !== 0) return at return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 } - -// Strictly older than the anchor in the DESC order, i.e. on a later page. -function beforeAnchor(record: AuditRecord, anchor: CursorAnchor): boolean { - const t = record.createdAt.getTime() - const at = anchor.createdAt.getTime() - if (t !== at) return t < at - return record.id < anchor.id -} diff --git a/services/api/src/services/admin/discovery-repository.drizzle.ts b/services/api/src/services/admin/discovery-repository.drizzle.ts index e1dae12e..4f1cd0b7 100644 --- a/services/api/src/services/admin/discovery-repository.drizzle.ts +++ b/services/api/src/services/admin/discovery-repository.drizzle.ts @@ -1,6 +1,7 @@ import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" import type { Queryable, Sql, SqlFragment } from "../../db/client.js" -import { decodeCursor, clampLimit, paginate } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { paginate, parseKeysetCursor } from "../../db/cursor-helpers.js" import { writeAudit } from "./audit.js" import { andAll, ilikeAnyOf, legacyContactEmailUsable } from "./sql-fragments.js" import { @@ -161,7 +162,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { args: ListDiscoveryArgs, ): Promise<{ records: DiscoveryTaskRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor, true) + const anchor = parseKeysetCursor(args.cursor) const sortValue = sortValueExpr(sql, args.sort) const conds: SqlFragment[] = [] @@ -175,7 +176,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { } if (anchor !== null) { conds.push( - sql`AND (${sortValue}, t.id) < (${anchor.createdAt.getTime()}::bigint, ${anchor.id}::uuid)`, + sql`AND (${sortValue}, t.id) < (${anchor.at.getTime()}::bigint, ${anchor.id}::uuid)`, ) } @@ -185,7 +186,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { sql`ORDER BY ${sortValue} DESC, t.id DESC LIMIT ${limit + 1}`, ) const { items, nextCursor } = paginate(rows.map(toTaskRecord), limit, (r) => ({ - createdAt: new Date(args.sort === "reports" ? r.total : (r.population ?? 0)), + at: new Date(args.sort === "reports" ? r.total : (r.population ?? 0)), id: r.id, })) return { records: items, nextCursor } diff --git a/services/api/src/services/admin/discovery-repository.memory.ts b/services/api/src/services/admin/discovery-repository.memory.ts index ebdf8e2b..a5d9f1f4 100644 --- a/services/api/src/services/admin/discovery-repository.memory.ts +++ b/services/api/src/services/admin/discovery-repository.memory.ts @@ -114,7 +114,7 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 }) - // The shared cursor carries a Date, so the sort value rides in its createdAt slot as epoch-ms; the + // The shared cursor carries a Date, so the sort value rides in its `at` slot as epoch-ms; the // Drizzle repository's SQL keyset consumes the same encoding. const sortValue = (r: DiscoveryTaskRecord): number => args.sort === "reports" ? r.total : (r.population ?? 0) @@ -124,7 +124,7 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { rows, args.cursor, args.limit, - (r) => ({ createdAt: new Date(sortValue(r)), id: r.id }), + (r) => ({ at: new Date(sortValue(r)), id: r.id }), true, ) } diff --git a/services/api/src/services/admin/gov-claims-repository.drizzle.ts b/services/api/src/services/admin/gov-claims-repository.drizzle.ts index 317170d2..1b94cabc 100644 --- a/services/api/src/services/admin/gov-claims-repository.drizzle.ts +++ b/services/api/src/services/admin/gov-claims-repository.drizzle.ts @@ -5,13 +5,13 @@ import type { Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" +import { clampLimit } from "./pagination.js" import { - decodeCursor, - clampLimit, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { ilikeAnyOf } from "./sql-fragments.js" import { GOV_CHECKS, @@ -91,7 +91,7 @@ export function makeDrizzleGovClaimsRepository(sql: Sql): GovClaimsRepository { args: ListGovClaimsArgs, ): Promise<{ records: GovClaimRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor, true) + const anchor = parseKeysetCursor(args.cursor) const newestFirst = args.sort === "newest" const facet = args.filter === "all" ? sql`` : sql`AND status = ${args.filter}` diff --git a/services/api/src/services/admin/gov-claims-repository.memory.ts b/services/api/src/services/admin/gov-claims-repository.memory.ts index 93827544..c2100c40 100644 --- a/services/api/src/services/admin/gov-claims-repository.memory.ts +++ b/services/api/src/services/admin/gov-claims-repository.memory.ts @@ -71,7 +71,7 @@ export class InMemoryGovClaimsRepository implements GovClaimsRepository { }) return pageInMemoryById(rows, args.cursor, args.limit, (r) => ({ - createdAt: r.createdAt, + at: r.createdAt, id: r.id, })) } diff --git a/services/api/src/services/admin/inbound-repository.drizzle.ts b/services/api/src/services/admin/inbound-repository.drizzle.ts index 0f48f203..0e4d0e8b 100644 --- a/services/api/src/services/admin/inbound-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-repository.drizzle.ts @@ -1,11 +1,11 @@ import type { Sql } from "../../db/client.js" +import { clampLimit } from "./pagination.js" import { - clampLimit, - decodeCursor, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { likeContains } from "./like.js" import { writeAudit } from "./audit.js" import { HTML_PREVIEW_SOURCE_CHARS, PREVIEW_SOURCE_CHARS, toPreview } from "./mail-preview.js" @@ -128,7 +128,7 @@ export function makeDrizzleInboundRepository(sql: Sql): InboundRepository { async list(query: InboxListQuery): Promise { const limit = clampLimit(query.limit) - const anchor = decodeCursor(query.cursor, true) + const anchor = parseKeysetCursor(query.cursor) const cursorFilter = anchor !== null ? sql`AND ${keysetPredicate(sql, sql`received_at`, sql`id`, anchor)}` diff --git a/services/api/src/services/admin/inbound-repository.memory.ts b/services/api/src/services/admin/inbound-repository.memory.ts index ee37432a..2693c08a 100644 --- a/services/api/src/services/admin/inbound-repository.memory.ts +++ b/services/api/src/services/admin/inbound-repository.memory.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" -import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" import { localPartOf, type InboundEmailInsert, @@ -55,7 +56,7 @@ export class InMemoryInboundRepository implements InboundRepository { list(query: InboxListQuery): Promise { const limit = clampLimit(query.limit) - const anchor = decodeCursor(query.cursor) + const anchor = parseKeysetCursor(query.cursor, { requireUuid: false }) const q = query.q?.trim().toLowerCase() let filtered = this.rows.filter((r) => { if (query.status === "unread" && r.status !== "unread") return false @@ -71,18 +72,10 @@ export class InMemoryInboundRepository implements InboundRepository { const d = b.receivedAt.getTime() - a.receivedAt.getTime() return d !== 0 ? d : a.id < b.id ? 1 : a.id > b.id ? -1 : 0 }) - if (anchor) { - filtered = filtered.filter((r) => { - const t = r.receivedAt.getTime() - const at = anchor.createdAt.getTime() - return t < at || (t === at && r.id < anchor.id) - }) - } - const page = filtered.slice(0, limit) - const hasMore = filtered.length > limit - const last = page[page.length - 1] - const nextCursor = - hasMore && last ? encodeCursor({ createdAt: last.receivedAt, id: last.id }) : null + const { items: page, nextCursor } = pageBeforeTimeCursor(filtered, anchor, limit, (r) => ({ + at: r.receivedAt, + id: r.id, + })) return Promise.resolve({ items: page.map(toListItem), nextCursor }) } diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts index 4a05681a..ba4fa7eb 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts @@ -1,5 +1,6 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" -import { decodeOffsetCursor, encodeOffsetCursor, clampLimit } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { decodeOffsetCursor, encodeOffsetCursor } from "../../db/cursor-helpers.js" import { writeAudit } from "./audit.js" import { buildUnmappedRecord, shouldIncludeUnmapped } from "./jurisdiction-directory-projection.js" import { diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts index 5364cb48..f5e6c945 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" -import { clampLimit, decodeOffsetCursor, encodeOffsetCursor } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { decodeOffsetCursor, encodeOffsetCursor } from "../../db/cursor-helpers.js" import type { OutreachStateRecord } from "./mail-repository.drizzle.js" import { buildUnmappedRecord, diff --git a/services/api/src/services/admin/mail-repository.drizzle.ts b/services/api/src/services/admin/mail-repository.drizzle.ts index d243e54d..9ab2a690 100644 --- a/services/api/src/services/admin/mail-repository.drizzle.ts +++ b/services/api/src/services/admin/mail-repository.drizzle.ts @@ -1,11 +1,11 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" +import { clampLimit } from "./pagination.js" import { - clampLimit, - decodeCursor, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { PREVIEW_SOURCE_CHARS } from "./mail-preview.js" import { writeAudit } from "./audit.js" import { ilikeAnyOf } from "./sql-fragments.js" @@ -468,7 +468,7 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { async listThreads(input: ListThreadsInput): Promise { const limit = clampLimit(input.limit) - const anchor = decodeCursor(input.cursor, true) + const anchor = parseKeysetCursor(input.cursor) const activityAt = sql`COALESCE(t.last_message_at, t.created_at)` const cursorFilter = anchor !== null ? sql`AND ${keysetPredicate(sql, activityAt, sql`t.id`, anchor)}` : sql`` diff --git a/services/api/src/services/admin/mail-repository.memory.ts b/services/api/src/services/admin/mail-repository.memory.ts index a7e045dc..15b5c04a 100644 --- a/services/api/src/services/admin/mail-repository.memory.ts +++ b/services/api/src/services/admin/mail-repository.memory.ts @@ -29,7 +29,8 @@ import { ROUTE_CLAIM_STALE_SECONDS, ROUTE_DEADLINE_INFLIGHT_SECONDS, } from "./outbound-send-policy.js" -import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" import type { MailDelivery, MailStatsResponse, MailStatus, MailThreadDTO } from "@civfix/shared" const DAY_MS = 24 * 60 * 60 * 1000 @@ -295,20 +296,12 @@ export class InMemoryMailRepository implements MailRepository { listThreads(input: ListThreadsInput): Promise { const limit = clampLimit(input.limit) - const anchor = decodeCursor(input.cursor) + const anchor = parseKeysetCursor(input.cursor, { requireUuid: false }) const q = input.q !== undefined ? input.q.trim().toLowerCase() : "" const latestByThread = this.latestByThread() const sortKey = (t: MailThreadRecord): number => (t.lastMessageAt ?? t.createdAt).getTime() - const isBefore = (t: MailThreadRecord): boolean => { - if (anchor === null) return true - const k = sortKey(t) - const a = anchor.createdAt.getTime() - if (k !== a) return k < a - return t.id < anchor.id - } - const rows = [...this.threads.values()].filter((t) => { const latest = latestByThread.get(t.id) ?? null if ( @@ -328,7 +321,7 @@ export class InMemoryMailRepository implements MailRepository { const hay = `${t.org ?? ""} ${t.subject ?? ""} ${latest?.fromAddr ?? ""}`.toLowerCase() if (!hay.includes(q)) return false } - return isBefore(t) + return true }) rows.sort((a, b) => { @@ -337,14 +330,11 @@ export class InMemoryMailRepository implements MailRepository { return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 }) - const hasMore = rows.length > limit - const page = hasMore ? rows.slice(0, limit) : rows + const { items: page, nextCursor } = pageBeforeTimeCursor(rows, anchor, limit, (t) => ({ + at: t.lastMessageAt ?? t.createdAt, + id: t.id, + })) const items = page.map((t) => toThreadListItem(t, latestByThread.get(t.id) ?? null)) - const last = page[page.length - 1] - const nextCursor = - hasMore && last - ? encodeCursor({ createdAt: last.lastMessageAt ?? last.createdAt, id: last.id }) - : null return Promise.resolve({ items, nextCursor }) } diff --git a/services/api/src/services/admin/moderation-repository.drizzle.ts b/services/api/src/services/admin/moderation-repository.drizzle.ts index 5629094a..1a5d1040 100644 --- a/services/api/src/services/admin/moderation-repository.drizzle.ts +++ b/services/api/src/services/admin/moderation-repository.drizzle.ts @@ -1,13 +1,13 @@ import type { ReportCategory } from "@civfix/shared" import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { writeAudit } from "./audit.js" +import { clampLimit } from "./pagination.js" import { - clampLimit, - decodeCursor, keysetInstant, keysetPredicate, paginateKeyset, -} from "./pagination.js" + parseKeysetCursor, +} from "../../db/cursor-helpers.js" import { ADMIN_CATEGORIES } from "./category-counts.js" import { ilikeAnyOf } from "./sql-fragments.js" import { tombstonePostInTx } from "../post-repository.drizzle.js" @@ -298,7 +298,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository args: ListModerationArgs, ): Promise<{ records: ModerationItemRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor, true) + const anchor = parseKeysetCursor(args.cursor) const keyset = anchor ? sql`AND ${keysetPredicate(sql, sql`created_at`, sql`id`, anchor)}` : sql`` diff --git a/services/api/src/services/admin/moderation-repository.memory.ts b/services/api/src/services/admin/moderation-repository.memory.ts index a4b9f87a..387dd1aa 100644 --- a/services/api/src/services/admin/moderation-repository.memory.ts +++ b/services/api/src/services/admin/moderation-repository.memory.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" -import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" import { assertTargetIsNotOfficialAccount, assertTargetIsNotOperatorRole, @@ -128,21 +129,11 @@ export class InMemoryModerationRepository implements ModerationRepository { }) const limit = clampLimit(args.limit) - const anchor = decodeCursor(args.cursor) - const remaining = - anchor === null - ? rows - : rows.filter((r) => { - const d = r.createdAt.getTime() - anchor.createdAt.getTime() - return d !== 0 ? d < 0 : r.id < anchor.id - }) - const slice = remaining.slice(0, limit + 1) - if (slice.length <= limit) { - return { records: slice, nextCursor: null } - } - const records = slice.slice(0, limit) - const last = records[records.length - 1] - const nextCursor = last ? encodeCursor({ createdAt: last.createdAt, id: last.id }) : null + const anchor = parseKeysetCursor(args.cursor, { requireUuid: false }) + const { items: records, nextCursor } = pageBeforeTimeCursor(rows, anchor, limit, (r) => ({ + at: r.createdAt, + id: r.id, + })) return { records, nextCursor } } diff --git a/services/api/src/services/admin/pagination.ts b/services/api/src/services/admin/pagination.ts index a827d181..a6710470 100644 --- a/services/api/src/services/admin/pagination.ts +++ b/services/api/src/services/admin/pagination.ts @@ -1,84 +1,17 @@ -import { encodeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" - -export { - encodeKeysetCursor, - keysetInstant, - keysetPredicate, - paginate, - paginateKeyset, -} from "../../db/cursor-helpers.js" +import { encodeTimeCursor, parseKeysetCursor, type TimeCursor } from "../../db/cursor-helpers.js" export const ADMIN_DEFAULT_LIMIT = 25 /** Mirrors the shared AdminListQuery limit ceiling. */ const ADMIN_MAX_LIMIT = 100 -export interface CursorAnchor { - createdAt: Date - id: string -} - -/** Keeps the cursor's instant as text because that is what keysetPredicate binds. */ -export interface KeysetAnchor extends CursorAnchor { - atText: string -} - -export function encodeCursor(anchor: CursorAnchor): string { - return encodeTimeCursor({ at: anchor.createdAt, id: anchor.id }) -} - -/** - * Repos whose keyset casts `${id}::uuid` pass requireUuidId=true so a forged non-UUID id degrades to "from - * the start" rather than raising a Postgres 22P02 (a 500). Repos keyed on something else (activity's - * synthetic composite ids) leave it false. A legacy timestamp-only cursor anchors at the max uuid for that - * instant instead of erroring. - */ -export function decodeCursor( - cursor: string | null | undefined, - requireUuidId = false, -): KeysetAnchor | null { - const parsed = parseKeysetCursor(cursor, { requireUuid: requireUuidId }) - if (parsed === null) return null - return { createdAt: parsed.at, id: parsed.id, atText: parsed.atText } -} - -/** - * Offset paging serves the jurisdictions directory: static reference data browsed under an arbitrary sort - * (population / reports / name), where keyset's drift-immunity buys nothing and a free choice of ORDER BY - * is worth more. The cursor stays opaque so the wire `nextCursor` contract is unchanged. - */ -export function encodeOffsetCursor(offset: number): string { - return Buffer.from(JSON.stringify({ o: Math.max(0, Math.floor(offset)) }), "utf8").toString( - "base64url", - ) -} - -/** - * The cursor is opaque but not authenticated, so a caller can mint one carrying any integer, and an - * unbounded OFFSET makes Postgres walk and discard that many rows per request. Mirrors clampOffset in - * services/volunteer-hours-service.ts. The directory is in the low thousands of rows, so a legitimate - * deep page is never near this. - */ -export const ADMIN_MAX_OFFSET = 100_000 - -export function decodeOffsetCursor(cursor: string | null | undefined): number { - if (!cursor) return 0 - try { - const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { o?: unknown } - const o = typeof parsed.o === "number" && Number.isFinite(parsed.o) ? Math.floor(parsed.o) : 0 - return Math.min(Math.max(0, o), ADMIN_MAX_OFFSET) - } catch { - return 0 - } -} - /** * Pages a pre-sorted list. `anchorOf` must return the row's real sort value (not a placeholder) so the * cursor has the same shape as the Drizzle impl's for the same page; the id alone drives the slice * position. * - * `requireUuid` mirrors the Drizzle twin's decodeCursor flag: a fake whose production counterpart casts - * the anchor to uuid must discard a non-uuid anchor the same way, or it silently accepts cursors prod - * throws away. Fakes whose tests seed synthetic ids ("rep-1") leave it false. + * `requireUuid` mirrors the Drizzle twin's parseKeysetCursor requireUuid: a fake whose production + * counterpart casts the anchor to uuid must discard a non-uuid anchor the same way, or it silently + * accepts cursors prod throws away. Fakes whose tests seed synthetic ids ("rep-1") leave it false. * * When the anchor row left the filtered set between pages (a discovery task whose contacts were just * saved, a removed report), paging ends rather than restarting from the top, which would loop the client @@ -88,11 +21,11 @@ export function pageInMemoryById( rows: readonly T[], cursor: string | null | undefined, limit: number | undefined, - anchorOf: (row: T) => CursorAnchor, + anchorOf: (row: T) => TimeCursor, requireUuid = false, ): { records: T[]; nextCursor: string | null } { const lim = clampLimit(limit) - const anchor = decodeCursor(cursor, requireUuid) + const anchor = parseKeysetCursor(cursor, { requireUuid }) let start = 0 if (anchor !== null) { const idx = rows.findIndex((row) => anchorOf(row).id === anchor.id) @@ -102,7 +35,7 @@ export function pageInMemoryById( if (slice.length <= lim) return { records: slice, nextCursor: null } const records = slice.slice(0, lim) const last = records[records.length - 1] - return { records, nextCursor: last !== undefined ? encodeCursor(anchorOf(last)) : null } + return { records, nextCursor: last !== undefined ? encodeTimeCursor(anchorOf(last)) : null } } // The wire schema already coerces and caps; this clamp keeps a repo from ever receiving a 0, negative or diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index 85cd6e88..6c15b9f1 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -19,7 +19,7 @@ import { loadChatMentions, loadChatMentionsFor } from "./chat-mentions-repositor import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" import { monotonicReadWatermark } from "./read-watermark-repository.drizzle.js" import { assertReplyTarget, replyMapForRows } from "./chat-reply-hydration.js" -import type { TimeCursor } from "../db/cursor-helpers.js" +import { msKeysetFilter, type TimeCursor } from "../db/cursor-helpers.js" import type { PresignMedia } from "./media-presign.js" import { makeDrizzleRoomMessagesRepository, @@ -576,14 +576,7 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql`` const rawActivity = sql`COALESCE(last_msg.created_at, t.created_at)` const activity = sql`date_trunc('milliseconds', ${rawActivity})` - let cursorFilter = sql`` - if (cursor !== null && cursor !== undefined) { - const msCeiling = new Date(cursor.at.getTime() + 1) - cursorFilter = sql` - AND ${activity} < ${msCeiling} - AND (${activity} < ${cursor.at} OR t.id < ${cursor.id}::uuid) - ` - } + const cursorFilter = msKeysetFilter(sql, activity, sql`t.id`, cursor) const rows = await sql< { thread_id: string diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index df2f7b7e..9da29714 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -7,7 +7,12 @@ import type { EventTeamRole, EventVisibility, } from "@civfix/shared" -import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" +import { + encodeTimeCursor, + isBeforeTimeCursor, + pageWith, + parseTimeCursor, +} from "../../db/cursor-helpers.js" import { eventWindowOf, hasEventEnded } from "../cleanup-rules.js" import type { CleanupPersonView } from "../cleanup-repository.types.js" import type { @@ -404,12 +409,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { !this.isClosed(i.cleanupId), ) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || compareIds(b.id, a.id)) - .filter( - (i) => - cursor === null || - i.createdAt.getTime() < cursor.at.getTime() || - (i.createdAt.getTime() === cursor.at.getTime() && i.id < cursor.id), - ) + .filter((i) => isBeforeTimeCursor(i.createdAt.getTime(), i.id, cursor)) .slice(0, args.limit + 1) .map( (i): PendingInviteForUserRecord => ({ diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index e4e9dd3d..52865823 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -54,6 +54,7 @@ import { roleChangeWithdrawsInvites, } from "./organization-repository.types.js" import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" +import { encodeTimeCursor } from "../../db/cursor-helpers.js" const SEED_DISPLAY_NAME = "Member" @@ -146,7 +147,7 @@ function pageAfterCursor( } function memberCursorKey(member: StoredMember): string { - return `${member.joinedAt.toISOString()}|${member.userId}` + return encodeTimeCursor({ at: member.joinedAt, id: member.userId }) } export class InMemoryOrganizationRepository implements OrganizationRepository { @@ -671,11 +672,8 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { .filter((v) => query.status === undefined || v.status === query.status) .filter((v) => query.kind === undefined || v.kind === query.kind) .sort((a, b) => b.submittedAt.getTime() - a.submittedAt.getTime()) - const { page, nextCursor } = pageAfterCursor( - rows, - query.cursor, - query.limit, - (v) => `${v.submittedAt.toISOString()}|${v.id}`, + const { page, nextCursor } = pageAfterCursor(rows, query.cursor, query.limit, (v) => + encodeTimeCursor({ at: v.submittedAt, id: v.id }), ) return Promise.resolve({ items: page.map((row) => this.toAdminRecord(row)), @@ -734,11 +732,8 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { .filter((o) => query.kind === undefined || o.verifiedKind === query.kind) .filter((o) => query.suspended === undefined || (o.suspendedAt !== null) === query.suspended) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - const { page, nextCursor } = pageAfterCursor( - rows, - query.cursor, - query.limit, - (o) => `${o.createdAt.toISOString()}|${o.id}`, + const { page, nextCursor } = pageAfterCursor(rows, query.cursor, query.limit, (o) => + encodeTimeCursor({ at: o.createdAt, id: o.id }), ) const counts: AdminOrganizationCounts | null = query.cursor === null diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index 72c35c1c..bb8009ae 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -1,6 +1,11 @@ import { randomUUID } from "node:crypto" import type { CheckinMethod } from "@civfix/shared" -import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" +import { + encodeTimeCursor, + pageBeforeTimeCursor, + pageWith, + parseTimeCursor, +} from "../../db/cursor-helpers.js" import { DEFAULT_EVENT_DURATION_MS, eventEndsAtMs, @@ -769,20 +774,14 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos (a, b) => b.registeredAt.getTime() - a.registeredAt.getTime() || b.id.localeCompare(a.id), ) const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) - if (cursor !== null) { - rows = rows.filter( - (r) => - r.registeredAt.getTime() < cursor.at.getTime() || - (r.registeredAt.getTime() === cursor.at.getTime() && r.id < cursor.id), - ) + const page = pageBeforeTimeCursor(rows, cursor, query.limit, (r) => ({ + at: r.registeredAt, + id: r.id, + })) + const out: RosterPage = { + rows: page.items.map((r) => this.toRecord(r)), + nextCursor: page.nextCursor, } - const page = rows.slice(0, query.limit) - const last = page[page.length - 1] - const nextCursor = - rows.length > query.limit && last !== undefined - ? encodeTimeCursor({ at: last.registeredAt, id: last.id }) - : null - const out: RosterPage = { rows: page.map((r) => this.toRecord(r)), nextCursor } if (query.withTotal) { out.total = [...this.registrations.values()].filter( (r) => r.cleanupId === query.cleanupId && r.status === "registered", @@ -1089,13 +1088,13 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos : w.status === args.status, ) rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)) - const page = rows.slice(0, args.limit) - const last = page[page.length - 1] - const nextCursor = - rows.length > args.limit && last !== undefined - ? encodeTimeCursor({ at: last.createdAt, id: last.id }) - : null - return { rows: page.map((entry) => this.waitlistView(entry)), nextCursor } + const page = pageWith(rows, args.limit, (last) => + encodeTimeCursor({ at: last.createdAt, id: last.id }), + ) + return { + rows: page.items.map((entry) => this.waitlistView(entry)), + nextCursor: page.nextCursor, + } } async findWaitlistEntry(cleanupId: string, waitlistId: string): Promise { diff --git a/services/api/src/services/threads-repository.drizzle.ts b/services/api/src/services/threads-repository.drizzle.ts index 8126c426..73acf397 100644 --- a/services/api/src/services/threads-repository.drizzle.ts +++ b/services/api/src/services/threads-repository.drizzle.ts @@ -3,7 +3,7 @@ import type { ReportCategory } from "@civfix/shared" import type { Sql, SqlFragment } from "../db/client.js" import type { ConversationHideRoomKind } from "../db/schema/conversation_hides.js" import { publicReportFilter } from "./report-sql.js" -import type { TimeCursor } from "../db/cursor-helpers.js" +import { msKeysetFilter, type TimeCursor } from "../db/cursor-helpers.js" import { THREADS_DEFAULT_LIMIT, type GroupThreadAggregateView, @@ -14,20 +14,6 @@ import { type ThreadsRepository, } from "./threads-service.js" -function threadsCursorFilter( - sql: Sql, - activity: SqlFragment, - idColumn: SqlFragment, - cursor: TimeCursor | null | undefined, -): SqlFragment { - if (cursor === null || cursor === undefined) return sql`` - const msCeiling = new Date(cursor.at.getTime() + 1) - return sql` - AND ${activity} < ${msCeiling} - AND (${activity} < ${cursor.at} OR ${idColumn} < ${cursor.id}::uuid) - ` -} - function reportThreadTitle(category: ReportCategory | string, addr: string | null): string { const label = REPORT_CATEGORY_LABELS[category as ReportCategory] ?? category const short = (addr ?? "").split(",")[0]?.trim() ?? "" @@ -63,7 +49,7 @@ async function listThreadFamily( const scope = sql(spec.scopeColumn) const rawActivity = sql`COALESCE(last_msg.created_at, mem.joined_at)` const activity = sql`date_trunc('milliseconds', ${rawActivity})` - const cursorFilter = threadsCursorFilter(sql, activity, sql`r.id`, cursor) + const cursorFilter = msKeysetFilter(sql, activity, sql`r.id`, cursor) return await sql` WITH page AS ( SELECT diff --git a/services/api/src/services/threads-service.ts b/services/api/src/services/threads-service.ts index 41b1a1c4..46dbd19c 100644 --- a/services/api/src/services/threads-service.ts +++ b/services/api/src/services/threads-service.ts @@ -2,6 +2,7 @@ import { relativeAgo, avatarGradient } from "@civfix/shared" import type { MessageThreadDTO, PersonDTO, RoomKind } from "@civfix/shared" import { encodeTimeCursor, + isBeforeTimeCursor, pageWith, parseTimeCursor, type TimeCursor, @@ -219,12 +220,6 @@ function dmThreadTitle(peer: DmThreadAggregateView["peer"]): string { return peer.handle !== null ? `@${peer.handle}` : peer.displayName } -function beforeCursor(cursor: TimeCursor | null, activity: number, id: string): boolean { - if (cursor === null) return true - const at = cursor.at.getTime() - return activity < at || (activity === at && id < cursor.id) -} - export function makeThreadsService(deps: ThreadsServiceDeps): ThreadsService { const now = deps.now ?? (() => new Date()) @@ -357,7 +352,7 @@ export function makeThreadsService(deps: ThreadsServiceDeps): ThreadsService { ) const merged = [...cleanupEntries, ...dmEntries, ...reportEntries, ...groupEntries] - .filter((e) => beforeCursor(cursor, e.activity, e.dto.id)) + .filter((e) => isBeforeTimeCursor(e.activity, e.dto.id, cursor)) .sort( (a, b) => b.activity - a.activity || (a.dto.id < b.dto.id ? 1 : a.dto.id > b.dto.id ? -1 : 0), diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 4985bd15..398943ab 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -6,7 +6,7 @@ import type { OrganizationRefDTO, VolunteerHoursSource, } from "@civfix/shared" -import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" +import { encodeTimeCursor, isBeforeTimeCursor, pageWith } from "../db/cursor-helpers.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" import { eventDayKey } from "./host/event-day.js" import { @@ -419,12 +419,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor (e) => e.userId === args.userId && e.voidedAt === undefined && sources.includes(e.source), ) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - .filter((e) => { - if (args.cursor === null) return true - const at = e.createdAt.getTime() - const anchor = args.cursor.at.getTime() - return at < anchor || (at === anchor && e.id < args.cursor.id) - }) + .filter((e) => isBeforeTimeCursor(e.createdAt.getTime(), e.id, args.cursor)) .slice(0, args.limit + 1) const { items, nextCursor } = pageWith(rows, args.limit, (last) => diff --git a/services/api/test/unit/admin-discovery.test.ts b/services/api/test/unit/admin-discovery.test.ts index bc5e24e7..a445c0f3 100644 --- a/services/api/test/unit/admin-discovery.test.ts +++ b/services/api/test/unit/admin-discovery.test.ts @@ -220,8 +220,8 @@ describe("discovery list", () => { repo.seedTask({ id: randomUUID(), geoid: `${i}`, place: `Place ${i}`, population: 100 - i }) } // A forged/legacy cursor carrying a non-uuid id would raise a Postgres 22P02 -> 500 against the real - // keyset, so decodeCursor drops it and the query pages from the top. The fake must agree, or an offline - // test proves a paging behavior production does not have. + // keyset, so parseKeysetCursor drops it and the query pages from the top. The fake must agree, or an + // offline test proves a paging behavior production does not have. const page = await svc.list({ limit: 2, cursor: "2026-06-06T00:00:00.000Z|not-a-uuid" }) expect(page.items.map((i) => i.place)).toEqual(["Place 0", "Place 1"]) }) diff --git a/services/api/test/unit/admin-keyset-precision.test.ts b/services/api/test/unit/admin-keyset-precision.test.ts index 1380eb29..2b0be569 100644 --- a/services/api/test/unit/admin-keyset-precision.test.ts +++ b/services/api/test/unit/admin-keyset-precision.test.ts @@ -11,7 +11,7 @@ import { makeDrizzleAdminUserRepository } from "../../src/services/admin/admin-u import { makeDrizzleAdminEventRepository } from "../../src/services/admin/admin-event-repository.drizzle.js" import { makeDrizzleModerationRepository } from "../../src/services/admin/moderation-repository.drizzle.js" import { makeDrizzleActivityRepository } from "../../src/services/admin/activity-repository.drizzle.js" -import { decodeCursor } from "../../src/services/admin/pagination.js" +import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" const AT = new Date("2026-09-01T10:00:00.123Z") const AT_TEXT = "2026-09-01T10:00:00.123456Z" @@ -39,18 +39,20 @@ function expectExactAnchor(ctl: FakeSqlControl, match: RegExp): void { expect(stmt.sql).toContain("::timestamptz") } -describe("decodeCursor keeps the cursor instant as text", () => { +describe("parseKeysetCursor keeps the cursor instant as text", () => { it("returns the microsecond text alongside the Date", () => { - const anchor = decodeCursor(CURSOR, true) + const anchor = parseKeysetCursor(CURSOR, { requireUuid: true }) expect(anchor?.atText).toBe(AT_TEXT) expect(anchor?.id).toBe(ID_A) }) it("keeps a legacy millisecond cursor and a timestamp-only cursor parseable", () => { - expect(decodeCursor(`2026-09-01T10:00:00.123Z|${ID_A}`, true)?.atText).toBe( + expect( + parseKeysetCursor(`2026-09-01T10:00:00.123Z|${ID_A}`, { requireUuid: true })?.atText, + ).toBe("2026-09-01T10:00:00.123Z") + expect(parseKeysetCursor("2026-09-01T10:00:00.123Z", { requireUuid: true })?.atText).toBe( "2026-09-01T10:00:00.123Z", ) - expect(decodeCursor("2026-09-01T10:00:00.123Z", true)?.atText).toBe("2026-09-01T10:00:00.123Z") }) }) diff --git a/services/api/test/unit/admin-pagination.test.ts b/services/api/test/unit/admin-pagination.test.ts index 8fc63d82..43fb817f 100644 --- a/services/api/test/unit/admin-pagination.test.ts +++ b/services/api/test/unit/admin-pagination.test.ts @@ -6,7 +6,7 @@ import { ADMIN_MAX_OFFSET, decodeOffsetCursor, encodeOffsetCursor, -} from "../../src/services/admin/pagination.js" +} from "../../src/db/cursor-helpers.js" // Bypasses encodeOffsetCursor's own lower clamp. function forgeCursor(offset: number): string { diff --git a/services/api/test/unit/keyset-cursor-forged.test.ts b/services/api/test/unit/keyset-cursor-forged.test.ts index 6cab87d2..d3089794 100644 --- a/services/api/test/unit/keyset-cursor-forged.test.ts +++ b/services/api/test/unit/keyset-cursor-forged.test.ts @@ -13,7 +13,6 @@ import { loadEnv } from "../../src/env.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" import { parseKeysetCursor } from "../../src/db/cursor-helpers.js" -import { decodeCursor } from "../../src/services/admin/pagination.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" @@ -37,7 +36,7 @@ describe("parseKeysetCursor refuses instants Postgres would not accept", () => { it.each(FORGED_INSTANTS)("%s gives the first page", (_label, instant) => { expect(parseKeysetCursor(`${instant}|${ID}`)).toBeNull() expect(parseKeysetCursor(instant)).toBeNull() - expect(decodeCursor(`${instant}|${ID}`, true)).toBeNull() + expect(parseKeysetCursor(`${instant}|${ID}`, { requireUuid: true })).toBeNull() }) }) From de761e9e229ff2638926fa6cb7165fa1aad82400 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 15:46:50 +0000 Subject: [PATCH 02/20] shared lib helpers: hash, bounded concurrency, sleep, time units, page limits --- services/api/package.json | 5 ++++- services/api/src/lib/base-url.ts | 6 +++++- services/api/src/lib/concurrency.ts | 18 ++++++++++++++++++ services/api/src/lib/hash.ts | 7 +++++++ services/api/src/lib/page-limit.ts | 7 +++++++ services/api/src/lib/sleep.ts | 13 +++++++++++++ services/api/src/lib/time.ts | 9 +++++++++ services/api/src/services/admin/pagination.ts | 9 ++------- services/api/src/services/media-presign.ts | 19 +------------------ 9 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 services/api/src/lib/concurrency.ts create mode 100644 services/api/src/lib/hash.ts create mode 100644 services/api/src/lib/page-limit.ts create mode 100644 services/api/src/lib/sleep.ts create mode 100644 services/api/src/lib/time.ts diff --git a/services/api/package.json b/services/api/package.json index e3b13722..f7cc0cd3 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -20,7 +20,10 @@ "./adapters/storage-local": "./src/adapters/storage.local.ts", "./adapters/abuse-checks": "./src/adapters/abuse-checks.ts", "./errors": "./src/errors/glitchtip.ts", - "./migrate": "./src/db/migrate.ts" + "./migrate": "./src/db/migrate.ts", + "./env-parsers": "./src/env/parsers.ts", + "./concurrency": "./src/lib/concurrency.ts", + "./time": "./src/lib/time.ts" }, "scripts": { "build": "tsup", diff --git a/services/api/src/lib/base-url.ts b/services/api/src/lib/base-url.ts index a73a4616..bd8fa6c7 100644 --- a/services/api/src/lib/base-url.ts +++ b/services/api/src/lib/base-url.ts @@ -3,6 +3,10 @@ export const DEFAULT_API_PORT = 8080 const TRAILING_SLASHES_RE = /\/+$/ +export function stripTrailingSlashes(value: string): string { + return value.replace(TRAILING_SLASHES_RE, "") +} + export interface BaseUrlEnv { NODE_ENV?: string PORT?: number @@ -21,7 +25,7 @@ function baseUrlOr( env: BaseUrlEnv, port: number, ): string { - const trimmed = (configured ?? "").trim().replace(TRAILING_SLASHES_RE, "") + const trimmed = stripTrailingSlashes((configured ?? "").trim()) if (trimmed.length > 0) return trimmed if (env.NODE_ENV === "production") throw new Error(`${variable} is required in production`) return `http://localhost:${port}` diff --git a/services/api/src/lib/concurrency.ts b/services/api/src/lib/concurrency.ts new file mode 100644 index 00000000..617853a7 --- /dev/null +++ b/services/api/src/lib/concurrency.ts @@ -0,0 +1,18 @@ +// Order-preserving, used instead of `Promise.all(arr.map(fn))` on fan-outs so a large page can't fire +// hundreds of concurrent R2/DB ops. +export async function mapWithLimit( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length) + const cap = Math.max(1, Math.min(limit, items.length)) + let next = 0 + const workers = Array.from({ length: cap }, async () => { + for (let i = next++; i < items.length; i = next++) { + results[i] = await fn(items[i] as T, i) + } + }) + await Promise.all(workers) + return results +} diff --git a/services/api/src/lib/hash.ts b/services/api/src/lib/hash.ts new file mode 100644 index 00000000..a05ff5e0 --- /dev/null +++ b/services/api/src/lib/hash.ts @@ -0,0 +1,7 @@ +import { createHash } from "node:crypto" + +// Sync and node:crypto only: auth/crypto's async sha256Hex goes through oslo, which must not ride into +// the media-worker bundle (it inlines @civfix/api modules but does not install oslo). +export function sha256HexSync(input: string | Uint8Array): string { + return createHash("sha256").update(input).digest("hex") +} diff --git a/services/api/src/lib/page-limit.ts b/services/api/src/lib/page-limit.ts new file mode 100644 index 00000000..12e28ec6 --- /dev/null +++ b/services/api/src/lib/page-limit.ts @@ -0,0 +1,7 @@ +// The wire schema already coerces and caps; this clamp keeps a repo from ever receiving a 0, negative or +// huge LIMIT. +export function clampPageLimit(limit: number | undefined, fallback: number, max: number): number { + return limit === undefined || !Number.isFinite(limit) + ? fallback + : Math.min(Math.max(1, Math.floor(limit)), max) +} diff --git a/services/api/src/lib/sleep.ts b/services/api/src/lib/sleep.ts new file mode 100644 index 00000000..91973b69 --- /dev/null +++ b/services/api/src/lib/sleep.ts @@ -0,0 +1,13 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +// Unref'd so a pending backoff never holds the process open during shutdown. +export function unrefSleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (typeof timer.unref === "function") timer.unref() + }) +} diff --git a/services/api/src/lib/time.ts b/services/api/src/lib/time.ts new file mode 100644 index 00000000..39679fdb --- /dev/null +++ b/services/api/src/lib/time.ts @@ -0,0 +1,9 @@ +export const MS_PER_SECOND = 1000 +export const SECONDS_PER_MINUTE = 60 +export const MINUTES_PER_HOUR = 60 +export const SECONDS_PER_HOUR = 60 * 60 +export const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +export const MS_PER_MINUTE = 60 * MS_PER_SECOND +export const MS_PER_HOUR = 60 * MS_PER_MINUTE +export const MS_PER_DAY = 24 * MS_PER_HOUR +export const MS_PER_WEEK = 7 * MS_PER_DAY diff --git a/services/api/src/services/admin/pagination.ts b/services/api/src/services/admin/pagination.ts index a6710470..da880e09 100644 --- a/services/api/src/services/admin/pagination.ts +++ b/services/api/src/services/admin/pagination.ts @@ -1,4 +1,5 @@ import { encodeTimeCursor, parseKeysetCursor, type TimeCursor } from "../../db/cursor-helpers.js" +import { clampPageLimit } from "../../lib/page-limit.js" export const ADMIN_DEFAULT_LIMIT = 25 /** Mirrors the shared AdminListQuery limit ceiling. */ @@ -38,12 +39,6 @@ export function pageInMemoryById( return { records, nextCursor: last !== undefined ? encodeTimeCursor(anchorOf(last)) : null } } -// The wire schema already coerces and caps; this clamp keeps a repo from ever receiving a 0, negative or -// huge LIMIT. export function clampLimit(limit: number | undefined): number { - if (limit === undefined || !Number.isFinite(limit)) return ADMIN_DEFAULT_LIMIT - const n = Math.floor(limit) - if (n < 1) return 1 - if (n > ADMIN_MAX_LIMIT) return ADMIN_MAX_LIMIT - return n + return clampPageLimit(limit, ADMIN_DEFAULT_LIMIT, ADMIN_MAX_LIMIT) } diff --git a/services/api/src/services/media-presign.ts b/services/api/src/services/media-presign.ts index 73cf9e21..7b6344bf 100644 --- a/services/api/src/services/media-presign.ts +++ b/services/api/src/services/media-presign.ts @@ -54,21 +54,4 @@ export function makePacketMediaPresigner(storage: PresignStorage): PresignPacket // Bounds concurrent SigV4 signings / R2 ops per page. export const PRESIGN_CONCURRENCY = 8 -// Order-preserving, used instead of `Promise.all(arr.map(fn))` on presign/sub-query fan-outs so a large -// page can't fire hundreds of concurrent R2/DB ops. -export async function mapWithLimit( - items: readonly T[], - limit: number, - fn: (item: T, index: number) => Promise, -): Promise { - const results = new Array(items.length) - const cap = Math.max(1, Math.min(limit, items.length)) - let next = 0 - const workers = Array.from({ length: cap }, async () => { - for (let i = next++; i < items.length; i = next++) { - results[i] = await fn(items[i] as T, i) - } - }) - await Promise.all(workers) - return results -} +export { mapWithLimit } from "../lib/concurrency.js" From 16a669a93cdaa72f58fd48b8eacb5457f0f71b73 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 15:56:57 +0000 Subject: [PATCH 03/20] callers adopt the shared helpers for hashing, sleep, concurrency, time units, limits and filenames --- .../src/adapters/chat-send-dedupe.redis.ts | 12 +--- services/api/src/adapters/inbound-mail.cf.ts | 6 +- services/api/src/adapters/jobs.pgboss.ts | 2 +- services/api/src/adapters/mailer-defaults.ts | 3 + services/api/src/adapters/mailer.oci.ts | 6 +- services/api/src/adapters/push-expo.ts | 5 +- services/api/src/adapters/push-sender.ts | 6 +- services/api/src/adapters/push-webpush.ts | 2 +- services/api/src/adapters/redis-incr.ts | 3 +- services/api/src/adapters/storage.local.ts | 11 ++-- services/api/src/adapters/storage.r2.ts | 4 +- services/api/src/auth/cf-access.ts | 5 +- services/api/src/auth/jwks.ts | 4 +- services/api/src/auth/oauth.ts | 4 +- services/api/src/db/cursor-helpers.ts | 4 +- services/api/src/db/demo-join-event.ts | 17 +++-- services/api/src/db/seed-demo-la.ts | 62 +++++++++++-------- services/api/src/env/core-env.ts | 6 +- services/api/src/lib/filename.ts | 9 +++ services/api/src/lifecycle.ts | 9 +-- .../api/src/routes/admin/broadcasts.routes.ts | 9 ++- services/api/src/routes/admin/inbox.routes.ts | 3 +- services/api/src/routes/admin/mail.routes.ts | 3 +- services/api/src/routes/chat-route-helpers.ts | 5 +- services/api/src/routes/cleanups.routes.ts | 6 +- services/api/src/routes/dm.routes.ts | 4 +- services/api/src/routes/forms.routes.ts | 4 +- services/api/src/routes/social.routes.ts | 7 +-- .../src/services/admin/activity-service.ts | 4 +- .../admin/admin-event-repository.memory.ts | 7 +-- .../src/services/admin/admin-event-service.ts | 8 +-- .../admin/admin-report-chat-service.ts | 14 ++--- .../services/admin/admin-report-service.ts | 7 ++- .../src/services/admin/admin-user-service.ts | 4 +- .../src/services/admin/analytics-shaping.ts | 8 +-- .../src/services/admin/discovery-service.ts | 9 ++- .../api/src/services/admin/home-service.ts | 12 +--- .../src/services/admin/inbound-processor.ts | 8 +-- .../inbound-retention-repository.drizzle.ts | 5 +- .../admin/inbound-thread-correlation.ts | 4 +- .../admin/jurisdiction-contacts-service.ts | 4 +- .../api/src/services/admin/mail-format.ts | 6 +- .../api/src/services/admin/mail-preview.ts | 14 ++--- .../services/admin/mail-repository.memory.ts | 5 +- .../api/src/services/admin/mail-service.ts | 3 +- .../src/services/admin/moderation-service.ts | 3 +- .../src/services/admin/outreach-service.ts | 7 +-- services/api/src/services/affiliation.ts | 3 +- .../api/src/services/certificate-model.ts | 4 +- .../api/src/services/certificate-service.ts | 11 ++-- .../api/src/services/chat-edit-service.ts | 3 +- .../api/src/services/chat-group-service.ts | 6 +- .../src/services/chat-room-fanout-notifier.ts | 2 +- .../src/services/chat-room-notifier-wiring.ts | 3 +- .../api/src/services/cleanup-notifications.ts | 5 +- services/api/src/services/cleanup-rules.ts | 5 +- services/api/src/services/cleanup-service.ts | 25 ++++---- services/api/src/services/cleanup-slots.ts | 3 +- services/api/src/services/feed-ranking.ts | 4 +- .../api/src/services/guest-rsvp-service.ts | 20 +++--- .../src/services/host/analytics-service.ts | 8 +-- .../host/announcement-repository.drizzle.ts | 3 +- .../src/services/host/announcement-service.ts | 3 +- .../host/broadcast-capability-token.ts | 3 +- .../services/host/broadcast-email-sender.ts | 8 +-- .../api/src/services/host/broadcast-lanes.ts | 3 +- .../src/services/host/broadcast-service.ts | 11 ++-- .../api/src/services/host/checkin-service.ts | 5 +- services/api/src/services/host/comms-jobs.ts | 2 +- .../services/host/event-analytics-service.ts | 4 +- .../api/src/services/host/event-fields.ts | 3 +- .../api/src/services/host/export-service.ts | 3 +- .../services/host/host-analytics-shaping.ts | 5 +- .../services/host/host-portfolio-service.ts | 3 +- .../host/host-team-repository.memory.ts | 5 +- .../src/services/host/host-team-service.ts | 13 ++-- .../api/src/services/host/insights-service.ts | 13 ++-- .../api/src/services/host/metrics-service.ts | 7 ++- .../src/services/host/organization-service.ts | 23 +++---- .../api/src/services/host/page-service.ts | 11 ++-- .../services/host/registration-retention.ts | 11 ++-- .../src/services/host/registration-wiring.ts | 6 +- .../api/src/services/host/ticket-token.ts | 5 +- services/api/src/services/media-byte-quota.ts | 3 +- .../media-claim-repository.drizzle.ts | 2 +- .../api/src/services/media-intake-service.ts | 2 +- .../message-attachments-repository.drizzle.ts | 3 +- .../api/src/services/notification-helpers.ts | 2 +- .../api/src/services/notification-service.ts | 2 +- .../src/services/post-repository.drizzle.ts | 3 +- services/api/src/services/post-service.ts | 2 +- services/api/src/services/report-chat-send.ts | 2 +- services/api/src/services/report-service.ts | 9 +-- .../volunteer-hours-repository.drizzle.ts | 3 +- .../volunteer-hours-repository.memory.ts | 5 +- .../src/services/volunteer-hours-service.ts | 33 +++++----- services/api/src/ws/frame-handler.ts | 2 +- services/api/src/ws/report-rate-limit.ts | 4 +- services/api/src/ws/send-resilience.ts | 12 +--- .../api/test/unit/adapters-mailer-oci.test.ts | 3 +- services/media-worker/src/config.ts | 13 +--- services/media-worker/src/jobs.ts | 3 +- .../media-worker/src/jobs/orphan-sweep.ts | 18 +----- .../media-worker/src/jobs/retention-sweep.ts | 9 ++- services/media-worker/src/sandbox/exec.ts | 8 +-- services/media-worker/src/seams.ts | 3 +- services/media-worker/src/worker.ts | 2 +- 107 files changed, 335 insertions(+), 418 deletions(-) create mode 100644 services/api/src/adapters/mailer-defaults.ts create mode 100644 services/api/src/lib/filename.ts diff --git a/services/api/src/adapters/chat-send-dedupe.redis.ts b/services/api/src/adapters/chat-send-dedupe.redis.ts index 3c963496..d9256d3a 100644 --- a/services/api/src/adapters/chat-send-dedupe.redis.ts +++ b/services/api/src/adapters/chat-send-dedupe.redis.ts @@ -8,6 +8,8 @@ import { type SendDedupeStore, type SendReservation, } from "../ws/send-resilience.js" +import { unrefSleep } from "../lib/sleep.js" +import { MS_PER_SECOND } from "../lib/time.js" export interface RedisSendDedupeOptions { ttlSeconds?: number @@ -16,18 +18,10 @@ export interface RedisSendDedupeOptions { sleep?: (ms: number) => Promise } -const MS_PER_SECOND = 1000 - function wholeSecondsToMs(seconds: number): number { return Math.max(1, Math.ceil(seconds)) * MS_PER_SECOND } -const realSleep = (ms: number): Promise => - new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - if (typeof timer.unref === "function") timer.unref() - }) - // Every Redis failure fails open: dedupe only suppresses retried duplicates, so an outage must not // block chat sends, and the shared client's error handler already logs the outage itself. const OPEN: SendReservation = { state: "open" } @@ -44,7 +38,7 @@ export class RedisSendDedupeStore implements SendDedupeStore { this.ttlMs = wholeSecondsToMs(opts.ttlSeconds ?? SEND_DEDUPE_TTL_SECONDS) this.pendingTtlMs = wholeSecondsToMs(opts.pendingTtlSeconds ?? SEND_DEDUPE_PENDING_TTL_SECONDS) this.inFlightAttempts = opts.inFlightAttempts ?? SEND_DEDUPE_INFLIGHT_ATTEMPTS - this.sleep = opts.sleep ?? realSleep + this.sleep = opts.sleep ?? unrefSleep } async reserve(key: string): Promise { diff --git a/services/api/src/adapters/inbound-mail.cf.ts b/services/api/src/adapters/inbound-mail.cf.ts index 69d31ad8..e46f2a73 100644 --- a/services/api/src/adapters/inbound-mail.cf.ts +++ b/services/api/src/adapters/inbound-mail.cf.ts @@ -4,6 +4,7 @@ import type { ParsedMailAddress, ParsedMailAttachment, } from "@civfix/shared/interfaces" +import { collapseWhitespace } from "@civfix/shared" import type { AddressObject, Attachment, EmailAddress, HeaderLines } from "mailparser" import { getDomain } from "tldts" import { domainOfOrNull } from "./mail-text.js" @@ -264,10 +265,7 @@ function singleFromMailbox( } function headerLineValue(header: HeaderLines[number]): string { - return header.line - .slice(header.line.indexOf(":") + 1) - .replace(/\s+/g, " ") - .trim() + return collapseWhitespace(header.line.slice(header.line.indexOf(":") + 1)) } function toAddress(value: EmailAddress): ParsedMailAddress { diff --git a/services/api/src/adapters/jobs.pgboss.ts b/services/api/src/adapters/jobs.pgboss.ts index d7fa2040..934d3294 100644 --- a/services/api/src/adapters/jobs.pgboss.ts +++ b/services/api/src/adapters/jobs.pgboss.ts @@ -15,6 +15,7 @@ import { GUEST_RETENTION_SWEEP_JOB, } from "../services/guest-rsvp-service.js" import { CHAT_ROOM_FANOUT_JOB } from "../services/chat-fanout-jobs.js" +import { SECONDS_PER_MINUTE } from "../lib/time.js" export interface PgBossJobsLogger { error(obj: unknown, msg?: string): void @@ -59,7 +60,6 @@ type ApiQueueName = (typeof API_QUEUE_NAMES)[number] type QueueRetryPolicy = Required> -const SECONDS_PER_MINUTE = 60 const DATA_EXPORT_RETRY_LIMIT = 10 // A data export that fails on a mail credential or approved-sender fault has to wait for an operator to diff --git a/services/api/src/adapters/mailer-defaults.ts b/services/api/src/adapters/mailer-defaults.ts new file mode 100644 index 00000000..247c972e --- /dev/null +++ b/services/api/src/adapters/mailer-defaults.ts @@ -0,0 +1,3 @@ +// A leaf module so env/core-env can share the default without importing mailer.oci, which pulls the +// native argon2 binding in through auth/otp. +export const OCI_MAILER_DEFAULT_TIMEOUT_MS = 15_000 diff --git a/services/api/src/adapters/mailer.oci.ts b/services/api/src/adapters/mailer.oci.ts index a5520ca6..be4f18f9 100644 --- a/services/api/src/adapters/mailer.oci.ts +++ b/services/api/src/adapters/mailer.oci.ts @@ -17,6 +17,8 @@ import { renderEmailBody } from "./email-layout.js" import { renderMessage } from "../i18n/renderMessage.js" import { resolveLocale, type Locale } from "../i18n/locales.js" import { OTP_TTL_SECONDS } from "../auth/otp.js" +import { SECONDS_PER_MINUTE } from "../lib/time.js" +import { OCI_MAILER_DEFAULT_TIMEOUT_MS } from "./mailer-defaults.js" const CRLF_RE = /[\r\n\0]/ const CRLF_GLOBAL_RE = /[\r\n\0]/g @@ -60,12 +62,8 @@ const consoleLogger: OciMailerLogger = { warn: (obj, msg) => console.warn(msg ?? "", obj), } -export const OCI_MAILER_DEFAULT_TIMEOUT_MS = 15_000 - const SMTPS_IMPLICIT_TLS_PORT = 465 -const SECONDS_PER_MINUTE = 60 - const DEFAULT_EVENT_TITLE = "the event" const GUEST_OTP_DEFAULT_MINUTES = "5" diff --git a/services/api/src/adapters/push-expo.ts b/services/api/src/adapters/push-expo.ts index 355cba94..fc6a0e26 100644 --- a/services/api/src/adapters/push-expo.ts +++ b/services/api/src/adapters/push-expo.ts @@ -1,5 +1,6 @@ import type { PushLogger, PlatformDispatcher } from "./push-sender.js" import { fetchJsonWithTimeout, type FetchJsonResult } from "./http-fetch.js" +import { sleep } from "../lib/sleep.js" export interface ExpoPushConfig { accessToken?: string @@ -26,10 +27,6 @@ function isRetryableStatus(status: number): boolean { return status === HTTP_TOO_MANY_REQUESTS || status >= HTTP_SERVER_ERROR_MIN } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export function isExpoPushToken(token: string): boolean { return EXPO_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix)) } diff --git a/services/api/src/adapters/push-sender.ts b/services/api/src/adapters/push-sender.ts index 3235603b..def57f41 100644 --- a/services/api/src/adapters/push-sender.ts +++ b/services/api/src/adapters/push-sender.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto" import type { PushSender, PushPayload, PushPlatform } from "@civfix/shared/interfaces" import type { Db } from "../db/client.js" import { pushTokens } from "../db/schema/push_tokens.js" @@ -7,7 +6,8 @@ import { makeApnsDispatcher } from "./push-apns.js" import { makeFcmDispatcher } from "./push-fcm.js" import { makeWebPushDispatcher } from "./push-webpush.js" import { makeExpoDispatcher, isExpoPushToken, type ExpoPushConfig } from "./push-expo.js" -import { mapWithLimit } from "../services/media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { sha256HexSync } from "../lib/hash.js" import type { CounterStore } from "../abuse/counter-store.js" import type { PushAddressResolver } from "../services/push-token-policy.js" @@ -43,7 +43,7 @@ export interface PushLogger { const LOG_HASH_HEX_CHARS = 12 export function hashForLog(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, LOG_HASH_HEX_CHARS) + return sha256HexSync(value).slice(0, LOG_HASH_HEX_CHARS) } const ACTIVE_TOKEN_SCAN_CAP_PER_USER = 20 diff --git a/services/api/src/adapters/push-webpush.ts b/services/api/src/adapters/push-webpush.ts index a8071fb3..f845e432 100644 --- a/services/api/src/adapters/push-webpush.ts +++ b/services/api/src/adapters/push-webpush.ts @@ -6,7 +6,7 @@ import { resolveSafePushTarget, type PushAddressResolver, } from "../services/push-token-policy.js" -import { mapWithLimit } from "../services/media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" import { Agent } from "node:https" import type WebPush from "web-push" diff --git a/services/api/src/adapters/redis-incr.ts b/services/api/src/adapters/redis-incr.ts index 3642f7ac..9fc443c6 100644 --- a/services/api/src/adapters/redis-incr.ts +++ b/services/api/src/adapters/redis-incr.ts @@ -1,4 +1,5 @@ import type { RedisClient } from "./redis.js" +import { MS_PER_SECOND } from "../lib/time.js" // The window starts whenever the key has no expiry, not when the value equals the increment: a zero // increment would otherwise restart it, and a key whose TTL was lost would never expire again. @@ -14,8 +15,6 @@ const DECRBY_FLOOR_LUA = const DECRBY_COMMAND_NAME = "civfixDecrByFloor" -const MS_PER_SECOND = 1000 - type IncrExpire = (key: string, ttlSeconds: number) => Promise type IncrByExpire = (key: string, by: number, ttlSeconds: number) => Promise diff --git a/services/api/src/adapters/storage.local.ts b/services/api/src/adapters/storage.local.ts index 94859ec1..3809743b 100644 --- a/services/api/src/adapters/storage.local.ts +++ b/services/api/src/adapters/storage.local.ts @@ -1,10 +1,13 @@ -import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto" +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto" import { createReadStream, type Dirent } from "node:fs" import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" import type { Readable } from "node:stream" import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path" import { AppError, ErrorCode } from "@civfix/shared" import type { StorageHeadWithEtag } from "../services/media-etag.js" +import { stripTrailingSlashes } from "../lib/base-url.js" +import { sha256HexSync } from "../lib/hash.js" +import { MS_PER_SECOND } from "../lib/time.js" import type { PresignPutOptions, PresignPutResult, @@ -30,9 +33,7 @@ const DEFAULT_CONTENT_TYPE = "application/octet-stream" const DEFAULT_LIST_LIMIT = 1000 const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]) const LOOPBACK_IPV4_PATTERN = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ -const TRAILING_SLASHES_RE = /\/+$/ const STAGED_FILE_RANDOM_BYTES = 16 -const MS_PER_SECOND = 1000 export type SignatureVerdict = "valid" | "expired" | "invalid" @@ -344,7 +345,7 @@ function assertUsableRoot(config: LocalDiskStorageConfig): string { } function contentEtag(body: Uint8Array | Buffer): string { - return createHash("sha256").update(body).digest("hex") + return sha256HexSync(body) } function assertSafeKey(key: string): void { @@ -382,7 +383,7 @@ function normalizeBaseUrl(publicApiUrl: string): string { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`PUBLIC_API_URL must be http(s): ${trimmed}`) } - return trimmed.replace(TRAILING_SLASHES_RE, "") + return stripTrailingSlashes(trimmed) } function isLoopbackUrl(baseUrl: string): boolean { diff --git a/services/api/src/adapters/storage.r2.ts b/services/api/src/adapters/storage.r2.ts index e18cc45f..602f44f9 100644 --- a/services/api/src/adapters/storage.r2.ts +++ b/services/api/src/adapters/storage.r2.ts @@ -9,6 +9,7 @@ import type { } from "@civfix/shared/interfaces" import { normalizeEtag, type StorageHeadWithEtag } from "../services/media-etag.js" import { readProxySettings, shouldProxyHost } from "./proxy-egress.js" +import { stripTrailingSlashes } from "../lib/base-url.js" import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3" export interface R2StorageConfig { @@ -37,7 +38,6 @@ const DEFAULT_CONTENT_TYPE = "application/octet-stream" const HTTP_NOT_FOUND = 404 const NOT_FOUND_ERROR_NAMES: ReadonlySet = new Set(["NotFound", "NoSuchKey"]) -const TRAILING_SLASHES_RE = /\/+$/ const LEADING_SLASHES_RE = /^\/+/ const HTTP_SCHEME_RE = /^https?:\/\//i @@ -245,7 +245,7 @@ async function boundedRequestHandler(host: string): Promise { diff --git a/services/api/src/auth/jwks.ts b/services/api/src/auth/jwks.ts index 7dfd6177..66753af0 100644 --- a/services/api/src/auth/jwks.ts +++ b/services/api/src/auth/jwks.ts @@ -1,7 +1,7 @@ -import { createHash } from "node:crypto" import { AppError, ErrorCode } from "@civfix/shared" import { exposeMessage } from "../errors/exposed-message.js" import { constantTimeStringEqual } from "./crypto.js" +import { sha256HexSync } from "../lib/hash.js" const JWKS_CACHE_TTL_MS = 60 * 60 * 1000 @@ -198,7 +198,7 @@ function decodeJsonSegment(segment: string): T | null { function nonceMatches(claimNonce: string | undefined, expected: string): boolean { if (typeof claimNonce !== "string" || claimNonce.length === 0) return false - const expectedHash = createHash("sha256").update(expected).digest("hex") + const expectedHash = sha256HexSync(expected) return ( constantTimeStringEqual(claimNonce, expected) || constantTimeStringEqual(claimNonce, expectedHash) diff --git a/services/api/src/auth/oauth.ts b/services/api/src/auth/oauth.ts index 77c1c1a7..ac05218c 100644 --- a/services/api/src/auth/oauth.ts +++ b/services/api/src/auth/oauth.ts @@ -1,5 +1,5 @@ import { Apple, Google, generateCodeVerifier, generateState } from "arctic" -import { AppError } from "@civfix/shared" +import { AppError, collapseWhitespace } from "@civfix/shared" import { containsSlur } from "../abuse/slur-filter.js" import { EmailTakenError, @@ -306,7 +306,7 @@ function providerFallbackName(provider: string): string { function sanitizeDisplayName(raw: string | null | undefined): string | null { if (raw === null || raw === undefined) return null - const collapsed = raw.replace(/\s+/g, " ").trim() + const collapsed = collapseWhitespace(raw) const name = truncateCodePoints(collapsed, MAX_PROVIDER_DISPLAY_NAME_LENGTH).trimEnd() if (name === "" || containsSlur(name)) return null return name diff --git a/services/api/src/db/cursor-helpers.ts b/services/api/src/db/cursor-helpers.ts index d436f496..f386ac5d 100644 --- a/services/api/src/db/cursor-helpers.ts +++ b/services/api/src/db/cursor-helpers.ts @@ -1,5 +1,6 @@ import type postgres from "postgres" import type { Queryable } from "./client.js" +import { MS_PER_MINUTE } from "../lib/time.js" export const CURSOR_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -73,7 +74,6 @@ const CURSOR_INSTANT_PARTS_RE = // Postgres refuses a timestamptz input whose UTC offset is past 15:59 (22009), while JS Date takes // anything up to 23:59. const MAX_CURSOR_OFFSET_MINUTES = 15 * 60 + 59 -const MINUTE_MS = 60_000 function pad(n: number, width = 2): string { return String(n).padStart(width, "0") @@ -96,7 +96,7 @@ function canonicalCursorInstant(text: string, at: Date): string | null { offsetMinutes = (Number(offsetH) * 60 + offsetMinutePart) * (sign === "-" ? -1 : 1) if (Math.abs(offsetMinutes) > MAX_CURSOR_OFFSET_MINUTES) return null } - const wall = new Date(at.getTime() + offsetMinutes * MINUTE_MS) + const wall = new Date(at.getTime() + offsetMinutes * MS_PER_MINUTE) if ( wall.getUTCFullYear() !== Number(year) || wall.getUTCMonth() + 1 !== Number(month) || diff --git a/services/api/src/db/demo-join-event.ts b/services/api/src/db/demo-join-event.ts index 7fc7ba16..c1b5d753 100644 --- a/services/api/src/db/demo-join-event.ts +++ b/services/api/src/db/demo-join-event.ts @@ -25,10 +25,7 @@ import { DEMO_EMAIL_DOMAIN, DEMO_EMAIL_PATTERN } from "./seed-demo-domain.js" import { demoTicketTokenHasher, mintDemoSignupSeats } from "./demo-signup-seats.js" import { DEMO_PRNG_SEED, chance, rand, rint, seedDemoRandom, shuffle } from "./demo-random.js" import { deriveCleanupStatus, eventWindowOf } from "../services/cleanup-rules.js" - -const MINUTE_MS = 60_000 -const HOUR_MS = 60 * MINUTE_MS -const DAY_MS = 24 * HOUR_MS +import { MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE } from "../lib/time.js" const DEFAULT_JOIN_COUNT = 15 const MAX_JOIN_COUNT = 200 @@ -38,17 +35,17 @@ const SLOT_CLAIM_PROBABILITY = 0.45 // of signups rather than rows backdated before the event was announced. The start is clamped to at // least 12h ago so an event with a bogus/near-future created_at still yields a spread of past join // times instead of a one-minute cluster. -const RSVP_SPREAD_MS = 5 * DAY_MS -const MIN_RSVP_LOOKBACK_MS = 12 * HOUR_MS +const RSVP_SPREAD_MS = 5 * MS_PER_DAY +const MIN_RSVP_LOOKBACK_MS = 12 * MS_PER_HOUR /** Biased toward the recent end, because RSVPs cluster after a promo post. */ function joinTimestamp(start: Date, now: Date): Date { - const span = Math.max(now.getTime() - start.getTime(), MINUTE_MS) + const span = Math.max(now.getTime() - start.getTime(), MS_PER_MINUTE) const t = new Date(now.getTime() - Math.pow(rand(), 2) * span) t.setUTCMinutes(rint(0, 59), rint(0, 59), 0) // The minute/second jitter can overshoot either bound; clamp inside (start, now). - if (t.getTime() >= now.getTime()) return new Date(now.getTime() - rint(2, 45) * MINUTE_MS) - if (t.getTime() <= start.getTime()) return new Date(start.getTime() + MINUTE_MS) + if (t.getTime() >= now.getTime()) return new Date(now.getTime() - rint(2, 45) * MS_PER_MINUTE) + if (t.getTime() <= start.getTime()) return new Date(start.getTime() + MS_PER_MINUTE) return t } @@ -176,7 +173,7 @@ async function claimDemoSlots( claimed++ await tx` INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id, claimed_at) - VALUES (${eventId}, ${m.user_id}, ${slot.id}, ${new Date(m.joined_at.getTime() + rint(1, 30) * MINUTE_MS)}) + VALUES (${eventId}, ${m.user_id}, ${slot.id}, ${new Date(m.joined_at.getTime() + rint(1, 30) * MS_PER_MINUTE)}) ON CONFLICT (cleanup_id, user_id) DO NOTHING ` } diff --git a/services/api/src/db/seed-demo-la.ts b/services/api/src/db/seed-demo-la.ts index f3a7f618..7e00367b 100644 --- a/services/api/src/db/seed-demo-la.ts +++ b/services/api/src/db/seed-demo-la.ts @@ -50,6 +50,7 @@ import { DEMO_EMAIL_DOMAIN, DEMO_EMAIL_PATTERN } from "./seed-demo-domain.js" import { DEFAULT_EVENT_DURATION_MS, DEFAULT_EVENT_SLOT_TITLE } from "../services/cleanup-rules.js" import { demoTicketTokenHasher, mintDemoSignupSeats } from "./demo-signup-seats.js" import { touchUserActivity } from "./sql/user-activity.js" +import { MINUTES_PER_HOUR, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE } from "../lib/time.js" import { DEMO_PRNG_SEED, chance, @@ -162,10 +163,6 @@ const INSERT_CHUNK = { // Timestamps get an LA-plausible time-of-day (evenings and weekends heavier), stored as UTC. The // seeded window spans both PST and PDT, so the offset is resolved per date. -const MINUTE = 60 * 1000 -const HOUR = 60 * MINUTE -const DAY = 24 * HOUR -const HOUR_MINUTES = 60 const DAYS_PER_WEEK = 7 const LA_TIME_ZONE = "America/Los_Angeles" const LA_STANDARD_OFFSET_HOURS = -8 @@ -197,8 +194,8 @@ export function laLocalToUtc(dayMs: number, hour: number, minute: number, second minute, second, ) - const offset = laOffsetHoursAt(new Date(wallAsUtc - LA_STANDARD_OFFSET_HOURS * HOUR)) - return new Date(wallAsUtc - offset * HOUR) + const offset = laOffsetHoursAt(new Date(wallAsUtc - LA_STANDARD_OFFSET_HOURS * MS_PER_HOUR)) + return new Date(wallAsUtc - offset * MS_PER_HOUR) } function localHour(): number { @@ -234,7 +231,7 @@ function atLocalTime(dayMs: number): Date { } function randTimestamp(start: Date, end: Date): Date { - const span = Math.max(end.getTime() - start.getTime(), MINUTE) + const span = Math.max(end.getTime() - start.getTime(), MS_PER_MINUTE) for (let attempt = 0; attempt < TIMESTAMP_ATTEMPTS; attempt++) { const t = atLocalTime(start.getTime() + rand() * span) if (t.getTime() < start.getTime() || t.getTime() > end.getTime()) continue @@ -245,7 +242,7 @@ function randTimestamp(start: Date, end: Date): Date { } function minutesAfter(d: Date, min: number, max: number): Date { - return new Date(d.getTime() + rint(min, max) * MINUTE) + return new Date(d.getTime() + rint(min, max) * MS_PER_MINUTE) } function later(a: Date, b: Date): Date { return a.getTime() >= b.getTime() ? a : b @@ -533,7 +530,10 @@ function eventScheduledAt(kind: SeedEvent["status"], organizer: SeedUser, now: D if (kind === "upcoming") return nextSaturdayish(now, rint(3, 21)) return nextSaturdayish( new Date( - later(organizer.createdAt, new Date(now.getTime() - EVENT_HISTORY_DAYS * DAY)).getTime(), + later( + organizer.createdAt, + new Date(now.getTime() - EVENT_HISTORY_DAYS * MS_PER_DAY), + ).getTime(), ), rint(7, 120), now, @@ -547,12 +547,15 @@ function eventScheduledAt(kind: SeedEvent["status"], organizer: SeedUser, now: D function eventCreatedAt(organizer: SeedUser, scheduledAt: Date, now: Date): Date { const createdCeiling = new Date( Math.min( - scheduledAt.getTime() - EVENT_MIN_CREATE_LEAD_DAYS * DAY, - now.getTime() - EVENT_MIN_AGE_HOURS * HOUR, + scheduledAt.getTime() - EVENT_MIN_CREATE_LEAD_DAYS * MS_PER_DAY, + now.getTime() - EVENT_MIN_AGE_HOURS * MS_PER_HOUR, ), ) return randTimestamp( - later(organizer.createdAt, new Date(scheduledAt.getTime() - EVENT_MAX_CREATE_LEAD_DAYS * DAY)), + later( + organizer.createdAt, + new Date(scheduledAt.getTime() - EVENT_MAX_CREATE_LEAD_DAYS * MS_PER_DAY), + ), createdCeiling, ) } @@ -666,15 +669,15 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { } function nextSaturdayish(base: Date, minDays: number, latest?: Date): Date { - let d = new Date(base.getTime() + minDays * DAY) + let d = new Date(base.getTime() + minDays * MS_PER_DAY) for (let i = 0; i < DAYS_PER_WEEK; i++) { - const dow = new Date(d.getTime() + i * DAY).getUTCDay() + const dow = new Date(d.getTime() + i * MS_PER_DAY).getUTCDay() if (dow === SATURDAY || (dow === SUNDAY && chance(0.4))) { - d = new Date(d.getTime() + i * DAY) + d = new Date(d.getTime() + i * MS_PER_DAY) break } } - if (latest && d.getTime() >= latest.getTime()) d = new Date(latest.getTime() - DAY) + if (latest && d.getTime() >= latest.getTime()) d = new Date(latest.getTime() - MS_PER_DAY) return laLocalToUtc(d.getTime(), EVENT_START_HOUR, pick([0, 0, 30]), 0) } @@ -694,7 +697,10 @@ function reportTimeline( } let cursor = publishedAt ?? createdAt const nextStep = (maxDays: number): Date => - randTimestamp(cursor, new Date(Math.min(cursor.getTime() + maxDays * DAY, now.getTime()))) + randTimestamp( + cursor, + new Date(Math.min(cursor.getTime() + maxDays * MS_PER_DAY, now.getTime())), + ) if (status === "acknowledged" || status === "in_progress" || status === "resolved") { cursor = nextStep(10) timeline.push({ @@ -875,7 +881,7 @@ function addEventPosts(events: SeedEvent[], now: Date, addTop: AddTopPost): void } } if (ev.status === "done" && chance(0.85)) { - const recapAt = minutesAfter(ev.scheduledAt, 3 * HOUR_MINUTES, 30 * HOUR_MINUTES) + const recapAt = minutesAfter(ev.scheduledAt, 3 * MINUTES_PER_HOUR, 30 * MINUTES_PER_HOUR) if (recapAt.getTime() < now.getTime()) { addTop( ev.organizer, @@ -895,7 +901,7 @@ function addEventPosts(events: SeedEvent[], now: Date, addTop: AddTopPost): void function addReportPosts(reports: SeedReport[], now: Date, addTop: AddTopPost): void { for (const r of reports) { if (r.status === "submitted" || !chance(0.3)) continue - const at = minutesAfter(r.publishedAt ?? r.createdAt, 5, 36 * HOUR_MINUTES) + const at = minutesAfter(r.publishedAt ?? r.createdAt, 5, 36 * MINUTES_PER_HOUR) if (at.getTime() >= now.getTime()) continue addTop( r.reporter, @@ -952,7 +958,7 @@ function addThreadReplies( if (replier.id !== root.author.id && threadRepliers.has(replier.id)) continue const start = later(replier.createdAt, parent.createdAt) const end = new Date( - Math.min(parent.createdAt.getTime() + REPLY_WINDOW_DAYS * DAY, now.getTime()), + Math.min(parent.createdAt.getTime() + REPLY_WINDOW_DAYS * MS_PER_DAY, now.getTime()), ) if (start.getTime() >= end.getTime()) continue const [poolEn, poolEs] = replyPools(parent) @@ -987,7 +993,7 @@ function reshareWindow( ): { start: Date; end: Date } | null { const start = later(sharer.createdAt, target.createdAt) const end = new Date( - Math.min(target.createdAt.getTime() + RESHARE_WINDOW_DAYS * DAY, now.getTime()), + Math.min(target.createdAt.getTime() + RESHARE_WINDOW_DAYS * MS_PER_DAY, now.getTime()), ) return start.getTime() >= end.getTime() ? null : { start, end } } @@ -1107,7 +1113,9 @@ function makeLikesAndSaves( const k = `${p.id}:${liker.id}` if (likeKeys.has(k)) continue const start = later(liker.createdAt, p.createdAt) - const end = new Date(Math.min(p.createdAt.getTime() + LIKE_WINDOW_DAYS * DAY, now.getTime())) + const end = new Date( + Math.min(p.createdAt.getTime() + LIKE_WINDOW_DAYS * MS_PER_DAY, now.getTime()), + ) if (start.getTime() >= end.getTime()) continue likeKeys.add(k) likes.push({ postId: p.id, userId: liker.id, createdAt: randTimestamp(start, end) }) @@ -1126,7 +1134,7 @@ function makeHours(events: SeedEvent[], now: Date): SeedHours[] { const rows: SeedHours[] = [] for (const ev of events) { if (ev.status !== "done") continue - const loggedAt = minutesAfter(ev.scheduledAt, 4 * HOUR_MINUTES, 48 * HOUR_MINUTES) + const loggedAt = minutesAfter(ev.scheduledAt, 4 * MINUTES_PER_HOUR, 48 * MINUTES_PER_HOUR) if (loggedAt.getTime() >= now.getTime()) continue for (const m of ev.members) { if (m.role !== "organizer" && !chance(0.85)) continue // a few no-shows never get credited @@ -1884,8 +1892,12 @@ function generateCohort( now: Date, hashFor: (seatId: string) => string, ): SeedData { - const start = new Date(now.getTime() - COHORT_HISTORY_DAYS * DAY) - const users = makeUsers(userCount, start, new Date(now.getTime() - NEWEST_ACCOUNT_AGE_DAYS * DAY)) + const start = new Date(now.getTime() - COHORT_HISTORY_DAYS * MS_PER_DAY) + const users = makeUsers( + userCount, + start, + new Date(now.getTime() - NEWEST_ACCOUNT_AGE_DAYS * MS_PER_DAY), + ) const follows = makeFollows(users, now) const events = makeEvents(users, now) const reports = makeReports(users, Math.round(userCount * REPORTS_PER_USER), now) diff --git a/services/api/src/env/core-env.ts b/services/api/src/env/core-env.ts index 7adaf856..f54f1891 100644 --- a/services/api/src/env/core-env.ts +++ b/services/api/src/env/core-env.ts @@ -7,6 +7,7 @@ import { OUTBOUND_SEND_MIN_THROUGHPUT_BPS, } from "../services/admin/outbound-send-policy.js" import { CENSUS_DEFAULT_TIMEOUT_MS } from "../adapters/jurisdiction-lookup.census.js" +import { OCI_MAILER_DEFAULT_TIMEOUT_MS } from "../adapters/mailer-defaults.js" import { DEFAULT_API_PORT } from "../lib/base-url.js" import { parseBool, @@ -25,9 +26,6 @@ const DEV_ANON_TOKEN_SIGNING_KEY = "dev-insecure-anon-token-signing-key-do-not-u const SIGNING_KEY_MIN_LENGTH = 32 const SMTP_PORT_DEFAULT = 587 -// Mirrors OCI_MAILER_DEFAULT_TIMEOUT_MS in adapters/mailer.oci.ts, which cannot be imported here: that -// module pulls the native argon2 binding in through auth/otp. -const SMTP_TIMEOUT_MS_DEFAULT = 15_000 const SMS_DAILY_CAP_DEFAULT = 50 const VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS_DEFAULT = 60 const OUTREACH_THROTTLE_DAYS_DEFAULT = 7 @@ -331,7 +329,7 @@ export function loadMailEnv( const OCI_EMAIL_SMTP_PASS = r.requiredString("OCI_EMAIL_SMTP_PASS", gated) const OCI_EMAIL_SMTP_TIMEOUT_MS = r.positiveInt( "OCI_EMAIL_SMTP_TIMEOUT_MS", - SMTP_TIMEOUT_MS_DEFAULT, + OCI_MAILER_DEFAULT_TIMEOUT_MS, ) const minThroughput = r.positiveInt( "OUTBOUND_SEND_MIN_THROUGHPUT_BPS", diff --git a/services/api/src/lib/filename.ts b/services/api/src/lib/filename.ts new file mode 100644 index 00000000..30cf6f85 --- /dev/null +++ b/services/api/src/lib/filename.ts @@ -0,0 +1,9 @@ +export const ATTACHMENT_FILENAME_MAX_CHARS = 120 + +// Only ever used through String#replace, which resets lastIndex, so sharing the /g regex is safe. +const UNSAFE_FILENAME_CHARS_RE = /[^A-Za-z0-9._-]+/g +const LEADING_DOTS_RE = /^\.+/ + +export function safeFilenameChars(name: string): string { + return name.replace(UNSAFE_FILENAME_CHARS_RE, "_").replace(LEADING_DOTS_RE, "") +} diff --git a/services/api/src/lifecycle.ts b/services/api/src/lifecycle.ts index 8efa6230..955afcc8 100644 --- a/services/api/src/lifecycle.ts +++ b/services/api/src/lifecycle.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify" import { flushErrorReporting } from "./errors/glitchtip.js" import { SHUTDOWN_DRAIN_MS_MAX } from "./env/parsers.js" +import { sleep } from "./lib/sleep.js" declare module "fastify" { interface FastifyInstance { @@ -55,12 +56,6 @@ interface WebsocketHost { websocketServer?: { clients?: Iterable<{ terminate: () => void }> } } -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - async function settleWithin(work: Promise, ms: number): Promise<"settled" | "timeout"> { let timer: ReturnType | undefined const expiry = new Promise<"timeout">((resolve) => { @@ -147,7 +142,7 @@ export function makeShutdown( }, hardDeadlineMs) deadline.unref() - if (drainMs > 0) await delay(drainMs) + if (drainMs > 0) await sleep(drainMs) app.log.info("shutdown: drain window elapsed, closing server") diff --git a/services/api/src/routes/admin/broadcasts.routes.ts b/services/api/src/routes/admin/broadcasts.routes.ts index 866b4700..4998b21f 100644 --- a/services/api/src/routes/admin/broadcasts.routes.ts +++ b/services/api/src/routes/admin/broadcasts.routes.ts @@ -7,7 +7,6 @@ import { type AdminHostListResponse, type SetHostMessagingSuspendedResponse, } from "@civfix/shared" -import { createHash } from "node:crypto" import type { FastifyInstance, FastifyRequest } from "fastify" import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" @@ -16,6 +15,8 @@ import { parse } from "../_validate.js" import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import { makeDrizzleBroadcastRepository } from "../../services/host/broadcast-repository.drizzle.js" import type { BroadcastRepository } from "../../services/host/broadcast-repository.js" +import { sha256HexSync } from "../../lib/hash.js" +import { MS_PER_DAY } from "../../lib/time.js" export interface AdminBroadcastOverrides { repo: BroadcastRepository @@ -33,13 +34,11 @@ const ADMIN_HOST_DEFAULT_LIMIT = 50 const ADMIN_HOST_DEFAULT_WINDOW_DAYS = 30 -const DAY_MS = 24 * 60 * 60 * 1000 - // Enough of the digest to tell subjects apart in the console without echoing the subject text itself. const SUBJECT_HASH_HEX_LENGTH = 16 function hashSubject(subject: string): string { - return createHash("sha256").update(subject).digest("hex").slice(0, SUBJECT_HASH_HEX_LENGTH) + return sha256HexSync(subject).slice(0, SUBJECT_HASH_HEX_LENGTH) } function mergeParams(request: FastifyRequest): Record { @@ -109,7 +108,7 @@ export async function registerAdminBroadcastRoutes( const rows = await broadcastRepo().listAdminHosts({ ...(query.q !== undefined ? { q: query.q } : {}), ...(query.suspended !== undefined ? { suspended: query.suspended } : {}), - windowStart: new Date(Date.now() - windowDays * DAY_MS), + windowStart: new Date(Date.now() - windowDays * MS_PER_DAY), cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), limit: limit + 1, }) diff --git a/services/api/src/routes/admin/inbox.routes.ts b/services/api/src/routes/admin/inbox.routes.ts index e7091175..bdc052ec 100644 --- a/services/api/src/routes/admin/inbox.routes.ts +++ b/services/api/src/routes/admin/inbox.routes.ts @@ -17,7 +17,8 @@ import { idParam, parse, parseBodyWithId, sendOk } from "./_route-utils.js" import { auditRead } from "./_audit-read.js" import { requireOperator } from "../../auth/admin-guard.js" import { MEDIA_GET_URL_TTL_SEC } from "../../services/media-intake-service.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../../services/media-presign.js" +import { PRESIGN_CONCURRENCY } from "../../services/media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" import { makeDrizzleInboundRepository, type InboundRepository, diff --git a/services/api/src/routes/admin/mail.routes.ts b/services/api/src/routes/admin/mail.routes.ts index 09d7aa24..6a48946b 100644 --- a/services/api/src/routes/admin/mail.routes.ts +++ b/services/api/src/routes/admin/mail.routes.ts @@ -23,7 +23,8 @@ import { auditRead } from "./_audit-read.js" import { requireOperator } from "../../auth/admin-guard.js" import { perIdentity } from "../../plugins/rate-limit.js" import { MEDIA_GET_URL_TTL_SEC } from "../../services/media-intake-service.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../../services/media-presign.js" +import { PRESIGN_CONCURRENCY } from "../../services/media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" import { makeMailService } from "../../services/admin/mail-service.js" import { makeContainerOutboundMailService, diff --git a/services/api/src/routes/chat-route-helpers.ts b/services/api/src/routes/chat-route-helpers.ts index 320b6fe8..9a61dd7f 100644 --- a/services/api/src/routes/chat-route-helpers.ts +++ b/services/api/src/routes/chat-route-helpers.ts @@ -4,6 +4,7 @@ import { broadcastMessageUpdate, roomKeyFor } from "../ws/gateway.js" import type { ChatMessageMeta, SoftDeleteOpts } from "../services/chat-repository.drizzle.js" import type { ResolveChatPowers } from "../services/chat-room-roles.js" import { neutralizeChatViewerFields } from "../services/chat-viewer-fields.js" +import { clampPageLimit } from "../lib/page-limit.js" export { neutralizeChatViewerFields } @@ -13,11 +14,11 @@ export const DELETE_MESSAGE_FORBIDDEN = "You can't delete this message." export const MESSAGE_ALREADY_DELETED = "This message was already deleted." export const REPORT_NOT_FOUND = "Report not found" -export const CHAT_HISTORY_DEFAULT_LIMIT = 30 +const CHAT_HISTORY_DEFAULT_LIMIT = 30 const CHAT_HISTORY_MAX_LIMIT = 50 export function clampChatHistoryLimit(requested: number | undefined): number { - return Math.min(Math.max(requested ?? CHAT_HISTORY_DEFAULT_LIMIT, 1), CHAT_HISTORY_MAX_LIMIT) + return clampPageLimit(requested, CHAT_HISTORY_DEFAULT_LIMIT, CHAT_HISTORY_MAX_LIMIT) } export interface ChatHistorySource { diff --git a/services/api/src/routes/cleanups.routes.ts b/services/api/src/routes/cleanups.routes.ts index 4e3d0cef..9c5c5ac9 100644 --- a/services/api/src/routes/cleanups.routes.ts +++ b/services/api/src/routes/cleanups.routes.ts @@ -59,7 +59,7 @@ import { makeRouteNotificationService } from "../services/route-notifier.js" import { MEDIA_GET_URL_TTL_SEC } from "../services/media-intake-service.js" import { perHost, perIdentity } from "../plugins/rate-limit.js" import { route } from "../versioning/route.js" -import { chatHistoryPayload } from "./chat-route-helpers.js" +import { chatHistoryPayload, clampChatHistoryLimit } from "./chat-route-helpers.js" import { CappedBBoxQueryParam, LatLngQueryParam } from "./query-encoding.js" export interface CleanupServiceOverrides { @@ -94,8 +94,6 @@ const ListCleanupsQuerySchema = z.object({ limit: z.string().optional(), }) -const HISTORY_DEFAULT_LIMIT = 30 - const MEMBER_MANAGEMENT_RATE_LIMIT = { max: 20, timeWindow: "1 minute" } as const const CREATE_CLEANUP_RATE_LIMIT = { max: 20, timeWindow: "1 minute" } as const @@ -468,7 +466,7 @@ export async function registerCleanupRoutes( const isMember = await repo().isMember(id, userId) if (!isMember) throw AppError.forbidden("You are not a member of this cleanup.") - const limit = q.limit ?? HISTORY_DEFAULT_LIMIT + const limit = clampChatHistoryLimit(q.limit) const pinsSource = q.before === undefined && q.around === undefined ? pinsRepo() : null const payload: ChatHistoryResponse = await chatHistoryPayload( { diff --git a/services/api/src/routes/dm.routes.ts b/services/api/src/routes/dm.routes.ts index 6f8143dd..c05f5ad0 100644 --- a/services/api/src/routes/dm.routes.ts +++ b/services/api/src/routes/dm.routes.ts @@ -28,8 +28,8 @@ import { type ConversationMutesRepository, } from "../services/conversation-mutes-repository.drizzle.js" import { - CHAT_HISTORY_DEFAULT_LIMIT, chatHistoryPayload, + clampChatHistoryLimit, DELETE_MESSAGE_FORBIDDEN, MESSAGE_ALREADY_DELETED, } from "./chat-route-helpers.js" @@ -124,7 +124,7 @@ export async function registerDmRoutes(app: FastifyInstance, container: Containe const q = parse(DmHistoryQuerySchema, request.query) await authorizePeer(id, userId, "You can't view this conversation.") - const limit = q.limit ?? CHAT_HISTORY_DEFAULT_LIMIT + const limit = clampChatHistoryLimit(q.limit) const payload: ChatHistoryResponse = await chatHistoryPayload( { history: (before, pageLimit, around) => diff --git a/services/api/src/routes/forms.routes.ts b/services/api/src/routes/forms.routes.ts index 39197857..644b2e88 100644 --- a/services/api/src/routes/forms.routes.ts +++ b/services/api/src/routes/forms.routes.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto" import { AppError } from "@civfix/shared" import { z } from "zod" import type { FastifyInstance } from "fastify" @@ -12,6 +11,7 @@ import { renderEmailBody } from "../adapters/email-layout.js" import { sanitizeHeaderValue } from "../adapters/mail-text.js" import { parse } from "./_validate.js" import { exposeMessage } from "../errors/exposed-message.js" +import { sha256HexSync } from "../lib/hash.js" const HOME_TURF_REQUESTS_PER_MINUTE = 5 @@ -100,7 +100,7 @@ export async function claimHomeTurfConfirmation( counters: CounterStore, ): Promise { const canonical = canonicalizeHomeTurfEmail(email) - const digest = createHash("sha256").update(canonical).digest("hex") + const digest = sha256HexSync(canonical) const key = HOME_TURF_EMAIL_COUNTER_PREFIX + digest const count = await counters.incr(key, HOME_TURF_EMAIL_WINDOW_SECONDS) return count <= HOME_TURF_EMAIL_LIMIT_PER_DAY diff --git a/services/api/src/routes/social.routes.ts b/services/api/src/routes/social.routes.ts index 0a8e0f17..7ad1fa04 100644 --- a/services/api/src/routes/social.routes.ts +++ b/services/api/src/routes/social.routes.ts @@ -30,6 +30,7 @@ import { makeRouteNotificationService } from "../services/route-notifier.js" import { MEDIA_GET_URL_TTL_SEC } from "../services/media-intake-service.js" import { perIdentity } from "../plugins/rate-limit.js" import { route } from "../versioning/route.js" +import { isUuid } from "../db/cursor-helpers.js" import { parse } from "./_validate.js" export const FOLLOW_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) @@ -53,8 +54,6 @@ const PERSON_REF_MAX = 40 const PersonRefParamsSchema = z.object({ id: z.string().min(1).max(PERSON_REF_MAX) }).strict() -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - const FOLLOW_SUGGESTIONS_DEFAULT_LIMIT = 10 export async function registerSocialRoutes( @@ -110,7 +109,7 @@ export async function registerSocialRoutes( } async function resolvePersonId(ref: string): Promise { - if (UUID_RE.test(ref)) { + if (isUuid(ref)) { const person = await repo().findPersonById(ref) if (person === null) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return ref @@ -171,7 +170,7 @@ export async function registerSocialRoutes( route(app, "getProfile", async (request, reply) => { const { id } = parse(PersonRefParamsSchema, request.params) const svc = service() - const payload: GetProfileResponse = UUID_RE.test(id) + const payload: GetProfileResponse = isUuid(id) ? await svc.getProfile(id, viewerOf(request)) : await svc.getProfileByHandle(id, viewerOf(request)) reply.status(200).send(payload) diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index b99a27d7..f4243c1c 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -12,7 +12,7 @@ import type { ActivityListQuery, ActivityListResponse, } from "@civfix/shared" -import { ADMIN_DEFAULT_LIMIT, clampLimit } from "./pagination.js" +import { clampLimit } from "./pagination.js" export type ActivitySource = "audit" | "report" | "cleanup" | "mail_event" @@ -276,7 +276,7 @@ export function makeActivityService(deps: ActivityServiceDeps): ActivityService filter: parseActivityFilter(query.filter), sort: parseActivitySort(query.sort), cursor: query.cursor ?? null, - limit: clampLimit(query.limit ?? ADMIN_DEFAULT_LIMIT), + limit: clampLimit(query.limit), } const { records, nextCursor } = await deps.repo.list(args) return { items: records.map((r) => classifyActivity(r, ref)), nextCursor } diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/src/services/admin/admin-event-repository.memory.ts index 92c54b2c..07e1feba 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -20,6 +20,7 @@ import { isPubliclyVisibleStatus } from "../report-visibility.js" import { toEventStatus } from "./event-status.js" import { CIVFIX_OFFICIAL_DISPLAY_NAME } from "../../auth/official-account.js" import { DEFAULT_EVENT_DURATION_MS, deriveCleanupStatus } from "../cleanup-rules.js" +import { MS_PER_HOUR } from "../../lib/time.js" import type { AdminEventMessageRecord, AdminEventRecord, @@ -32,10 +33,8 @@ import type { import type { LinkedReportView } from "../cleanup-service.js" import type { AdminEventCounts, EventKind, EventStatus, ReportCategory } from "@civfix/shared" -const HOUR_MS = 3_600_000 - -const IN_PROGRESS_SEED_OFFSET_MS = -HOUR_MS -const UPCOMING_SEED_OFFSET_MS = 7 * 24 * HOUR_MS +const IN_PROGRESS_SEED_OFFSET_MS = -MS_PER_HOUR +const UPCOMING_SEED_OFFSET_MS = 7 * 24 * MS_PER_HOUR const SEEDED_ACTOR_NAME = "operator" diff --git a/services/api/src/services/admin/admin-event-service.ts b/services/api/src/services/admin/admin-event-service.ts index 8e6b6da0..faf60677 100644 --- a/services/api/src/services/admin/admin-event-service.ts +++ b/services/api/src/services/admin/admin-event-service.ts @@ -21,7 +21,9 @@ import type { import { toLinkedReportRef, type LinkedReportView } from "../cleanup-service.js" import { toRelAbs } from "./admin-format.js" import { toPersonDTO, type AdminPersonRecord } from "./admin-person.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" +import { clampLimit } from "./pagination.js" import { eventTimelineKind, eventStatusNote, @@ -37,8 +39,6 @@ export { eventTimelineKind, } from "./admin-event-helpers.js" -const ADMIN_EVENTS_DEFAULT_LIMIT = 25 - const EVENT_NOT_FOUND = "Event not found" // A cleanup always HAS an organizer, so this stands in for corrupt data, not for a supported "no organizer" @@ -215,7 +215,7 @@ export function makeAdminEventService(deps: AdminEventServiceDeps): AdminEventSe status, flaggedOnly, cursor: query.cursor ?? null, - limit: query.limit ?? ADMIN_EVENTS_DEFAULT_LIMIT, + limit: clampLimit(query.limit), } // The counts describe the whole searched set rather than the page, so they are computed on page 1 // only (the shared admin-list policy; the console reads them off the first page). diff --git a/services/api/src/services/admin/admin-report-chat-service.ts b/services/api/src/services/admin/admin-report-chat-service.ts index 3a45e749..42075cb3 100644 --- a/services/api/src/services/admin/admin-report-chat-service.ts +++ b/services/api/src/services/admin/admin-report-chat-service.ts @@ -7,14 +7,15 @@ import { } from "@civfix/shared" import type { FastifyBaseLogger } from "fastify" import { neutralizeChatViewerFields } from "../chat-viewer-fields.js" -import { chatHistoryPayload, type ChatHistorySource } from "../../routes/chat-route-helpers.js" +import { + chatHistoryPayload, + clampChatHistoryLimit, + type ChatHistorySource, +} from "../../routes/chat-route-helpers.js" import { sendReportChatMessage, type ReportChatSendDeps } from "../report-chat-send.js" import type { AdminReportChatRepository } from "./admin-report-chat-repository.drizzle.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../auth/official-account.js" -const ADMIN_REPORT_CHAT_HISTORY_DEFAULT = 30 -const ADMIN_REPORT_CHAT_HISTORY_MAX = 50 - /** Called only after the operator's change committed; never throws. */ export type MessageUpdateAnnouncer = (messageId: string) => Promise @@ -77,10 +78,7 @@ export function makeAdminReportChatService( return { async history(reportId, query, viewerUserId): Promise { await assertReportExists(reportId) - const limit = Math.min( - Math.max(query.limit ?? ADMIN_REPORT_CHAT_HISTORY_DEFAULT, 1), - ADMIN_REPORT_CHAT_HISTORY_MAX, - ) + const limit = clampChatHistoryLimit(query.limit) return chatHistoryPayload(deps.historySource(reportId, viewerUserId), query, limit) }, diff --git a/services/api/src/services/admin/admin-report-service.ts b/services/api/src/services/admin/admin-report-service.ts index fc72bee9..47f059b8 100644 --- a/services/api/src/services/admin/admin-report-service.ts +++ b/services/api/src/services/admin/admin-report-service.ts @@ -21,9 +21,10 @@ import type { PacketAttachment } from "./outbound-mail-service.js" import { toLinkedEventRef, type LinkedEventView } from "../cleanup-service.js" import { toRelAbs } from "./admin-format.js" import { toPersonDTO } from "./admin-person.js" -import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignPacketMedia } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { PRESIGN_CONCURRENCY, type PresignPacketMedia } from "../media-presign.js" import { isPubliclyVisibleStatus } from "../report-visibility.js" -import { ADMIN_DEFAULT_LIMIT } from "./pagination.js" +import { clampLimit } from "./pagination.js" import { JURISDICTION_REPLY_NOTE, resolveListFilter, @@ -162,7 +163,7 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor flaggedOnly, needsVerificationOnly, cursor: query.cursor ?? null, - limit: query.limit ?? ADMIN_DEFAULT_LIMIT, + limit: clampLimit(query.limit), } const [{ records, nextCursor }, counts] = await Promise.all([ deps.repo.listReports(args), diff --git a/services/api/src/services/admin/admin-user-service.ts b/services/api/src/services/admin/admin-user-service.ts index c5308574..55a2c10d 100644 --- a/services/api/src/services/admin/admin-user-service.ts +++ b/services/api/src/services/admin/admin-user-service.ts @@ -21,7 +21,7 @@ import type { CleanupMemberRole, } from "@civfix/shared" import { toRelAbs } from "./admin-format.js" -import { ADMIN_DEFAULT_LIMIT } from "./pagination.js" +import { clampLimit } from "./pagination.js" import { applyRoleChange } from "./role-change.js" import type { MessageUpdateAnnouncer } from "./admin-report-chat-service.js" import { @@ -236,7 +236,7 @@ export function makeAdminUserService(deps: AdminUserServiceDeps): AdminUserServi flaggedOnly, deletedOnly, cursor: query.cursor ?? null, - limit: query.limit ?? ADMIN_DEFAULT_LIMIT, + limit: clampLimit(query.limit), } const [{ records, nextCursor }, counts] = await Promise.all([ deps.repo.listUsers(args), diff --git a/services/api/src/services/admin/analytics-shaping.ts b/services/api/src/services/admin/analytics-shaping.ts index 4f33f9b7..4811b255 100644 --- a/services/api/src/services/admin/analytics-shaping.ts +++ b/services/api/src/services/admin/analytics-shaping.ts @@ -21,6 +21,7 @@ import { type RetentionRow, type WeekBucket, } from "./analytics-types.js" +import { MS_PER_DAY, MS_PER_WEEK } from "../../lib/time.js" const MONTH_ABBR = [ "Jan", @@ -37,9 +38,6 @@ const MONTH_ABBR = [ "Dec", ] as const -const DAY_MS = 24 * 60 * 60 * 1000 -const WEEK_MS = 7 * DAY_MS - const ROUTE_TIME_PENDING_DELTA = "Phase 3" export function round1(n: number): number { @@ -172,7 +170,7 @@ export function buildPinsByWeek( const values = new Array(weeks).fill(0) for (const b of buckets) { const bWeek = startOfWeekUtc(b.weekStart).getTime() - const weeksAgo = Math.round((refWeek - bWeek) / WEEK_MS) + const weeksAgo = Math.round((refWeek - bWeek) / MS_PER_WEEK) if (weeksAgo >= 0 && weeksAgo < weeks) { // index 0 is the oldest week in the window, weeks-1 is the current week. values[weeks - 1 - weeksAgo] = b.count @@ -245,7 +243,7 @@ export function startOfWeekUtc(d: Date): Date { // getUTCDay: 0=Sun..6=Sat. Postgres week starts Monday; shift so Monday is the anchor. const dow = copy.getUTCDay() const deltaToMonday = (dow + 6) % 7 - return new Date(copy.getTime() - deltaToMonday * DAY_MS) + return new Date(copy.getTime() - deltaToMonday * MS_PER_DAY) } export function subtractMonths( diff --git a/services/api/src/services/admin/discovery-service.ts b/services/api/src/services/admin/discovery-service.ts index 402d8747..f733b64c 100644 --- a/services/api/src/services/admin/discovery-service.ts +++ b/services/api/src/services/admin/discovery-service.ts @@ -11,7 +11,8 @@ import { AppError, REPORT_CATEGORY_LABELS, relativeAgo } from "@civfix/shared" import { ADMIN_CATEGORIES } from "./category-counts.js" -import { ADMIN_DEFAULT_LIMIT } from "./pagination.js" +import { clampLimit } from "./pagination.js" +import { MS_PER_HOUR } from "../../lib/time.js" import type { DiscoveryContact, DiscoveryListQuery, @@ -35,8 +36,6 @@ const DISCOVERY_CATEGORIES = ADMIN_CATEGORIES /** A task breaches when its oldest waiting report is older than this. */ export const DISCOVERY_SLA_HOURS = 24 -const HOUR_MS = 60 * 60 * 1000 - const DISCOVERY_TASK_NOT_FOUND = "Discovery task not found" const SUGGESTION_NOTE_AUTHOR = "Reporter" @@ -195,7 +194,7 @@ export function computeContactState(record: DiscoveryTaskRecord): { export function isOverSla(oldestWaitingAt: Date | null, now: Date): boolean { if (oldestWaitingAt === null) return false const ageMs = now.getTime() - oldestWaitingAt.getTime() - return ageMs > DISCOVERY_SLA_HOURS * HOUR_MS + return ageMs > DISCOVERY_SLA_HOURS * MS_PER_HOUR } /** @@ -287,7 +286,7 @@ export function makeDiscoveryService(deps: DiscoveryServiceDeps): DiscoveryServi filter: query.filter ?? "all", sort: query.sort ?? "pop", cursor: query.cursor ?? null, - limit: query.limit ?? ADMIN_DEFAULT_LIMIT, + limit: clampLimit(query.limit), } const { records, nextCursor } = await deps.repo.listTasks(args) // Only the detail renders notes, so list rows carry an empty notes[] rather than a note read diff --git a/services/api/src/services/admin/home-service.ts b/services/api/src/services/admin/home-service.ts index fb0b9613..56d8774b 100644 --- a/services/api/src/services/admin/home-service.ts +++ b/services/api/src/services/admin/home-service.ts @@ -18,6 +18,7 @@ import type { ReportsSectionCounts, UsersSectionCounts, } from "./home-types.js" +import { mapWithLimit } from "../../lib/concurrency.js" export * from "./home-types.js" @@ -92,16 +93,7 @@ async function runBounded( limit: number, tasks: T, ): Promise> { - const results = new Array(tasks.length) - let cursor = 0 - const worker = async (): Promise => { - for (let index = cursor++; index < tasks.length; index = cursor++) { - results[index] = await tasks[index]!() - } - } - const lanes = Math.max(1, Math.min(limit, tasks.length)) - await Promise.all(Array.from({ length: lanes }, worker)) - return results as SectionResults + return (await mapWithLimit(tasks, limit, (task) => task())) as SectionResults } export interface HomeServiceDeps { diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index aeb7dc85..9d9084df 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto" import type { Container } from "../../di.js" import type { InboundMail, ParsedMail, Storage } from "@civfix/shared/interfaces" import type { MailAttachment } from "@civfix/shared" @@ -29,6 +28,8 @@ import type { ReportChatSystemEmitter } from "../report-timeline-event.js" import { readMailAuthVerdict, type MailAuthVerdict } from "../../adapters/inbound-mail.cf.js" import { sanitizeInboundHtml } from "./inbound-html-sanitizer.js" import { htmlToText } from "./mail-preview.js" +import { ATTACHMENT_FILENAME_MAX_CHARS, safeFilenameChars } from "../../lib/filename.js" +import { sha256HexSync } from "../../lib/hash.js" export { detectBounce, resolveMessageId } @@ -54,7 +55,6 @@ const THREADED_ATTACHMENT_PREFIX = "inbound-mail/" const INBOX_ATTACHMENT_PREFIX = "inbound-emails/" const ATTACHMENT_CONTENT_TYPE = "application/octet-stream" const RAW_MAIL_CONTENT_TYPE = "message/rfc822" -const ATTACHMENT_FILENAME_MAX_CHARS = 120 const CIVFIX_HEADER_PREFIX = "x-civfix-" const AUTH_VERDICT_HEADER = `${CIVFIX_HEADER_PREFIX}auth-verdict` @@ -66,7 +66,7 @@ export const INBOUND_BOUNCE_MAX_ATTEMPTS = 6 const CONTENT_DIGEST_HEX_CHARS = 32 function contentDigest(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex").slice(0, CONTENT_DIGEST_HEX_CHARS) + return sha256HexSync(bytes).slice(0, CONTENT_DIGEST_HEX_CHARS) } async function withTimeout(work: Promise, ms: number): Promise { @@ -483,6 +483,6 @@ async function moveToFailed(storage: Storage, key: string, bytes: Uint8Array): P } function sanitizeFilename(name: string): string { - const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "") + const cleaned = safeFilenameChars(name) return cleaned.length > 0 ? cleaned.slice(0, ATTACHMENT_FILENAME_MAX_CHARS) : "file" } diff --git a/services/api/src/services/admin/inbound-retention-repository.drizzle.ts b/services/api/src/services/admin/inbound-retention-repository.drizzle.ts index 4663c92d..2a5e64f4 100644 --- a/services/api/src/services/admin/inbound-retention-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-retention-repository.drizzle.ts @@ -1,10 +1,9 @@ import type { Sql } from "../../db/client.js" - -const DAY_MS = 24 * 60 * 60 * 1000 +import { MS_PER_DAY } from "../../lib/time.js" const INBOUND_EMAIL_RETENTION_DAYS = 180 -export const INBOUND_EMAIL_RETENTION_MS = INBOUND_EMAIL_RETENTION_DAYS * DAY_MS +export const INBOUND_EMAIL_RETENTION_MS = INBOUND_EMAIL_RETENTION_DAYS * MS_PER_DAY export const INBOUND_EMAIL_RETENTION_BATCH = 200 diff --git a/services/api/src/services/admin/inbound-thread-correlation.ts b/services/api/src/services/admin/inbound-thread-correlation.ts index dd133b90..94172f86 100644 --- a/services/api/src/services/admin/inbound-thread-correlation.ts +++ b/services/api/src/services/admin/inbound-thread-correlation.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto" import type { Container } from "../../di.js" import type { ParsedMail } from "@civfix/shared/interfaces" import type { @@ -14,6 +13,7 @@ import type { CleanupRepository } from "../cleanup-service.js" import { makeContainerReportChatEmitter } from "../report-chat-emitter.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" import { MESSAGE_BODY_MAX, segmentGraphemes } from "@civfix/shared" +import { sha256HexSync } from "../../lib/hash.js" import { DEFAULT_REPLY_DOMAIN, domainOf, @@ -374,5 +374,5 @@ export function resolveMessageId(mail: ParsedMail): string { String(body.length), body.slice(0, DERIVED_ID_BODY_PREFIX_CHARS), ].join("|") - return `${DERIVED_ID_PREFIX}${createHash("sha256").update(basis).digest("hex")}` + return `${DERIVED_ID_PREFIX}${sha256HexSync(basis)}` } diff --git a/services/api/src/services/admin/jurisdiction-contacts-service.ts b/services/api/src/services/admin/jurisdiction-contacts-service.ts index ecfebd5b..ebb69bc5 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-service.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-service.ts @@ -6,7 +6,7 @@ import type { JurisdictionListQuery } from "@civfix/shared" import { toDirectoryDTO, hasAnyContact } from "./jurisdiction-directory-projection.js" import type { DirectoryFilter, DirectorySort } from "./jurisdiction-contacts-types.js" import { isReservedHandle } from "../../auth/reserved-handles.js" -import { ADMIN_DEFAULT_LIMIT } from "./pagination.js" +import { clampLimit } from "./pagination.js" import { isThrottled } from "./outreach-service.js" import { OUTREACH_DIGEST_JOB, @@ -68,7 +68,7 @@ export function makeJurisdictionContactsService( layer: query.layer ?? null, sort: (query.sort ?? "population") as DirectorySort, cursor: query.cursor ?? null, - limit: query.limit ?? ADMIN_DEFAULT_LIMIT, + limit: clampLimit(query.limit), }) return { items: records.map(toDirectoryDTO), diff --git a/services/api/src/services/admin/mail-format.ts b/services/api/src/services/admin/mail-format.ts index 9f23cacd..90874498 100644 --- a/services/api/src/services/admin/mail-format.ts +++ b/services/api/src/services/admin/mail-format.ts @@ -25,6 +25,7 @@ import type { AdminReportRoutingRecord, } from "./admin-report-types.js" import type { MarkdownInline } from "@civfix/shared/markdown" +import { ATTACHMENT_FILENAME_MAX_CHARS, safeFilenameChars } from "../../lib/filename.js" export const NO_PHOTO_LINKS = "(none)" @@ -39,10 +40,7 @@ const ATTACHMENT_EXTENSIONS: Record = { } const EXTENSION_FALLBACK = ".jpg" -const ATTACHMENT_FILENAME_MAX_CHARS = 120 const FILE_EXTENSION_RE = /\.[A-Za-z0-9]{2,5}$/ -const UNSAFE_FILENAME_CHARS_RE = /[^A-Za-z0-9._-]+/g -const LEADING_DOTS_RE = /^\.+/ const SHORT_ID_CHARS = 8 const UNKNOWN_PLACE_LABEL = "the area" @@ -55,7 +53,7 @@ function hasExtension(name: string): boolean { export function attachmentFilename(r2Key: string, index: number, contentType?: string): string { const ext = ATTACHMENT_EXTENSIONS[(contentType ?? "").trim().toLowerCase()] ?? EXTENSION_FALLBACK const tail = r2Key.split("/").pop() ?? "" - const cleaned = tail.replace(UNSAFE_FILENAME_CHARS_RE, "_").replace(LEADING_DOTS_RE, "") + const cleaned = safeFilenameChars(tail) if (cleaned.length === 0) return `photo-${index + 1}${ext}` if (hasExtension(cleaned)) return cleaned.slice(0, ATTACHMENT_FILENAME_MAX_CHARS) return `${cleaned.slice(0, ATTACHMENT_FILENAME_MAX_CHARS - ext.length)}${ext}` diff --git a/services/api/src/services/admin/mail-preview.ts b/services/api/src/services/admin/mail-preview.ts index a1612fb0..8d858672 100644 --- a/services/api/src/services/admin/mail-preview.ts +++ b/services/api/src/services/admin/mail-preview.ts @@ -1,3 +1,5 @@ +import { collapseWhitespace } from "@civfix/shared" + export const PREVIEW_LEN = 140 export const PREVIEW_SOURCE_CHARS = 400 @@ -5,10 +7,10 @@ export const PREVIEW_SOURCE_CHARS = 400 export const HTML_PREVIEW_SOURCE_CHARS = 4096 export function toPreview(body: string | null | undefined, html?: string | null): string { - const fromText = collapse(body ?? "") + const fromText = collapseWhitespace(body ?? "") if (fromText.length > 0) return truncate(fromText) if (html === undefined || html === null || html.length === 0) return "" - return truncate(collapse(htmlToText(html))) + return truncate(collapseWhitespace(htmlToText(html))) } const RAW_TEXT_ELEMENTS = new Set(["script", "style"]) @@ -25,8 +27,6 @@ const ABSOLUTE_HTTP_URL_RE = /^https?:\/\//i const EXTRA_BLANK_LINES_RE = /\n{3,}/g -const WHITESPACE_RUN_RE = /\s+/g - const MAX_CODE_POINT = 0x10ffff const SURROGATE_MIN = 0xd800 const SURROGATE_MAX = 0xdfff @@ -57,7 +57,7 @@ export function htmlToText(html: string): string { let href: string | null = null let anchorText = "" const breakLine = (): void => { - const text = collapse(line) + const text = collapseWhitespace(line) lines.push(quoteDepth > 0 && text !== "" ? `> ${text}` : text) line = "" } @@ -166,10 +166,6 @@ function codePointText(code: number): string { return String.fromCodePoint(invalid ? REPLACEMENT_CHARACTER : code) } -function collapse(value: string): string { - return value.replace(WHITESPACE_RUN_RE, " ").trim() -} - function truncate(value: string): string { return value.slice(0, PREVIEW_LEN) } diff --git a/services/api/src/services/admin/mail-repository.memory.ts b/services/api/src/services/admin/mail-repository.memory.ts index 15b5c04a..876e3d59 100644 --- a/services/api/src/services/admin/mail-repository.memory.ts +++ b/services/api/src/services/admin/mail-repository.memory.ts @@ -31,10 +31,9 @@ import { } from "./outbound-send-policy.js" import { clampLimit } from "./pagination.js" import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" +import { MS_PER_DAY } from "../../lib/time.js" import type { MailDelivery, MailStatsResponse, MailStatus, MailThreadDTO } from "@civfix/shared" -const DAY_MS = 24 * 60 * 60 * 1000 - export interface StoredMailEvent { id: string threadId: string | null @@ -549,7 +548,7 @@ export class InMemoryMailRepository implements MailRepository { } stats7d(): Promise { - const cutoff = this.now.getTime() - MAIL_STATS_WINDOW_DAYS * DAY_MS + const cutoff = this.now.getTime() - MAIL_STATS_WINDOW_DAYS * MS_PER_DAY const counts = { sent: 0, bounced: 0, failed: 0 } for (const e of this.events) { if (e.createdAt.getTime() < cutoff) continue diff --git a/services/api/src/services/admin/mail-service.ts b/services/api/src/services/admin/mail-service.ts index 87074438..2897c78b 100644 --- a/services/api/src/services/admin/mail-service.ts +++ b/services/api/src/services/admin/mail-service.ts @@ -17,7 +17,8 @@ import { MAX_PACKET_ATTACHMENT_BYTES, MAX_PACKET_TOTAL_BYTES, } from "./mail-format.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" import type { OutboundMailService } from "./outbound-mail-service.js" const MAIL_THREAD_NOT_FOUND = "Mail thread not found" diff --git a/services/api/src/services/admin/moderation-service.ts b/services/api/src/services/admin/moderation-service.ts index d89e4a19..25f7f208 100644 --- a/services/api/src/services/admin/moderation-service.ts +++ b/services/api/src/services/admin/moderation-service.ts @@ -16,7 +16,8 @@ import type { } from "@civfix/shared" import { clampLimit } from "./pagination.js" import { timelineKindForStatus } from "./admin-report-status.js" -import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { PRESIGN_CONCURRENCY, type PresignMedia } from "../media-presign.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" import type { MessageUpdateAnnouncer } from "./admin-report-chat-service.js" diff --git a/services/api/src/services/admin/outreach-service.ts b/services/api/src/services/admin/outreach-service.ts index 7307e6aa..056d01a2 100644 --- a/services/api/src/services/admin/outreach-service.ts +++ b/services/api/src/services/admin/outreach-service.ts @@ -1,6 +1,7 @@ import { REPORT_CATEGORY_LABELS } from "@civfix/shared" import type { ReportCategory } from "@civfix/shared" import { ADMIN_CATEGORIES } from "./category-counts.js" +import { MS_PER_DAY } from "../../lib/time.js" import type { MailRepository } from "./mail-repository.drizzle.js" import { isOutboundSendDeadlineError, @@ -25,13 +26,11 @@ export interface OutreachRepository { const OUTREACH_SWEEP_BATCH_SIZE = 200 -const DAY_MS = 24 * 60 * 60 * 1000 - export const OUTREACH_CATEGORIES: readonly ReportCategory[] = ADMIN_CATEGORIES export function isThrottled(lastOutreachAt: Date | null, now: Date, throttleDays: number): boolean { if (lastOutreachAt === null) return false - const windowMs = throttleDays * DAY_MS + const windowMs = throttleDays * MS_PER_DAY return now.getTime() - lastOutreachAt.getTime() < windowMs } @@ -126,7 +125,7 @@ export function makeOutreachService(deps: OutreachServiceDeps): OutreachService const at = now() const claim = deps.outreachRepo.claimOutreachWindow if (claim) { - const windowStart = new Date(at.getTime() - deps.throttleDays * DAY_MS) + const windowStart = new Date(at.getTime() - deps.throttleDays * MS_PER_DAY) const won = await claim(geoid, { at, windowStart }) if (!won) { return { geoid, sent: false, skipped: "throttled", reportCount: 0 } diff --git a/services/api/src/services/affiliation.ts b/services/api/src/services/affiliation.ts index 0c4db348..49e21d32 100644 --- a/services/api/src/services/affiliation.ts +++ b/services/api/src/services/affiliation.ts @@ -1,7 +1,8 @@ import type { OrganizationRefDTO, PersonDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" import { makeDrizzleAffiliationRepository } from "./affiliation-repository.drizzle.js" -import { PRESIGN_CONCURRENCY, mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { PRESIGN_CONCURRENCY } from "./media-presign.js" import { presentIds } from "./present-ids.js" export type PrimaryAffiliations = ReadonlyMap diff --git a/services/api/src/services/certificate-model.ts b/services/api/src/services/certificate-model.ts index f7e90526..cadb598b 100644 --- a/services/api/src/services/certificate-model.ts +++ b/services/api/src/services/certificate-model.ts @@ -1,7 +1,7 @@ -import { createHash } from "node:crypto" import { MAX_CERTIFICATE_ENTRIES } from "@civfix/shared" import type { VolunteerHoursSource } from "@civfix/shared" import { resolveLocale } from "../i18n/locales.js" +import { sha256HexSync } from "../lib/hash.js" import { renderMessage, type MessageKey } from "../i18n/renderMessage.js" export const CERTIFICATE_TIME_ZONE = "America/Los_Angeles" @@ -215,5 +215,5 @@ export function ledgerFingerprint(model: TranscriptModel): string { count: model.entryCount, rows: model.rows.map((row) => [row.id, row.hours.toFixed(2), row.occurredAt]), }) - return createHash("sha256").update(basis).digest("hex") + return sha256HexSync(basis) } diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index dc9e697d..9e68e93d 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -19,7 +19,7 @@ * not know about this table would silently break every issued transcript. */ -import { createHash, randomUUID } from "node:crypto" +import { randomUUID } from "node:crypto" import { AppError, CERTIFICATE_GET_URL_TTL_SEC, @@ -35,6 +35,8 @@ import type { } from "@civfix/shared" import type { StorageHead, StoragePutMeta } from "@civfix/shared/interfaces" import { resolveLocale } from "../i18n/locales.js" +import { sha256HexSync } from "../lib/hash.js" +import { MS_PER_SECOND } from "../lib/time.js" import { CERTIFICATE_CODE_MINT_ATTEMPTS, generateCertificateCode } from "./certificate-code.js" import { buildTranscriptModel, @@ -51,7 +53,6 @@ import type { const CERTIFICATE_KEY_PREFIX = "certificates/service-hours" const PDF_CONTENT_TYPE = "application/pdf" -const MS_PER_SECOND = 1000 const HOLDER_REVOKED_REASON = "holder" const TOMBSTONE_REVOKED_REASON = "account_closed" @@ -231,10 +232,6 @@ function certificateContentDisposition(code: string): string { return `inline; filename="civfix-service-hours-${formatCertificateCode(code)}.pdf"` } -function sha256Hex(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex") -} - function toLedgerRow(view: VolunteerHoursEntryView): TranscriptLedgerRow { return { id: view.id, @@ -315,7 +312,7 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat fingerprint, ...(deps.verifyBaseUrl !== undefined ? { verifyBaseUrl: deps.verifyBaseUrl } : {}), }) - const documentSha256 = sha256Hex(bytes) + const documentSha256 = sha256HexSync(bytes) await storage.put(key, bytes, { contentType: PDF_CONTENT_TYPE, contentDisposition: certificateContentDisposition(code), diff --git a/services/api/src/services/chat-edit-service.ts b/services/api/src/services/chat-edit-service.ts index 9289cd3d..271d7b93 100644 --- a/services/api/src/services/chat-edit-service.ts +++ b/services/api/src/services/chat-edit-service.ts @@ -16,13 +16,12 @@ import { neutralizeChatViewerFields } from "./chat-viewer-fields.js" import type { GatewayChatMentions } from "../ws/types.js" import type { ChatMessageMeta, ChatRepository } from "./chat-repository.drizzle.js" import type { DmRepository } from "./dm-repository.drizzle.js" +import { MS_PER_HOUR } from "../lib/time.js" const CHAT_EDIT_FORBIDDEN = "You can't edit this message." const MESSAGE_DELETED = "This message was deleted." -const MS_PER_HOUR = 3_600_000 - const EDIT_WINDOW_MS = EDIT_WINDOW_HOURS * MS_PER_HOUR const EDIT_ERROR_CODE = { diff --git a/services/api/src/services/chat-group-service.ts b/services/api/src/services/chat-group-service.ts index 42ff59c5..11ad7973 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -16,6 +16,7 @@ import type { } from "./chat-group-repository.drizzle.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { isOfficialAccount } from "../auth/official-account.js" +import { clampPageLimit } from "../lib/page-limit.js" import { NO_AFFILIATIONS, withAffiliation, type AffiliationLoader } from "./affiliation.js" export const GROUP_MEMBERS_DEFAULT_LIMIT = 25 @@ -309,10 +310,7 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi async listMembers(viewerId, req) { await requireReadable(viewerId, req.id) - const limit = Math.min( - Math.max(req.limit ?? GROUP_MEMBERS_DEFAULT_LIMIT, 1), - GROUP_MEMBERS_MAX_LIMIT, - ) + const limit = clampPageLimit(req.limit, GROUP_MEMBERS_DEFAULT_LIMIT, GROUP_MEMBERS_MAX_LIMIT) const page = await groups.listMembers(req.id, viewerId, req.cursor ?? null, limit) const affiliations = deps.affiliations ? await deps.affiliations( diff --git a/services/api/src/services/chat-room-fanout-notifier.ts b/services/api/src/services/chat-room-fanout-notifier.ts index 40379b96..6126f814 100644 --- a/services/api/src/services/chat-room-fanout-notifier.ts +++ b/services/api/src/services/chat-room-fanout-notifier.ts @@ -4,7 +4,7 @@ import type { NotificationService } from "./notification-service.js" import type { MessageKey } from "../i18n/renderMessage.js" import { CONVERSATION_BELL } from "./conversation-bell.js" import { textPreview } from "../routes/chat-notify-copy.js" -import { mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" export type RoomFanoutKind = "report" | "group" diff --git a/services/api/src/services/chat-room-notifier-wiring.ts b/services/api/src/services/chat-room-notifier-wiring.ts index f1824d53..9f92f6e9 100644 --- a/services/api/src/services/chat-room-notifier-wiring.ts +++ b/services/api/src/services/chat-room-notifier-wiring.ts @@ -12,13 +12,12 @@ import { type RoomFanoutKind, type RoomFanoutNotifierDeps, } from "./chat-room-fanout-notifier.js" +import { MS_PER_SECOND } from "../lib/time.js" export type RoomFanoutLogger = Pick export const ROOM_FANOUT_WINDOW_CLAIM_PREFIX = "chatfanout" -const MS_PER_SECOND = 1000 - export type ContainerRoomFanoutDeps = Record export function makeContainerRoomFanoutDeps( diff --git a/services/api/src/services/cleanup-notifications.ts b/services/api/src/services/cleanup-notifications.ts index efeea120..9c438bf1 100644 --- a/services/api/src/services/cleanup-notifications.ts +++ b/services/api/src/services/cleanup-notifications.ts @@ -1,12 +1,11 @@ import type { Jobs } from "@civfix/shared/interfaces" import type { MessageKey } from "../i18n/messages/en.js" import type { NotificationService } from "./notification-service.js" -import { mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { MS_PER_HOUR } from "../lib/time.js" import type { CleanupRepository, SlotReconcileResult } from "./cleanup-repository.types.js" import { CLEANUP_GUEST_UPDATE_FANOUT_JOB, type GuestUpdateFanoutJob } from "./guest-rsvp-service.js" -const MS_PER_HOUR = 60 * 60 * 1000 - export const CANCEL_FANOUT_MEMBER_CAP = 2000 const CANCEL_FANOUT_CONCURRENCY = 8 diff --git a/services/api/src/services/cleanup-rules.ts b/services/api/src/services/cleanup-rules.ts index 30cd2816..35e2bd02 100644 --- a/services/api/src/services/cleanup-rules.ts +++ b/services/api/src/services/cleanup-rules.ts @@ -12,13 +12,10 @@ import { hasEventEnded, } from "@civfix/shared/host" import type { EventWindowLike } from "@civfix/shared/host" +import { MS_PER_DAY, MS_PER_MINUTE } from "../lib/time.js" export { DEFAULT_EVENT_DURATION_MS, deriveCleanupStatus, eventEndsAtMs, hasEventEnded } -const MS_PER_MINUTE = 60 * 1000 - -const MS_PER_DAY = 24 * 60 * MS_PER_MINUTE - export const SCHEDULE_MAX_BACKDATE_MS = MS_PER_DAY export const DEFAULT_EVENT_SLOT_TITLE = "General volunteers" diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index 3b4a260f..9f0ad9c5 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -1,6 +1,13 @@ import { randomUUID } from "node:crypto" -import { AppError, ErrorCode, MAX_BRING_ITEMS, MAX_LINKED_REPORTS } from "@civfix/shared" +import { + AppError, + ErrorCode, + MAX_BRING_ITEMS, + MAX_LINKED_REPORTS, + collapseWhitespace, +} from "@civfix/shared" import { can, hostCapabilities, NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" +import { isUuid } from "../db/cursor-helpers.js" import { UNKNOWN_JURCODE } from "../db/reference-code.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { InMemoryCounterStore, type CounterStore } from "../abuse/counter-store.js" @@ -27,7 +34,9 @@ import type { NotificationService } from "./notification-service.js" import { EVENT_HOURS_MEMBER_CAP } from "./volunteer-hours-service.js" import type { OutboundMailService } from "./admin/outbound-mail-service.js" import { buildEventPacket } from "./admin/mail-format.js" -import { PRESIGN_CONCURRENCY, mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { SECONDS_PER_DAY, SECONDS_PER_HOUR } from "../lib/time.js" +import { PRESIGN_CONCURRENCY } from "./media-presign.js" import { attachAffiliations, type AffiliationLoader } from "./affiliation.js" import { CLEANUPS_DEFAULT_LIMIT, @@ -116,10 +125,6 @@ type HostEventPatch = Pick< | "hostReplyTo" > & { scheduledAt?: string } -const SECONDS_PER_HOUR = 60 * 60 - -const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR - // `joined` means an RSVP (a cleanup_members row, which the organizer always has). Org standing // grants host powers and visibility, not attendance: clients key Join/Leave off this flag. function isAttending(standing: HostStanding): boolean { @@ -145,12 +150,6 @@ function futureOrNull(at: Date | null, now: Date): string | null { return at !== null && at.getTime() > now.getTime() ? at.toISOString() : null } -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -function isUuid(value: string): boolean { - return UUID_RE.test(value) -} - const EVENT_CLOSED_MESSAGE = "This event is closed." const CANCELLED_EVENT_EDIT_MESSAGE = "This event has been cancelled and can no longer be edited." @@ -1502,7 +1501,7 @@ function guestVisibleChange( } function resourceRequestNote(message: string): string { - const collapsed = message.replace(/\s+/g, " ").trim() + const collapsed = collapseWhitespace(message) const preview = collapsed.length > RESOURCE_NOTE_PREVIEW_CHARS ? `${collapsed.slice(0, RESOURCE_NOTE_PREVIEW_CHARS)}…` diff --git a/services/api/src/services/cleanup-slots.ts b/services/api/src/services/cleanup-slots.ts index d7f3f9fe..4a792df0 100644 --- a/services/api/src/services/cleanup-slots.ts +++ b/services/api/src/services/cleanup-slots.ts @@ -3,8 +3,7 @@ import type { EventSlotInput } from "@civfix/shared" import { assertNoSlur } from "../abuse/slur-filter.js" import type { DesiredSlot, EventSlotView } from "./cleanup-repository.types.js" import type { EventWindow } from "./cleanup-rules.js" - -const MS_PER_MINUTE = 60_000 +import { MS_PER_MINUTE } from "../lib/time.js" const MIN_SLOT_DURATION_MS = MIN_SLOT_DURATION_MINUTES * MS_PER_MINUTE diff --git a/services/api/src/services/feed-ranking.ts b/services/api/src/services/feed-ranking.ts index 59903407..db6e1f3c 100644 --- a/services/api/src/services/feed-ranking.ts +++ b/services/api/src/services/feed-ranking.ts @@ -1,4 +1,5 @@ import { quantizeFeedScore, type FeedRankingConfig } from "@civfix/shared" +import { MS_PER_HOUR, MS_PER_SECOND } from "../lib/time.js" export interface FeedCandidate { id: string @@ -24,9 +25,6 @@ export interface RankedCandidate { score: number } -const MS_PER_HOUR = 3_600_000 -const MS_PER_SECOND = 1000 - export function quantizeClock(nowMs: number, clockBucketSeconds: number): number { const bucketMs = Math.max(1, Math.trunc(clockBucketSeconds)) * MS_PER_SECOND return Math.floor(nowMs / bucketMs) * bucketMs diff --git a/services/api/src/services/guest-rsvp-service.ts b/services/api/src/services/guest-rsvp-service.ts index c4c3acdd..263883b5 100644 --- a/services/api/src/services/guest-rsvp-service.ts +++ b/services/api/src/services/guest-rsvp-service.ts @@ -46,7 +46,8 @@ import { honeypotTripped } from "../abuse/honeypot.js" import { normalizeIp } from "../abuse/ip-rate-limit.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { smsFailureKind } from "../errors/sms-failure.js" -import { mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { MS_PER_HOUR, MS_PER_SECOND, SECONDS_PER_DAY, SECONDS_PER_MINUTE } from "../lib/time.js" import { renderMessage } from "../i18n/renderMessage.js" import { parseKeysetCursor, type KeysetCursor } from "../db/cursor-helpers.js" import { eventEndedError, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" @@ -67,8 +68,6 @@ export const GUEST_IP_MAX_PER_HOUR = 10 const GUEST_IP_WINDOW_SECONDS = 60 * 60 -const DAY_SECONDS = 24 * 60 * 60 - const GUESTS_DEFAULT_LIMIT = 25 type SmsPurpose = "otp" | "confirmation" | "notice" @@ -93,12 +92,6 @@ const GUEST_CONTACT_READS_PER_HOUR = 50 const GUEST_CONTACT_READ_WINDOW_SECONDS = 60 * 60 -const MS_PER_SECOND = 1000 - -const SECONDS_PER_MINUTE = 60 - -const MS_PER_HOUR = 60 * 60 * MS_PER_SECOND - const SMS_LOCALE = "en" const SMS_PLACE_COORD_DECIMALS = 5 @@ -592,7 +585,10 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi async function reserveSmsBudget(purpose: SmsPurpose): Promise { let used: number try { - used = await deps.counters.incr(`${SMS_BUDGET_KEY_PREFIX}${utcDayKey(now())}`, DAY_SECONDS) + used = await deps.counters.incr( + `${SMS_BUDGET_KEY_PREFIX}${utcDayKey(now())}`, + SECONDS_PER_DAY, + ) } catch (err) { deps.logger?.warn( { err, purpose }, @@ -990,7 +986,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi throw AppError.rateLimited("Too many code requests from this network.") } } - const daily = await deps.cache.incr(contactDayKey(digest), DAY_SECONDS) + const daily = await deps.cache.incr(contactDayKey(digest), SECONDS_PER_DAY) if (daily > GUEST_CONTACT_MAX_PER_DAY) { throw AppError.rateLimited("Too many code requests for this contact today.") } @@ -1229,7 +1225,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi async runRetentionSweep(): Promise { const at = new Date(now()) const contactCutoff = new Date( - now() - GUEST_CONTACT_RETENTION_DAYS * DAY_SECONDS * MS_PER_SECOND, + now() - GUEST_CONTACT_RETENTION_DAYS * SECONDS_PER_DAY * MS_PER_SECOND, ) const otpCutoff = new Date(now() - GUEST_OTP_RETENTION_HOURS * MS_PER_HOUR) const scrubbedGuests = await drainPages("guest contact scrub", (batchSize) => diff --git a/services/api/src/services/host/analytics-service.ts b/services/api/src/services/host/analytics-service.ts index e8e43f84..47905c14 100644 --- a/services/api/src/services/host/analytics-service.ts +++ b/services/api/src/services/host/analytics-service.ts @@ -50,7 +50,7 @@ import { METRIC_UNSUBSCRIBES, } from "./event-metric-names.js" import { - DAY_MS, + PORTFOLIO_EVENT_LIMIT, closureAllowsTotal, emptyRate, isoDayOf, @@ -62,8 +62,8 @@ import { toSeries, toSuppressedRate, } from "./host-analytics-shaping.js" +import { MS_PER_DAY } from "../../lib/time.js" -const PORTFOLIO_EVENT_LIMIT = 200 export const ARRIVAL_SAMPLE_LIMIT = 20_000 const PORTFOLIO_BY_EVENT_LIMIT = 50 @@ -139,7 +139,7 @@ export function makeAnalyticsService(deps: AnalyticsServiceDeps): AnalyticsServi function utcRangeWindow(days: number | null): { from: string; to: string } { const to = now() const span = days ?? ALL_RANGE_DAYS - const from = new Date(to.getTime() - (span - 1) * DAY_MS) + const from = new Date(to.getTime() - (span - 1) * MS_PER_DAY) return { from: isoDayOf(from), to: isoDayOf(to) } } @@ -437,7 +437,7 @@ export function makeAnalyticsService(deps: AnalyticsServiceDeps): AnalyticsServi function dayBounds(window: DayRange): { from: Date; to: Date } { const from = new Date(`${window.from}T00:00:00.000Z`) - const to = new Date(Date.parse(`${window.to}T00:00:00.000Z`) + DAY_MS) + const to = new Date(Date.parse(`${window.to}T00:00:00.000Z`) + MS_PER_DAY) return { from, to } } diff --git a/services/api/src/services/host/announcement-repository.drizzle.ts b/services/api/src/services/host/announcement-repository.drizzle.ts index 203dd0df..bbcf287e 100644 --- a/services/api/src/services/host/announcement-repository.drizzle.ts +++ b/services/api/src/services/host/announcement-repository.drizzle.ts @@ -1,6 +1,7 @@ import type { OrganizationRefDTO, PersonDTO } from "@civfix/shared" import type { Sql } from "../../db/client.js" -import { PRESIGN_CONCURRENCY, mapWithLimit } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" import { presentIds } from "../present-ids.js" import { publicAuthorIdentity } from "../public-author.js" import { publicServedKeyExpr } from "../media-served-key.js" diff --git a/services/api/src/services/host/announcement-service.ts b/services/api/src/services/host/announcement-service.ts index 39324d09..a4f98eb6 100644 --- a/services/api/src/services/host/announcement-service.ts +++ b/services/api/src/services/host/announcement-service.ts @@ -16,6 +16,7 @@ import type { } from "@civfix/shared" import type { FastifyBaseLogger } from "fastify" import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" +import { clampPageLimit } from "../../lib/page-limit.js" import type { AnnouncementCap, BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastRecord } from "./broadcast-types.js" import { ANNOUNCEMENT_VISIBLE_STATUSES } from "./broadcast-types.js" @@ -210,7 +211,7 @@ export function makeAnnouncementService(deps: AnnouncementServiceDeps): Announce }, async list(cleanupId, query, projection) { - const limit = Math.min(query.limit ?? ANNOUNCEMENT_DEFAULT_LIMIT, ANNOUNCEMENT_MAX_LIMIT) + const limit = clampPageLimit(query.limit, ANNOUNCEMENT_DEFAULT_LIMIT, ANNOUNCEMENT_MAX_LIMIT) const rows = await repo.listAnnouncements({ cleanupId, cursor: parseKeysetCursor(query.cursor, { direction: "desc" }), diff --git a/services/api/src/services/host/broadcast-capability-token.ts b/services/api/src/services/host/broadcast-capability-token.ts index 7f977656..76958659 100644 --- a/services/api/src/services/host/broadcast-capability-token.ts +++ b/services/api/src/services/host/broadcast-capability-token.ts @@ -1,11 +1,10 @@ import { createHmac } from "node:crypto" import { constantTimeStringEqual } from "../../auth/crypto.js" +import { MS_PER_DAY, MS_PER_SECOND } from "../../lib/time.js" const UNSUBSCRIBE_TOKEN_VERSION = "u1" const TOKEN_PAYLOAD_VERSION = 1 const TOKEN_PART_COUNT = 3 -const MS_PER_SECOND = 1000 -const MS_PER_DAY = 24 * 60 * 60 * MS_PER_SECOND export const UNSUBSCRIBE_TOKEN_TTL_DAYS = 400 diff --git a/services/api/src/services/host/broadcast-email-sender.ts b/services/api/src/services/host/broadcast-email-sender.ts index ac978380..554c7366 100644 --- a/services/api/src/services/host/broadcast-email-sender.ts +++ b/services/api/src/services/host/broadcast-email-sender.ts @@ -4,7 +4,9 @@ import type { Mailer } from "@civfix/shared/interfaces" import type { FastifyBaseLogger } from "fastify" import type { CacheClient } from "../../auth/cache.js" import { mailFailure } from "../../adapters/mail-failure.js" -import { mapWithLimit } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { sleep as timerSleep } from "../../lib/sleep.js" +import { MS_PER_SECOND } from "../../lib/time.js" import type { BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastRecord, @@ -27,7 +29,6 @@ import { mintUnsubscribeToken, unsubscribeExpiryFrom } from "./broadcast-capabil import { emailHashOf, type BroadcastConfig } from "./broadcast-service.js" const DEDUPE_TTL_SEC = 24 * 60 * 60 -const MS_PER_SECOND = 1000 const AUTOMATED_KINDS: ReadonlySet = new Set([ "confirmation", "waitlist_promoted", @@ -75,8 +76,7 @@ class TokenBucket { constructor( private readonly ratePerSec: number, private readonly now: () => number = () => Date.now(), - private readonly sleep: (ms: number) => Promise = (ms) => - new Promise((resolve) => setTimeout(resolve, ms)), + private readonly sleep: (ms: number) => Promise = timerSleep, ) { this.tokens = ratePerSec this.lastRefillMs = now() diff --git a/services/api/src/services/host/broadcast-lanes.ts b/services/api/src/services/host/broadcast-lanes.ts index 299d2add..e24e95b6 100644 --- a/services/api/src/services/host/broadcast-lanes.ts +++ b/services/api/src/services/host/broadcast-lanes.ts @@ -5,6 +5,7 @@ import type { BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastCreateInput, EventBroadcastContext } from "./broadcast-types.js" import { verifiedReplyTo } from "./broadcast-render.js" import { eventWindowOf, hasEventEnded } from "../cleanup-rules.js" +import { MS_PER_HOUR, MS_PER_SECOND } from "../../lib/time.js" export const DEFAULT_REMINDER_OFFSETS_MIN = [1440, 180] as const @@ -14,8 +15,6 @@ const REMINDER_SWEEP_LIMIT = 200 const AUTOMATED_CHANNELS: BroadcastChannel[] = ["inapp", "push", "email"] -const MS_PER_SECOND = 1000 -const MS_PER_HOUR = 3_600_000 const EVENT_UPDATE_WINDOW_SEC = MS_PER_HOUR / MS_PER_SECOND // An ISO timestamp cut to "YYYY-MM-DDTHH" names the UTC hour the throttle window counts in. const ISO_HOUR_LENGTH = 13 diff --git a/services/api/src/services/host/broadcast-service.ts b/services/api/src/services/host/broadcast-service.ts index 00466f1b..e95fea8d 100644 --- a/services/api/src/services/host/broadcast-service.ts +++ b/services/api/src/services/host/broadcast-service.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto" import { AppError } from "@civfix/shared" import type { BroadcastChannel, @@ -19,6 +18,8 @@ import type { FastifyBaseLogger } from "fastify" import { assertNoSlur } from "../../abuse/slur-filter.js" import type { CounterStore } from "../../abuse/counter-store.js" import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" +import { sha256HexSync } from "../../lib/hash.js" +import { MS_PER_HOUR, SECONDS_PER_DAY } from "../../lib/time.js" import type { BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastDraftPatch, @@ -40,8 +41,6 @@ import { audiencePages } from "./broadcast-audience.js" const BROADCAST_DEFAULT_LIMIT = 20 const BROADCAST_TEST_SENDS_PER_HOUR = 5 const TEST_SEND_WINDOW_SEC = 60 * 60 -export const DAY_SECONDS = 24 * 60 * 60 -const MS_PER_HOUR = 3_600_000 const ISO_DAY_LENGTH = 10 const COOLDOWN_SENDS = 1 const PREVIEW_SAMPLE_FIRST_NAME = "Alex" @@ -120,7 +119,7 @@ export function capError(kind: CapKind): AppError { } export function emailHashOf(email: string): string { - return createHash("sha256").update(email.trim().toLowerCase()).digest("hex") + return sha256HexSync(email.trim().toLowerCase()) } export function utcDayKey(at: Date): string { @@ -286,7 +285,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi try { await reserve( `bcast:event:${cleanupId}:${utcDayKey(now())}`, - DAY_SECONDS, + SECONDS_PER_DAY, config.perEventPerDay, "per_event_per_day", ) @@ -660,7 +659,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi const chargedKey = `bcast:host:${actorId}:${utcDayKey(now())}` let used: number try { - used = await deps.counters.incrBy(chargedKey, recipients, DAY_SECONDS) + used = await deps.counters.incrBy(chargedKey, recipients, SECONDS_PER_DAY) } catch (err) { deps.logger?.warn( { err, actorId }, diff --git a/services/api/src/services/host/checkin-service.ts b/services/api/src/services/host/checkin-service.ts index 18014ecf..a9c4d5ef 100644 --- a/services/api/src/services/host/checkin-service.ts +++ b/services/api/src/services/host/checkin-service.ts @@ -14,6 +14,7 @@ import type { UndoEventCheckInResponse, } from "@civfix/shared" import { sha256Hex } from "../../auth/crypto.js" +import { stripTrailingSlashes } from "../../lib/base-url.js" import { toEventRegistrationDTO, toSeatDTO } from "./registration-dto.js" import type { CheckinResultRecord, @@ -25,8 +26,6 @@ import { normalizeTicketToken, type TicketTokenSigner } from "./ticket-token.js" const CHECKIN_NOSHOW_SWEEP_BATCH = 1000 -const TRAILING_SLASHES = /\/+$/ - export interface GuestTicketLookup { ( manageTokenHash: string, @@ -101,7 +100,7 @@ export function makeCheckinService(deps: CheckinServiceDeps): CheckinService { icsUrl: deps.publicApiUrl === undefined || deps.publicApiUrl.length === 0 ? null - : `${deps.publicApiUrl.replace(TRAILING_SLASHES, "")}/v1/cleanups/${cleanupId}/ics`, + : `${stripTrailingSlashes(deps.publicApiUrl)}/v1/cleanups/${cleanupId}/ics`, } } diff --git a/services/api/src/services/host/comms-jobs.ts b/services/api/src/services/host/comms-jobs.ts index 9145e25b..089a7c17 100644 --- a/services/api/src/services/host/comms-jobs.ts +++ b/services/api/src/services/host/comms-jobs.ts @@ -20,12 +20,12 @@ import { makeDrizzleBroadcastRepository } from "./broadcast-repository.drizzle.j import { makeDrizzleHostTeamRepository } from "./host-team-repository.drizzle.js" import { TEAM_INVITE_EMAIL_SCRUB_DELAY_MS } from "./host-team-service.js" import { runRegistrationRetentionLanes } from "./registration-retention.js" +import { MS_PER_DAY } from "../../lib/time.js" const DELIVERY_RETENTION_DAYS = 180 const BROADCAST_CONTENT_RETENTION_DAYS = 180 const EXPORT_ROW_RETENTION_DAYS = 90 const EXPORT_REAP_LIMIT = 200 -const MS_PER_DAY = 86_400_000 function daysBefore(now: Date, days: number): Date { return new Date(now.getTime() - days * MS_PER_DAY) diff --git a/services/api/src/services/host/event-analytics-service.ts b/services/api/src/services/host/event-analytics-service.ts index b475d3f9..86bc007b 100644 --- a/services/api/src/services/host/event-analytics-service.ts +++ b/services/api/src/services/host/event-analytics-service.ts @@ -48,7 +48,6 @@ import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" import { ARRIVAL_SAMPLE_LIMIT } from "./analytics-service.js" import { METRIC_DONATION_CLICKS, METRIC_PAGE_VIEWS } from "./event-metric-names.js" import { - DAY_MS, clockPhase, closureAllowsTotal, emptyRate, @@ -59,6 +58,7 @@ import { toRate, toSeries, } from "./host-analytics-shaping.js" +import { MS_PER_DAY } from "../../lib/time.js" const EVENT_ANALYTICS_LIFECYCLE_DAYS = MAX_EVENT_ANALYTICS_SERIES_POINTS const EVENT_ANALYTICS_ARCHIVE_DAYS = 30 @@ -128,7 +128,7 @@ export function analyticsPhaseOf(clock: EventClockRecord, at: Date): EventAnalyt if (phase === "live") return "day_of" if (phase !== "ended") return "upcoming" const endedAt = clock.completedAt ?? clock.endsAt ?? clock.scheduledAt - return at.getTime() - endedAt.getTime() > EVENT_ANALYTICS_ARCHIVE_DAYS * DAY_MS + return at.getTime() - endedAt.getTime() > EVENT_ANALYTICS_ARCHIVE_DAYS * MS_PER_DAY ? "archived" : "completed" } diff --git a/services/api/src/services/host/event-fields.ts b/services/api/src/services/host/event-fields.ts index f7917442..067a1e80 100644 --- a/services/api/src/services/host/event-fields.ts +++ b/services/api/src/services/host/event-fields.ts @@ -6,11 +6,10 @@ import { MIN_EVENT_DURATION_MINUTES, } from "@civfix/shared" import { MAX_EVENT_DURATION_MS, MIN_EVENT_DURATION_MS } from "../cleanup-rules.js" +import { MINUTES_PER_HOUR } from "../../lib/time.js" export const DEFAULT_EVENT_TIME_ZONE = "America/Los_Angeles" -const MINUTES_PER_HOUR = 60 - const EVENT_REMINDER_OFFSET_CHOICES: readonly number[] = [60, 180, 1440, 2880, 10080] const FALLBACK_TIMEZONES: readonly string[] = [ diff --git a/services/api/src/services/host/export-service.ts b/services/api/src/services/host/export-service.ts index 26341b6a..9ed0e66a 100644 --- a/services/api/src/services/host/export-service.ts +++ b/services/api/src/services/host/export-service.ts @@ -10,9 +10,8 @@ import { } from "./export-builders.js" import type { HostExportRecord, HostExportRepository } from "./export-repository.drizzle.js" import type { WriteAuditInput } from "../admin/audit.js" +import { MS_PER_HOUR, MS_PER_SECOND } from "../../lib/time.js" -const MS_PER_SECOND = 1000 -const MS_PER_HOUR = 3_600_000 const EXPORT_DOWNLOAD_URL_TTL_SEC = 300 const EXPORT_LIST_LIMIT = 50 const EXPORT_YIELD_EVERY_ROWS = 1000 diff --git a/services/api/src/services/host/host-analytics-shaping.ts b/services/api/src/services/host/host-analytics-shaping.ts index 7cf76b11..7f8befbb 100644 --- a/services/api/src/services/host/host-analytics-shaping.ts +++ b/services/api/src/services/host/host-analytics-shaping.ts @@ -8,8 +8,9 @@ import { } from "@civfix/shared/host" import type { EventClockRecord } from "./analytics-repository.drizzle.js" import type { MetricRow } from "./metrics-repository.drizzle.js" +import { MS_PER_DAY } from "../../lib/time.js" -export const DAY_MS = 86_400_000 +export const PORTFOLIO_EVENT_LIMIT = 200 const ISO_DAY_LENGTH = 10 const EPOCH_YEAR = 1970 @@ -21,7 +22,7 @@ export function isoDayOf(at: Date): string { export function shiftDayKey(day: string, deltaDays: number): string { const [year, month, date] = day.split("-").map(Number) const shifted = new Date( - Date.UTC(year ?? EPOCH_YEAR, (month ?? 1) - 1, date ?? 1) + deltaDays * DAY_MS, + Date.UTC(year ?? EPOCH_YEAR, (month ?? 1) - 1, date ?? 1) + deltaDays * MS_PER_DAY, ) return isoDayOf(shifted) } diff --git a/services/api/src/services/host/host-portfolio-service.ts b/services/api/src/services/host/host-portfolio-service.ts index 75333544..ab2f5052 100644 --- a/services/api/src/services/host/host-portfolio-service.ts +++ b/services/api/src/services/host/host-portfolio-service.ts @@ -9,7 +9,8 @@ import type { } from "./host-portfolio-repository.drizzle.js" import { ZERO_HOSTED_EVENT_COUNTS, type HostedEventCounts } from "./portfolio-counts.js" import { isEventPubliclyVisible } from "./authz.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" export const HOSTED_EVENTS_DEFAULT_LIMIT = 20 diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index 9da29714..9b2fd7bd 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -31,6 +31,7 @@ import type { RevokeTeamInviteOutcome, } from "./host-team-repository.types.js" import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" +import { MS_PER_DAY } from "../../lib/time.js" interface StoredInvite { id: string @@ -76,9 +77,7 @@ function teamRoleRank(role: string): number { return TEAM_ROLE_RANK[role] ?? 0 } -const DAY_MS = 86_400_000 - -const SEED_EVENT_LEAD_MS = 30 * DAY_MS +const SEED_EVENT_LEAD_MS = 30 * MS_PER_DAY const SEED_JOINED_AT = "2026-01-01T00:00:00.000Z" diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index 63564d47..e8aad9e1 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -24,7 +24,9 @@ import { type AffiliationLoader, type PrimaryAffiliations, } from "../affiliation.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { MS_PER_DAY, SECONDS_PER_DAY } from "../../lib/time.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" import type { MessageKey } from "../../i18n/renderMessage.js" import type { CreateNotificationInput } from "../notification-service.js" import { assertMayGrantRole, isEventPubliclyVisible, requireCapability } from "./authz.js" @@ -37,17 +39,14 @@ import type { PendingInviteForUserRecord, } from "./host-team-repository.types.js" -const DAY_SEC = 24 * 60 * 60 -const DAY_MS = DAY_SEC * 1000 - export const TEAM_INVITES_PER_EVENT_PER_DAY = 30 -const TEAM_INVITE_WINDOW_SEC = DAY_SEC +const TEAM_INVITE_WINDOW_SEC = SECONDS_PER_DAY const TEAM_INVITE_COUNTER_KEY = "host:teamInvites" const TEAM_INVITE_TTL_DAYS = 14 -export const TEAM_INVITE_TTL_MS = TEAM_INVITE_TTL_DAYS * DAY_MS +export const TEAM_INVITE_TTL_MS = TEAM_INVITE_TTL_DAYS * MS_PER_DAY -export const TEAM_INVITE_EMAIL_SCRUB_DELAY_MS = 7 * DAY_MS +export const TEAM_INVITE_EMAIL_SCRUB_DELAY_MS = 7 * MS_PER_DAY const TEAM_MEMBER_CAP = 200 diff --git a/services/api/src/services/host/insights-service.ts b/services/api/src/services/host/insights-service.ts index 1873b888..edd9f19c 100644 --- a/services/api/src/services/host/insights-service.ts +++ b/services/api/src/services/host/insights-service.ts @@ -34,22 +34,19 @@ import type { } from "./registration-repository.types.js" import { CHECKIN_COARSEN_DAYS } from "./registration-retention.js" import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" -import { DAY_MS, clockPhase } from "./host-analytics-shaping.js" +import { PORTFOLIO_EVENT_LIMIT, clockPhase } from "./host-analytics-shaping.js" +import { MS_PER_DAY, MS_PER_MINUTE } from "../../lib/time.js" export const INSIGHTS_LIVE_CACHE_TTL_SEC = 15 export const INSIGHTS_CACHE_TTL_SEC = 300 -const INSIGHTS_PORTFOLIO_EVENT_LIMIT = 200 - const INSIGHTS_RETURNING_MIN_EVENTS = 2 const INSIGHTS_ARRIVAL_MIN_OFFSET_MIN = -120 const INSIGHTS_ARRIVAL_MAX_OFFSET_MIN = 240 -const MINUTE_MS = 60_000 - const INSIGHTS_CACHE_ENDPOINT = "insights" const ENDED_CACHE_RANGE = "event:ended" const OPEN_CACHE_RANGE = "event" @@ -107,7 +104,7 @@ function arrivalBuckets( ): ArrivalOffsetBucket[] { const byOffset = new Map() for (const bucket of arrivals) { - const offsetMin = Math.round((bucket.at.getTime() - startsAt.getTime()) / MINUTE_MS) + const offsetMin = Math.round((bucket.at.getTime() - startsAt.getTime()) / MS_PER_MINUTE) if (offsetMin < INSIGHTS_ARRIVAL_MIN_OFFSET_MIN) continue if (offsetMin > INSIGHTS_ARRIVAL_MAX_OFFSET_MIN) continue byOffset.set(offsetMin, (byOffset.get(offsetMin) ?? 0) + bucket.count) @@ -152,7 +149,7 @@ export function makeInsightsService(deps: InsightsServiceDeps): InsightsService const hostedEventIds = await deps.analytics.hostedEventIds( viewer.userId, null, - INSIGHTS_PORTFOLIO_EVENT_LIMIT, + PORTFOLIO_EVENT_LIMIT, ) if (hostedEventIds.length < INSIGHTS_RETURNING_MIN_EVENTS) return null return deps.analytics.returningAttendees(cleanupId, hostedEventIds) @@ -185,7 +182,7 @@ export function makeInsightsService(deps: InsightsServiceDeps): InsightsService returningOf(cleanupId, viewer, phase), ]) - const coarsened = at.getTime() - endOf(clock) > CHECKIN_COARSEN_DAYS * DAY_MS + const coarsened = at.getTime() - endOf(clock) > CHECKIN_COARSEN_DAYS * MS_PER_DAY const sources: InsightsSourceCount[] = bySource.map((row) => ({ source: row.source, diff --git a/services/api/src/services/host/metrics-service.ts b/services/api/src/services/host/metrics-service.ts index 587aa92b..087517a5 100644 --- a/services/api/src/services/host/metrics-service.ts +++ b/services/api/src/services/host/metrics-service.ts @@ -10,7 +10,8 @@ import { METRIC_SOURCE, NO_BUCKET, } from "./event-metric-names.js" -import { DAY_MS, isoDayOf } from "./host-analytics-shaping.js" +import { isoDayOf } from "./host-analytics-shaping.js" +import { MS_PER_DAY } from "../../lib/time.js" export { eventDayKey } @@ -150,7 +151,7 @@ export function makeMetricsService(deps: MetricsServiceDeps): MetricsService { function flushDays(): string[] { const days: string[] = [] for (let i = -FLUSH_LEAD_DAYS; i <= deps.lookbackDays; i += 1) { - days.push(isoDayOf(new Date(now().getTime() - i * DAY_MS))) + days.push(isoDayOf(new Date(now().getTime() - i * MS_PER_DAY))) } return days } @@ -215,7 +216,7 @@ export function makeMetricsService(deps: MetricsServiceDeps): MetricsService { }, async rollup() { - const since = new Date(now().getTime() - deps.lookbackDays * DAY_MS) + const since = new Date(now().getTime() - deps.lookbackDays * MS_PER_DAY) let after: string | null = null let events = 0 let rows = 0 diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index 43de3959..241fc763 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -32,7 +32,10 @@ import { toAttendeePersonDTO, toOrganizationRef } from "../cleanup-dto.js" import { NO_AFFILIATIONS, withAffiliation, type AffiliationLoader } from "../affiliation.js" import { hostForbiddenCopy } from "./authz.js" import { assertSlugAllowed } from "./slugs.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { stripTrailingSlashes } from "../../lib/base-url.js" +import { MS_PER_DAY, SECONDS_PER_DAY, SECONDS_PER_HOUR } from "../../lib/time.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" import type { AdminActorView, AdminOrganizationRecord, @@ -47,20 +50,16 @@ import type { UpdateOrganizationPatch, } from "./organization-repository.types.js" -const HOUR_SEC = 60 * 60 -const DAY_SEC = 24 * HOUR_SEC -const DAY_MS = DAY_SEC * 1000 - export const ORGS_CREATED_PER_DAY = 5 -const ORG_CREATE_WINDOW_SEC = DAY_SEC +const ORG_CREATE_WINDOW_SEC = SECONDS_PER_DAY const ORG_CREATE_COUNTER_KEY = "org:create" const ORG_INVITES_PER_HOUR = 20 -const ORG_INVITE_WINDOW_SEC = HOUR_SEC +const ORG_INVITE_WINDOW_SEC = SECONDS_PER_HOUR const ORG_INVITE_COUNTER_KEY = "org:invites" const ORG_VERIFICATIONS_PER_DAY = 3 -const ORG_VERIFICATION_WINDOW_SEC = DAY_SEC +const ORG_VERIFICATION_WINDOW_SEC = SECONDS_PER_DAY const ORG_VERIFY_COUNTER_KEY = "org:verify" const MY_ORGANIZATIONS_CAP = 50 @@ -71,7 +70,7 @@ const EIN_RETENTION_DAYS = 90 /** Same lifetime as event team invites. */ const ORG_INVITE_TTL_DAYS = 14 -const ORG_INVITE_TTL_MS = ORG_INVITE_TTL_DAYS * DAY_MS +const ORG_INVITE_TTL_MS = ORG_INVITE_TTL_DAYS * MS_PER_DAY const ORG_INVITE_TOKEN_BYTES = 32 @@ -82,8 +81,6 @@ const MY_ORG_INVITES_CAP = 20 const ACTION_EMAIL_TEMPLATE = "action" -const TRAILING_SLASHES = /\/+$/ - const ORG_INVITE_ACCEPT_PATH = "/manage/org-invites/accept" /** Where the in-app invite notice sends the invitee: the dashboard holds the invite inbox. */ @@ -424,7 +421,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza const now = deps.now ?? (() => new Date()) const newId = deps.newId ?? (() => randomUUID()) const newToken = deps.newToken ?? (() => generateToken(ORG_INVITE_TOKEN_BYTES)) - const webBase = () => deps.webOrigin.replace(TRAILING_SLASHES, "") + const webBase = () => stripTrailingSlashes(deps.webOrigin) async function logoUrlOf(record: OrganizationBaseRecord): Promise { if (record.logoKey === null || deps.presignLogo === undefined) return null @@ -1360,7 +1357,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza }, scrubDecidedEins(limit: number): Promise { - const cutoff = new Date(now().getTime() - EIN_RETENTION_DAYS * DAY_MS) + const cutoff = new Date(now().getTime() - EIN_RETENTION_DAYS * MS_PER_DAY) return deps.repo.scrubDecidedEins(cutoff, limit) }, } diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index d4ea4079..d694a650 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -14,7 +14,9 @@ import { currentVersion } from "@civfix/shared" import { parseMarkdownSubset, MARKDOWN_SUBSET_MAX_CHARS } from "@civfix/shared/markdown" import { can, type HostStanding } from "@civfix/shared/host" import { assertNoSlur } from "../../abuse/slur-filter.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" +import { mapWithLimit } from "../../lib/concurrency.js" +import { SECONDS_PER_DAY } from "../../lib/time.js" +import { PRESIGN_CONCURRENCY } from "../media-presign.js" import { assertSlugAllowed, RESERVED_SLUGS } from "./slugs.js" import type { CounterStore } from "../../abuse/counter-store.js" import { toEventPageDTO, toEventQuestionDTO, toPublicTicketType } from "./registration-dto.js" @@ -25,8 +27,6 @@ const HOST_PAGE_PUBLISH_COUNTER_KEY = "host:pagePublish" export const HOST_PAGE_PUBLISH_PER_DAY = 20 -const DAY_SECONDS = 24 * 60 * 60 - const EXTERNAL_LINK_SCHEME = "https://" const RESERVED_PAGE_ADDRESS_MESSAGE = "that address is reserved" @@ -323,7 +323,10 @@ export function makePageService(deps: PageServiceDeps): PageService { if (deps.counters === undefined) return let used: number try { - used = await deps.counters.incr(`${HOST_PAGE_PUBLISH_COUNTER_KEY}:${actorId}`, DAY_SECONDS) + used = await deps.counters.incr( + `${HOST_PAGE_PUBLISH_COUNTER_KEY}:${actorId}`, + SECONDS_PER_DAY, + ) } catch (err) { deps.logger?.warn({ err }, "event page: publish counter unavailable; refusing (fail closed)") throw AppError.rateLimited("Publishing is temporarily unavailable.") diff --git a/services/api/src/services/host/registration-retention.ts b/services/api/src/services/host/registration-retention.ts index 9d57fa5b..818e8665 100644 --- a/services/api/src/services/host/registration-retention.ts +++ b/services/api/src/services/host/registration-retention.ts @@ -1,5 +1,6 @@ import type { Sql } from "../../db/client.js" import { makeDrizzleRegistrationRetentionRepository } from "./registration-retention-repository.drizzle.js" +import { MS_PER_DAY } from "../../lib/time.js" export const REGISTRATION_RETENTION_BATCH = 500 @@ -13,8 +14,6 @@ const ATTENDEE_NAME_RETENTION_DAYS = 30 export const HOST_NOTE_RETENTION_DAYS = 90 -const DAY_MS = 24 * 60 * 60 * 1000 - export interface RegistrationRetentionResult { scrubbedAnswers: number coarsenedCheckins: number @@ -56,10 +55,10 @@ export async function runRegistrationRetentionLanes( logger?: RegistrationRetentionLogger, ): Promise { const repo = makeDrizzleRegistrationRetentionRepository(sql) - const answerCutoff = new Date(now.getTime() - ANSWER_RETENTION_DAYS * DAY_MS) - const checkinCutoff = new Date(now.getTime() - CHECKIN_COARSEN_DAYS * DAY_MS) - const nameCutoff = new Date(now.getTime() - ATTENDEE_NAME_RETENTION_DAYS * DAY_MS) - const noteCutoff = new Date(now.getTime() - HOST_NOTE_RETENTION_DAYS * DAY_MS) + const answerCutoff = new Date(now.getTime() - ANSWER_RETENTION_DAYS * MS_PER_DAY) + const checkinCutoff = new Date(now.getTime() - CHECKIN_COARSEN_DAYS * MS_PER_DAY) + const nameCutoff = new Date(now.getTime() - ATTENDEE_NAME_RETENTION_DAYS * MS_PER_DAY) + const noteCutoff = new Date(now.getTime() - HOST_NOTE_RETENTION_DAYS * MS_PER_DAY) const scrubbedAnswers = await drain( "event answers", diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index 5329442b..ce7d8b57 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -3,7 +3,7 @@ import type { Container } from "../../di.js" import type { Sql } from "../../db/client.js" import { makeMediaPresigner } from "../media-presign.js" import { makeGuestPromotionNotifier, type GuestPromotionNotifier } from "../guest-notify.js" -import { webBaseUrlOf } from "../../lib/base-url.js" +import { stripTrailingSlashes, webBaseUrlOf } from "../../lib/base-url.js" import { makeDrizzleGuestRsvpRepository } from "../guest-rsvp-repository.drizzle.js" import { writeAudit } from "../admin/audit.js" import type { HostCapability } from "@civfix/shared" @@ -38,8 +38,6 @@ export interface HostServiceLogger { error(obj: unknown, msg?: string): void } -const TRAILING_SLASHES = /\/+$/ - export interface HostRegistrationOverrides { guards?: HostGuards counters?: CounterStore @@ -77,7 +75,7 @@ export interface HostRegistrationServices { function platformMediaUrlPrefixes(container: Container): string[] { const bases = [container.env.R2_PUBLIC_BASE ?? "", container.env.PUBLIC_API_URL] return bases - .map((base) => base.trim().replace(TRAILING_SLASHES, "")) + .map((base) => stripTrailingSlashes(base.trim())) .filter((base) => base.length > 0) .map((base) => `${base}/`) } diff --git a/services/api/src/services/host/ticket-token.ts b/services/api/src/services/host/ticket-token.ts index ad376772..db37ffe8 100644 --- a/services/api/src/services/host/ticket-token.ts +++ b/services/api/src/services/host/ticket-token.ts @@ -1,5 +1,6 @@ -import { createHash, createHmac } from "node:crypto" +import { createHmac } from "node:crypto" import { constantTimeStringEqual } from "../../auth/crypto.js" +import { sha256HexSync } from "../../lib/hash.js" const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" @@ -53,7 +54,7 @@ export function makeTicketTokenSigner(secret: string): TicketTokenSigner { } function hashOf(token: string): string { - return createHash("sha256").update(normalizeTicketToken(token)).digest("hex") + return sha256HexSync(normalizeTicketToken(token)) } return { diff --git a/services/api/src/services/media-byte-quota.ts b/services/api/src/services/media-byte-quota.ts index 5e3602af..9391daf2 100644 --- a/services/api/src/services/media-byte-quota.ts +++ b/services/api/src/services/media-byte-quota.ts @@ -14,6 +14,7 @@ import type { RedisClient } from "../adapters/redis.js" import { attachAtomicIncrBy } from "../adapters/redis-incr.js" +import { MS_PER_SECOND } from "../lib/time.js" /** Generous for a real reporter: a handful of photos and a short video. */ export const MEDIA_UPLOAD_BYTES_PER_DAY = 512 * 1024 * 1024 @@ -23,8 +24,6 @@ export const MEDIA_UPLOAD_BYTE_WINDOW_SECONDS = 24 * 60 * 60 /** Distinct bucket from every other abuse counter (see abuse/counter-store.ts). */ export const MEDIA_UPLOAD_BYTE_PREFIX = "abuse:media:bytes:" -const MS_PER_SECOND = 1000 - export interface ByteMeter { /** Returns the running total after the add. */ add(subject: string, bytes: number): Promise diff --git a/services/api/src/services/media-claim-repository.drizzle.ts b/services/api/src/services/media-claim-repository.drizzle.ts index 7f850eae..27c2a475 100644 --- a/services/api/src/services/media-claim-repository.drizzle.ts +++ b/services/api/src/services/media-claim-repository.drizzle.ts @@ -1,5 +1,6 @@ import type { Queryable } from "../db/client.js" import { UNBOUND_GRACE_MS } from "./media-authorization.js" +import { MS_PER_SECOND } from "../lib/time.js" export interface AvatarClaimant { uploader: string @@ -13,7 +14,6 @@ export interface AvatarMediaRow extends Record { served_key: string | null } -const MS_PER_SECOND = 1000 const AVATAR_CLAIM_WINDOW_SECONDS = UNBOUND_GRACE_MS / MS_PER_SECOND type SqlTemplateTag = (strings: TemplateStringsArray, ...values: (string | number | null)[]) => Q diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index 32dda3d5..8a7cd71a 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -20,6 +20,7 @@ import { makeUnboundOnlyMediaViewAuthorizer, type MediaViewAuthorizer, } from "./media-authorization.js" +import { MS_PER_SECOND } from "../lib/time.js" type MediaPurpose = (typeof MEDIA_PURPOSE_VALUES)[number] @@ -42,7 +43,6 @@ const ALLOWED_VIDEO_CONTENT_TYPES: ReadonlySet = new Set(["video/mp4", " const SHA256_HEX = /^[0-9a-f]{64}$/ const UPLOAD_KEY_PREFIX = "uploads/" -const MS_PER_SECOND = 1000 const USER_QUOTA_PREFIX = "u:" const ANON_QUOTA_PREFIX = "a:" const IP_QUOTA_PREFIX = "ip:" diff --git a/services/api/src/services/message-attachments-repository.drizzle.ts b/services/api/src/services/message-attachments-repository.drizzle.ts index ea7b69cd..ecd440dc 100644 --- a/services/api/src/services/message-attachments-repository.drizzle.ts +++ b/services/api/src/services/message-attachments-repository.drizzle.ts @@ -2,7 +2,8 @@ import { AppError } from "@civfix/shared" import type { Queryable } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus } from "@civfix/shared" import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" -import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { uploaderServableFilter, uploaderServedKeyExpr } from "./media-served-key.js" import { userUploader } from "./media-uploader.js" diff --git a/services/api/src/services/notification-helpers.ts b/services/api/src/services/notification-helpers.ts index 822739d2..a5afed27 100644 --- a/services/api/src/services/notification-helpers.ts +++ b/services/api/src/services/notification-helpers.ts @@ -1,5 +1,6 @@ import type { NotificationDTO, NotificationPrefsDTO, NotificationType } from "@civfix/shared" import type { NotificationPrefsRecord, NotificationRecord } from "./notification-service.js" +import { MINUTES_PER_HOUR } from "../lib/time.js" export const DEFAULT_PREFS: NotificationPrefsRecord = { push: true, @@ -26,7 +27,6 @@ export function isFeedVisibleType(type: NotificationType): boolean { } const TIME_OF_DAY_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/ -const MINUTES_PER_HOUR = 60 const MAX_HOUR = 23 const MAX_MINUTE = 59 diff --git a/services/api/src/services/notification-service.ts b/services/api/src/services/notification-service.ts index e82847f3..2a8a1413 100644 --- a/services/api/src/services/notification-service.ts +++ b/services/api/src/services/notification-service.ts @@ -22,7 +22,7 @@ import { toNotificationDTO, toPrefsDTO, } from "./notification-helpers.js" -import { mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" import { classifyPushToken, isRegistrablePushEndpoint } from "./push-token-policy.js" import { renderMessage, type MessageKey, type MessageVars } from "../i18n/renderMessage.js" import { DEFAULT_LOCALE } from "../i18n/locales.js" diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index 02e7d58f..e5f774dd 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -23,7 +23,8 @@ import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" import { uploadersOf } from "./media-uploader.js" import { publicServedKeyExpr } from "./media-served-key.js" -import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { actorDisplayName, FALLBACK_ACTOR_NAME, publicAuthorIdentity } from "./public-author.js" import { presentIds } from "./present-ids.js" import { diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index a8bd2894..d4b4c279 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -36,7 +36,7 @@ import { type FeedCandidate, type RankedCandidate, } from "./feed-ranking.js" -import { mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" import { hiddenIdentity } from "./hidden-identity.js" import type { FeedPresence, FeedSnapshotEntry } from "./feed-presence.js" import type { PostNotifier } from "./notification-service.js" diff --git a/services/api/src/services/report-chat-send.ts b/services/api/src/services/report-chat-send.ts index 54aeeccc..f61fd570 100644 --- a/services/api/src/services/report-chat-send.ts +++ b/services/api/src/services/report-chat-send.ts @@ -5,7 +5,7 @@ import { type ChatMentionRecordSeam, } from "./chat-mention-resolver.js" import { neutralizeChatViewerFields } from "./chat-viewer-fields.js" -import { mapWithLimit } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" import { roomKeyFor } from "../ws/gateway.js" import type { GatewayChatMentions } from "../ws/types.js" diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index 8e7ba8c6..11ee3be4 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -16,9 +16,11 @@ import type { ReportVisibility, } from "@civfix/shared" import { assertNoSlur } from "../abuse/slur-filter.js" +import { isUuid } from "../db/cursor-helpers.js" import { UNKNOWN_JURCODE } from "../db/reference-code.js" import { toLinkedEventRef, type LinkedEventView } from "./cleanup-service.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { PRESIGN_CONCURRENCY } from "./media-presign.js" import { clusterByZoom, countByCategory, @@ -58,17 +60,12 @@ import { export * from "./report-service.types.js" export * from "./report-clustering.js" -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const REPORT_NOT_FOUND = "Report not found" const RESOLVED_BY_REPORTER_NOTE = "Marked resolved by the reporter" const REOPENED_BY_REPORTER_NOTE = "Reopened by the reporter" const HIDDEN_BY_REPORTER_NOTE = "Hidden from the public map by the reporter" const RELISTED_BY_REPORTER_NOTE = "Re-listed by the reporter" -function isUuid(value: string): boolean { - return UUID_RE.test(value) -} - type PresignMedia = ReportServiceDeps["presignMedia"] interface ReportViewFlags { diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index ce59975f..8d2b05d3 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -33,11 +33,10 @@ import type { VolunteerHoursEntryView, VolunteerHoursRepository, } from "./volunteer-hours-service.js" +import { MS_PER_SECOND } from "../lib/time.js" const MORE_PAGES = "more" -const MS_PER_SECOND = 1000 - const HOURS_ROUNDING_FACTOR = 100 const RECIPROCAL_LOOKBACK_INTERVAL = `${RECIPROCAL_LOOKBACK_MS / MS_PER_SECOND} seconds` diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 398943ab..a2c717de 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -31,6 +31,7 @@ import type { VolunteerHoursEntryView, VolunteerHoursRepository, } from "./volunteer-hours-service.js" +import { MS_PER_WEEK } from "../lib/time.js" export interface MemoryLeaderboardUser { name: string @@ -81,8 +82,6 @@ function round2(n: number): number { return Math.round(n * HOURS_ROUNDING_FACTOR) / HOURS_ROUNDING_FACTOR } -const WEEK_MS = 7 * 24 * 60 * 60 * 1000 - export interface InMemoryVolunteerHoursRepositoryOpts { now?: () => Date newId?: () => string @@ -257,7 +256,7 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor e.userId === entry.userId && e.source !== "report" && e.voidedAt === undefined && - nowMs - e.createdAt.getTime() <= WEEK_MS, + nowMs - e.createdAt.getTime() <= MS_PER_WEEK, ) .reduce((sum, e) => sum + e.hours, 0) if (weekly > weeklyFlagHours) { diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index a4d3bc41..6a3bdacd 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -28,7 +28,10 @@ import type { import { can, type HostStanding } from "@civfix/shared/host" import { parseKeysetCursor, type KeysetCursor } from "../db/cursor-helpers.js" import { MIN_EVENT_DURATION_MS, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" -import { mapWithLimit, PRESIGN_CONCURRENCY } from "./media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" +import { clampPageLimit } from "../lib/page-limit.js" +import { MS_PER_HOUR, MS_PER_MINUTE } from "../lib/time.js" +import { PRESIGN_CONCURRENCY } from "./media-presign.js" import type { AffiliationLoader } from "./affiliation.js" import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" import type { TopVolunteerRow } from "./host/analytics-repository.drizzle.js" @@ -49,10 +52,6 @@ export const MAX_ORG_CHIPS_FETCH = 20 const HOURS_NOTIFY_CONCURRENCY = 8 -const MS_PER_MINUTE = 60_000 - -const MS_PER_HOUR = 60 * MS_PER_MINUTE - const HOURS_ROUNDING_FACTOR = 100 const EVENT_WINDOW_GRACE_MS = MS_PER_HOUR @@ -287,11 +286,6 @@ export interface VolunteerHoursService { ): Promise } -function clampLimit(limit: number | undefined): number { - if (limit === undefined) return LEADERBOARD_DEFAULT_LIMIT - return Math.min(Math.max(1, Math.floor(limit)), LEADERBOARD_MAX_LIMIT) -} - function clampOffset(offset: number | undefined): number { if (offset === undefined) return 0 return Math.min(Math.max(0, Math.floor(offset)), LEADERBOARD_MAX_OFFSET) @@ -303,11 +297,6 @@ function reachableNextOffset(next: number | null): number | null { return next !== null && next <= LEADERBOARD_MAX_OFFSET ? next : null } -function clampEntriesLimit(limit: number | undefined): number { - if (limit === undefined) return HOURS_ENTRIES_DEFAULT_LIMIT - return Math.min(Math.max(1, Math.floor(limit)), HOURS_ENTRIES_MAX_LIMIT) -} - function neutralPublicHours(): PublicVolunteerHoursResponse { return { visible: true, @@ -577,7 +566,11 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu userId: string, query: MyVolunteerHoursEntriesQuery, ): Promise { - const limit = clampEntriesLimit(query.limit) + const limit = clampPageLimit( + query.limit, + HOURS_ENTRIES_DEFAULT_LIMIT, + HOURS_ENTRIES_MAX_LIMIT, + ) const [page, totalHours] = await Promise.all([ deps.repo.listEntries({ userId, cursor: parseKeysetCursor(query.cursor), limit }), deps.repo.totalHoursFor(userId), @@ -609,7 +602,11 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu return neutralPublicHours() } - const limit = clampEntriesLimit(query.limit) + const limit = clampPageLimit( + query.limit, + HOURS_ENTRIES_DEFAULT_LIMIT, + HOURS_ENTRIES_MAX_LIMIT, + ) const [totals, page] = await Promise.all([ deps.repo.totalsFor(userId), visibility.items @@ -692,7 +689,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu query: LeaderboardQuery, viewerId: string | null = null, ): Promise { - const limit = clampLimit(query.limit) + const limit = clampPageLimit(query.limit, LEADERBOARD_DEFAULT_LIMIT, LEADERBOARD_MAX_LIMIT) const offset = clampOffset(query.offset) const withExtras = query.limit === undefined || limit >= LEADERBOARD_EXTRAS_MIN_LIMIT const page = await deps.repo.leaderboard(geoid, limit, offset, viewerId, withExtras) diff --git a/services/api/src/ws/frame-handler.ts b/services/api/src/ws/frame-handler.ts index 8633c121..2b48b023 100644 --- a/services/api/src/ws/frame-handler.ts +++ b/services/api/src/ws/frame-handler.ts @@ -10,7 +10,7 @@ import { } from "@civfix/shared" import type { ChatConnection, UserChannel } from "@civfix/shared/interfaces" import { resolveAndRecordChatMentions } from "../services/chat-mention-resolver.js" -import { mapWithLimit } from "../services/media-presign.js" +import { mapWithLimit } from "../lib/concurrency.js" import { neutralizeChatViewerFields } from "../services/chat-viewer-fields.js" import { containsSlur } from "../abuse/slur-filter.js" import { SUSPENDED_MESSAGE, socketWriteVerdict } from "../auth/account-status.js" diff --git a/services/api/src/ws/report-rate-limit.ts b/services/api/src/ws/report-rate-limit.ts index 958516e0..189da6b7 100644 --- a/services/api/src/ws/report-rate-limit.ts +++ b/services/api/src/ws/report-rate-limit.ts @@ -1,3 +1,5 @@ +import { MS_PER_SECOND } from "../lib/time.js" + export interface RateLimiter { tryConsume(key: string): boolean } @@ -18,8 +20,6 @@ const EVICT_PREFER_FULL_WINDOW = 32 const DEFAULT_MAX_KEYS = 50_000 -const MS_PER_SECOND = 1000 - function refilledTokens(bucket: Bucket, at: number, opts: TokenBucketOptions): number { return Math.min( opts.capacity, diff --git a/services/api/src/ws/send-resilience.ts b/services/api/src/ws/send-resilience.ts index 8bf6ba73..a79d4735 100644 --- a/services/api/src/ws/send-resilience.ts +++ b/services/api/src/ws/send-resilience.ts @@ -1,5 +1,7 @@ import type { FastifyBaseLogger } from "fastify" import type { ChatMessageDTO, RoomKind, WsServerMessage } from "@civfix/shared" +import { unrefSleep } from "../lib/sleep.js" +import { MS_PER_SECOND } from "../lib/time.js" export const SEND_DEDUPE_TTL_SECONDS = 24 * 60 * 60 @@ -42,8 +44,6 @@ export function sendDedupeKey(userId: string, roomKey: string, clientId: string) const IN_MEMORY_SWEEP_THRESHOLD = 5000 -const MS_PER_SECOND = 1000 - export class InMemorySendDedupeStore implements SendDedupeStore { private readonly store = new Map() private readonly now: () => number @@ -153,12 +153,6 @@ export interface SendResilience { dedupeFailureCount(): number } -const realSleep = (ms: number): Promise => - new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - if (typeof timer.unref === "function") timer.unref() - }) - const OPEN: SendReservation = { state: "open" } function withTimeout(work: Promise, ms: number): Promise { @@ -201,7 +195,7 @@ export function makeRateLimitedWarn( } export function makeSendResilience(deps: SendResilienceDeps = {}): SendResilience { - const sleep = deps.sleep ?? realSleep + const sleep = deps.sleep ?? unrefSleep const jitter = deps.jitter ?? Math.random const attemptTimeoutMs = deps.attemptTimeoutMs ?? BROADCAST_ATTEMPT_TIMEOUT_MS const reserveTimeoutMs = deps.reserveTimeoutMs ?? RESERVE_TIMEOUT_MS diff --git a/services/api/test/unit/adapters-mailer-oci.test.ts b/services/api/test/unit/adapters-mailer-oci.test.ts index 2f5e8cc9..578cc27d 100644 --- a/services/api/test/unit/adapters-mailer-oci.test.ts +++ b/services/api/test/unit/adapters-mailer-oci.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { AppError, ErrorCode, MailSendError } from "@civfix/shared" import type { OutboundEmail } from "@civfix/shared/interfaces" -import { OciMailer, OCI_MAILER_DEFAULT_TIMEOUT_MS } from "../../src/adapters/mailer.oci.js" +import { OciMailer } from "../../src/adapters/mailer.oci.js" +import { OCI_MAILER_DEFAULT_TIMEOUT_MS } from "../../src/adapters/mailer-defaults.js" import { mailFailure } from "../../src/adapters/mail-failure.js" interface SentMailArgs { diff --git a/services/media-worker/src/config.ts b/services/media-worker/src/config.ts index 85e7bf06..80403c82 100644 --- a/services/media-worker/src/config.ts +++ b/services/media-worker/src/config.ts @@ -1,12 +1,6 @@ import { dirname, join } from "node:path" import { MAX_VIDEO_BYTES } from "@civfix/shared" - -const TRUTHY_FLAG_VALUES = ["1", "true", "yes", "on"] - -export function parseBool(raw: string | undefined, fallback: boolean): boolean { - if (raw === undefined || raw === "") return fallback - return TRUTHY_FLAG_VALUES.includes(raw.trim().toLowerCase()) -} +import { MS_PER_HOUR } from "@civfix/api/time" export function assertRealSeamInProd( source: NodeJS.ProcessEnv, @@ -106,7 +100,6 @@ export function loadHttpsProxy(source: NodeJS.ProcessEnv = process.env): string export const ALLOWED_VIDEO_CODECS: ReadonlySet = new Set(["h264", "hevc"]) const ONE_MB = 1024 * 1024 -const HOUR_MS = 60 * 60 * 1000 const DEFAULT_LIMITS = { maxImagePixels: 24_000_000, @@ -124,13 +117,13 @@ const DEFAULT_LIMITS = { thumbnailMaxEdge: 400, nsfwHoldThreshold: 0.8, mediaChecksConcurrency: 2, - orphanTtlMs: 6 * HOUR_MS, + orphanTtlMs: 6 * MS_PER_HOUR, orphanSweepBatch: 1000, orphanSweepMaxPages: 50, holdReleaseSweepBatch: 200, retentionSweepBatch: 5000, retentionSweepMaxPages: 20, - stuckMediaTtlMs: HOUR_MS, + stuckMediaTtlMs: MS_PER_HOUR, stuckSweepBatch: 500, stuckSweepMaxAttempts: 5, } as const diff --git a/services/media-worker/src/jobs.ts b/services/media-worker/src/jobs.ts index 3a9279a1..cd043660 100644 --- a/services/media-worker/src/jobs.ts +++ b/services/media-worker/src/jobs.ts @@ -18,7 +18,8 @@ import type PgBoss from "pg-boss" import type { Jobs, EnqueueOptions, JobHandler } from "@civfix/shared/interfaces" import { FakeJobs } from "@civfix/shared/fakes" -import { assertRealSeamInProd, loadLimits, parseBool } from "./config.js" +import { parseBool } from "@civfix/api/env-parsers" +import { assertRealSeamInProd, loadLimits } from "./config.js" /** Default ON outside production so the worker boots offline; an explicit ON in prod fails boot. */ function useFakeJobs(source: NodeJS.ProcessEnv = process.env): boolean { diff --git a/services/media-worker/src/jobs/orphan-sweep.ts b/services/media-worker/src/jobs/orphan-sweep.ts index be290855..d3a690cb 100644 --- a/services/media-worker/src/jobs/orphan-sweep.ts +++ b/services/media-worker/src/jobs/orphan-sweep.ts @@ -5,25 +5,11 @@ import { drainPages } from "./drain.js" import { resolveJobObs, type JobObsDeps, type JobLogFn, type JobReportFn } from "./obs.js" import { R2_PUT_TTL_SEC } from "@civfix/api/adapters/storage" import { servedKey, thumbnailKey } from "./media-keys.js" - -async function mapWithLimit( - items: T[], - limit: number, - fn: (item: T) => Promise, -): Promise { - let cursor = 0 - const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { - while (cursor < items.length) { - const item = items[cursor++] - if (item !== undefined) await fn(item) - } - }) - await Promise.all(runners) -} +import { mapWithLimit } from "@civfix/api/concurrency" +import { MS_PER_SECOND } from "@civfix/api/time" const ORPHAN_SWEEP = "orphan.sweep" const ORPHAN_CONCURRENCY = 8 -const MS_PER_SECOND = 1000 const LEGACY_PROCESSED_SUFFIXES = [".img", ".mp4"] export const LEAK_RETRY_MAX_ATTEMPTS = 5 diff --git a/services/media-worker/src/jobs/retention-sweep.ts b/services/media-worker/src/jobs/retention-sweep.ts index 1732462b..f199167e 100644 --- a/services/media-worker/src/jobs/retention-sweep.ts +++ b/services/media-worker/src/jobs/retention-sweep.ts @@ -10,6 +10,7 @@ import { GEOCODE_CACHE_TTL_MS } from "@civfix/api/geocode-cache" import { makeDrizzleRetentionRepository } from "@civfix/api/retention-repo" import { drainPages } from "./drain.js" import { resolveJobObs, type JobObsDeps } from "./obs.js" +import { MS_PER_DAY, MS_PER_HOUR } from "@civfix/api/time" export interface RetentionSweepDeps extends JobObsDeps { sql: Sql @@ -36,13 +37,11 @@ export interface RetentionSweepResult { } const RETENTION_SWEEP = "retention.sweep" -const HOUR_MS = 60 * 60 * 1000 -const DAY_MS = 24 * HOUR_MS -export const RETENTION_GRACE_MS = HOUR_MS +export const RETENTION_GRACE_MS = MS_PER_HOUR const RETENTION_BATCH = 5000 -const RETENTION_IDEMPOTENCY_MS = 48 * HOUR_MS -const RETENTION_NOTIFICATIONS_MS = 90 * DAY_MS +const RETENTION_IDEMPOTENCY_MS = 48 * MS_PER_HOUR +const RETENTION_NOTIFICATIONS_MS = 90 * MS_PER_DAY export const RETENTION_GEOCODE_CACHE_MS = GEOCODE_CACHE_TTL_MS export const RETENTION_INBOUND_EMAILS_MS = INBOUND_EMAIL_RETENTION_MS export const RETENTION_MAX_PAGES = 20 diff --git a/services/media-worker/src/sandbox/exec.ts b/services/media-worker/src/sandbox/exec.ts index edf7b409..d9580664 100644 --- a/services/media-worker/src/sandbox/exec.ts +++ b/services/media-worker/src/sandbox/exec.ts @@ -1,12 +1,8 @@ import { dirname } from "node:path" import { tmpdir } from "node:os" import { execa, type Options as ExecaOptions } from "execa" -import { - CHILD_KILL_SIGNAL, - loadSandboxIdentity, - parseBool, - type SandboxIdentity, -} from "../config.js" +import { parseBool } from "@civfix/api/env-parsers" +import { CHILD_KILL_SIGNAL, loadSandboxIdentity, type SandboxIdentity } from "../config.js" const STDERR_TAIL_CHARS = 800 const SANDBOX_LOCALE = "C" diff --git a/services/media-worker/src/seams.ts b/services/media-worker/src/seams.ts index bd6bfa10..4e17e9de 100644 --- a/services/media-worker/src/seams.ts +++ b/services/media-worker/src/seams.ts @@ -8,7 +8,8 @@ import type { AnonHoldReleaseRepo } from "@civfix/api/anon-hold-release" import { R2Storage } from "@civfix/api/adapters/storage" import { LOCAL_STORAGE_DEV_SIGNING_KEY, LocalDiskStorage } from "@civfix/api/adapters/storage-local" import { captureError, initErrorReporting, flushErrorReporting } from "@civfix/api/errors" -import { assertRealSeamInProd, loadLimits, parseBool, type WorkerLimits } from "./config.js" +import { parseBool } from "@civfix/api/env-parsers" +import { assertRealSeamInProd, loadLimits, type WorkerLimits } from "./config.js" import { makeDownloader, type DownloadFn } from "./download.js" import type { JobLogFn } from "./jobs/obs.js" diff --git a/services/media-worker/src/worker.ts b/services/media-worker/src/worker.ts index c7d7212f..73fe29b2 100644 --- a/services/media-worker/src/worker.ts +++ b/services/media-worker/src/worker.ts @@ -37,6 +37,7 @@ import { import { sweepStaleScratchDirs } from "./sandbox/tmp.js" import { assertSandboxPreflight } from "./sandbox/preflight.js" import { killAllSandboxChildren } from "./sandbox/exec.js" +import { MS_PER_SECOND } from "@civfix/api/time" export const ORPHAN_SWEEP_JOB = "orphan.sweep" export const CHAT_PARTITION_JOB = "chat.partition.maintenance" @@ -48,7 +49,6 @@ export const MEDIA_STUCK_SWEEP_JOB = "media.stuck.sweep" const CRON_EXPIRE_SECONDS = 25 * 60 const MEDIA_CHECKS_RETRY_LIMIT = 5 const UPLOAD_REAP_RETRY_LIMIT = 3 -const MS_PER_SECOND = 1000 const COMPOSE_STOP_GRACE_HEADROOM_SEC = 15 const QUEUES: readonly [name: string, options: QueueOptions][] = [ From e40480f4f396be6e7d891fe508d8fd4d59086cbb Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 15:58:12 +0000 Subject: [PATCH 04/20] drop the temporary mapWithLimit re-export --- services/api/src/services/media-presign.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/api/src/services/media-presign.ts b/services/api/src/services/media-presign.ts index 7b6344bf..5b89f8b2 100644 --- a/services/api/src/services/media-presign.ts +++ b/services/api/src/services/media-presign.ts @@ -53,5 +53,3 @@ export function makePacketMediaPresigner(storage: PresignStorage): PresignPacket // Bounds concurrent SigV4 signings / R2 ops per page. export const PRESIGN_CONCURRENCY = 8 - -export { mapWithLimit } from "../lib/concurrency.js" From 82493752186780af086e30171b2005fac7099300 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 15:59:04 +0000 Subject: [PATCH 05/20] chat notification copy helpers move from routes to services --- services/api/src/services/chat-bells.ts | 2 +- services/api/src/{routes => services}/chat-notify-copy.ts | 0 services/api/src/services/chat-room-fanout-notifier.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename services/api/src/{routes => services}/chat-notify-copy.ts (100%) diff --git a/services/api/src/services/chat-bells.ts b/services/api/src/services/chat-bells.ts index 336c20a4..d5fe5fa0 100644 --- a/services/api/src/services/chat-bells.ts +++ b/services/api/src/services/chat-bells.ts @@ -10,7 +10,7 @@ import type { ChatMessageDTO, NotificationType, RoomKind } from "@civfix/shared" import type { NotificationService } from "./notification-service.js" import { CONVERSATION_BELL } from "./conversation-bell.js" -import { messageAuthorName, textPreview } from "../routes/chat-notify-copy.js" +import { messageAuthorName, textPreview } from "./chat-notify-copy.js" import type { MessageKey } from "../i18n/renderMessage.js" import type { GatewayChatMentions, OnChatReply, OnDmDelivered } from "../ws/types.js" diff --git a/services/api/src/routes/chat-notify-copy.ts b/services/api/src/services/chat-notify-copy.ts similarity index 100% rename from services/api/src/routes/chat-notify-copy.ts rename to services/api/src/services/chat-notify-copy.ts diff --git a/services/api/src/services/chat-room-fanout-notifier.ts b/services/api/src/services/chat-room-fanout-notifier.ts index 6126f814..78904c98 100644 --- a/services/api/src/services/chat-room-fanout-notifier.ts +++ b/services/api/src/services/chat-room-fanout-notifier.ts @@ -3,7 +3,7 @@ import type { FastifyBaseLogger } from "fastify" import type { NotificationService } from "./notification-service.js" import type { MessageKey } from "../i18n/renderMessage.js" import { CONVERSATION_BELL } from "./conversation-bell.js" -import { textPreview } from "../routes/chat-notify-copy.js" +import { textPreview } from "./chat-notify-copy.js" import { mapWithLimit } from "../lib/concurrency.js" export type RoomFanoutKind = "report" | "group" From e03cc1a7c816618022798874e61e52c39d0980e9 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:14:06 +0000 Subject: [PATCH 06/20] one timeout module, one capped body reader, a shared chat message core and fan-out wiring helpers --- services/api/package.json | 4 +- services/api/src/adapters/http-fetch.ts | 28 +--- services/api/src/adapters/push-webpush.ts | 22 +-- .../api/src/adapters/reverse-geocode.chain.ts | 13 +- services/api/src/lib/capped-body.ts | 41 ++++++ .../src => api/src/lib}/timeout.ts | 33 ++++- services/api/src/lifecycle.ts | 23 ++- .../api/src/routes/chat-gateway-wiring.ts | 41 ++---- services/api/src/routes/chat-groups.routes.ts | 15 +- services/api/src/routes/report-chat.routes.ts | 11 +- .../src/services/admin/inbound-processor.ts | 20 +-- .../services/admin/outbound-mail-service.ts | 10 +- .../src/services/blocks-repository.drizzle.ts | 8 + .../src/services/chat-message-core.drizzle.ts | 137 ++++++++++++++++++ .../src/services/chat-repository.drizzle.ts | 101 ++----------- .../src/services/chat-room-notifier-wiring.ts | 60 +++----- .../conversation-mutes-repository.drizzle.ts | 14 ++ .../api/src/services/dm-repository.drizzle.ts | 99 ++----------- .../src/services/host/analytics-service.ts | 18 +-- .../services/host/event-analytics-service.ts | 26 +--- .../services/host/host-analytics-shaping.ts | 25 +++- .../api/src/services/report-chat-emitter.ts | 28 +--- .../src/services/report-chat-send-wiring.ts | 29 +--- services/api/src/services/threads-nudge.ts | 9 ++ services/api/src/ws/send-resilience.ts | 17 +-- services/media-worker/src/download.ts | 33 +---- .../media-worker/src/jobs/media-checks.ts | 2 +- services/media-worker/src/sandbox/image.ts | 2 +- 28 files changed, 406 insertions(+), 463 deletions(-) create mode 100644 services/api/src/lib/capped-body.ts rename services/{media-worker/src => api/src/lib}/timeout.ts (51%) create mode 100644 services/api/src/services/chat-message-core.drizzle.ts create mode 100644 services/api/src/services/threads-nudge.ts diff --git a/services/api/package.json b/services/api/package.json index f7cc0cd3..0989e007 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -23,7 +23,9 @@ "./migrate": "./src/db/migrate.ts", "./env-parsers": "./src/env/parsers.ts", "./concurrency": "./src/lib/concurrency.ts", - "./time": "./src/lib/time.ts" + "./time": "./src/lib/time.ts", + "./capped-body": "./src/lib/capped-body.ts", + "./timeout": "./src/lib/timeout.ts" }, "scripts": { "build": "tsup", diff --git a/services/api/src/adapters/http-fetch.ts b/services/api/src/adapters/http-fetch.ts index a5aeea36..e48c824b 100644 --- a/services/api/src/adapters/http-fetch.ts +++ b/services/api/src/adapters/http-fetch.ts @@ -1,3 +1,5 @@ +import { concatChunks, readCappedChunks, type CappedChunks } from "../lib/capped-body.js" + export type FetchJsonResult = | { ok: true; status: number; json: T } | { ok: false; kind: "http"; status: number } @@ -61,33 +63,19 @@ export async function fetchJsonWithTimeout( } } const reader = body.getReader() - const chunks: Uint8Array[] = [] - let total = 0 + let read: CappedChunks | null try { - for (;;) { - const { done, value } = await reader.read() - if (done) break - if (value) { - total += value.byteLength - if (total > maxBytes) { - controller.abort() - return { ok: false, kind: "body", status, error: new JsonBodyTooLargeError(maxBytes) } - } - chunks.push(value) - } - } + read = await readCappedChunks(reader, maxBytes, () => controller.abort()) } catch (error) { return { ok: false, kind: "body", status, error } } finally { reader.releaseLock?.() } + if (read === null) { + return { ok: false, kind: "body", status, error: new JsonBodyTooLargeError(maxBytes) } + } try { - const buf = new Uint8Array(total) - let offset = 0 - for (const c of chunks) { - buf.set(c, offset) - offset += c.byteLength - } + const buf = concatChunks(read.chunks, read.total) return { ok: true, status, json: JSON.parse(new TextDecoder().decode(buf)) as T } } catch (error) { return { ok: false, kind: "body", status, error } diff --git a/services/api/src/adapters/push-webpush.ts b/services/api/src/adapters/push-webpush.ts index f845e432..1da7c9f4 100644 --- a/services/api/src/adapters/push-webpush.ts +++ b/services/api/src/adapters/push-webpush.ts @@ -7,6 +7,7 @@ import { type PushAddressResolver, } from "../services/push-token-policy.js" import { mapWithLimit } from "../lib/concurrency.js" +import { passThroughRejection, settleWithin } from "../lib/timeout.js" import { Agent } from "node:https" import type WebPush from "web-push" @@ -148,22 +149,13 @@ export class WebPushDeadlineError extends Error { } } -async function withDeadline(work: Promise, ms: number, onExpire: () => void): Promise { - void work.catch(() => {}) - let timer: ReturnType | undefined - const guard = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - onExpire() - reject(new WebPushDeadlineError(ms)) - }, ms) - timer.unref?.() +function withDeadline(work: Promise, ms: number, onExpire: () => void): Promise { + return settleWithin(work, ms, { + timeoutError: () => new WebPushDeadlineError(ms), + onElapsed: onExpire, + unref: true, + normalizeError: passThroughRejection, }) - - try { - return await Promise.race([work, guard]) - } finally { - if (timer !== undefined) clearTimeout(timer) - } } export function makeWebPushDispatcher( diff --git a/services/api/src/adapters/reverse-geocode.chain.ts b/services/api/src/adapters/reverse-geocode.chain.ts index b437d965..750a9193 100644 --- a/services/api/src/adapters/reverse-geocode.chain.ts +++ b/services/api/src/adapters/reverse-geocode.chain.ts @@ -1,4 +1,5 @@ import type { AddressPrecision } from "@civfix/shared" +import { raceTimeout } from "../lib/timeout.js" export type PointResolver = (lat: number, lng: number) => Promise @@ -56,20 +57,12 @@ export function chainReverse( * A throwing provider counts as "no answer" (the seam fails open). The catch sits on the attempt itself so * it also covers a rejection that lands AFTER the deadline won the race. */ -async function withDeadline( +function withDeadline( provider: PointResolver, lat: number, lng: number, timeoutMs: number, ): Promise { const attempt = (async () => provider(lat, lng))().catch(() => null) - let timer: ReturnType | undefined - const budget = new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs) - }) - try { - return await Promise.race([attempt, budget]) - } finally { - if (timer !== undefined) clearTimeout(timer) - } + return raceTimeout(attempt, timeoutMs, () => null) } diff --git a/services/api/src/lib/capped-body.ts b/services/api/src/lib/capped-body.ts new file mode 100644 index 00000000..a18729be --- /dev/null +++ b/services/api/src/lib/capped-body.ts @@ -0,0 +1,41 @@ +export interface CappedChunks { + chunks: Uint8Array[] + total: number +} + +/** + * The cap is enforced while streaming, so a body that lies about (or omits) its content-length is cut off + * at maxBytes instead of being buffered whole. On overflow `onOverflow` runs (callers abort the fetch) and + * null is returned; read errors propagate raw. The caller owns the reader and releases its lock. + */ +export async function readCappedChunks( + reader: ReadableStreamDefaultReader, + maxBytes: number, + onOverflow: () => void, +): Promise { + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value) { + total += value.byteLength + if (total > maxBytes) { + onOverflow() + return null + } + chunks.push(value) + } + } + return { chunks, total } +} + +export function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array { + const out = new Uint8Array(total) + let offset = 0 + for (const c of chunks) { + out.set(c, offset) + offset += c.byteLength + } + return out +} diff --git a/services/media-worker/src/timeout.ts b/services/api/src/lib/timeout.ts similarity index 51% rename from services/media-worker/src/timeout.ts rename to services/api/src/lib/timeout.ts index 052d1b6b..bb8c72f7 100644 --- a/services/media-worker/src/timeout.ts +++ b/services/api/src/lib/timeout.ts @@ -1,8 +1,9 @@ /** - * Two boundaries in the worker need a JS-side wall clock: the per-job budget (media.checks) and the sharp - * pipeline (sandbox/image.ts, since libvips has no JS-observable timeout). Both had their own copy of the - * same race-free shape; the differences that matter are only WHICH error type is thrown and whether an - * abort side effect fires, so both are expressed as hooks here. + * Wall-clock bounds for work that has no timeout of its own. Callers differ only in WHICH error a timeout + * raises, whether a rejection of the work is coerced or passed through, whether the timer is unref'd and + * whether an abort side effect fires, so those are hooks and every caller keeps its own choices. + * + * This module stays import-free: it is inlined into the sandboxed image-lane child bundle. * * Rejecting does NOT cancel the wrapped work. The caller must arrange cancellation itself (`onElapsed` * exists for exactly that, e.g. aborting an in-flight download). @@ -10,7 +11,7 @@ export interface SettleWithinHooks { timeoutError: () => Error - normalizeError?: (err: unknown) => Error + normalizeError?: (err: unknown) => unknown /** Fires BEFORE the rejection. */ onElapsed?: () => void /** Unref the timer so a pending budget cannot by itself keep the process alive. */ @@ -19,6 +20,8 @@ export interface SettleWithinHooks { const coerceError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err))) +export const passThroughRejection = (err: unknown): unknown => err + export function settleWithin(p: Promise, ms: number, hooks: SettleWithinHooks): Promise { const normalize = hooks.normalizeError ?? coerceError return new Promise((resolve, reject) => { @@ -39,3 +42,23 @@ export function settleWithin(p: Promise, ms: number, hooks: SettleWithinHo ) }) } + +/** + * Resolves with `onTimeout()` rather than rejecting when `ms` elapses first; a rejection passes + * through. + */ +export async function raceTimeout( + work: Promise, + ms: number, + onTimeout: () => F, +): Promise { + let timer: ReturnType | undefined + const expiry = new Promise((resolve) => { + timer = setTimeout(() => resolve(onTimeout()), ms) + }) + try { + return await Promise.race([work, expiry]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} diff --git a/services/api/src/lifecycle.ts b/services/api/src/lifecycle.ts index 955afcc8..b9a74b04 100644 --- a/services/api/src/lifecycle.ts +++ b/services/api/src/lifecycle.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify" import { flushErrorReporting } from "./errors/glitchtip.js" import { SHUTDOWN_DRAIN_MS_MAX } from "./env/parsers.js" import { sleep } from "./lib/sleep.js" +import { raceTimeout } from "./lib/timeout.js" declare module "fastify" { interface FastifyInstance { @@ -56,16 +57,12 @@ interface WebsocketHost { websocketServer?: { clients?: Iterable<{ terminate: () => void }> } } -async function settleWithin(work: Promise, ms: number): Promise<"settled" | "timeout"> { - let timer: ReturnType | undefined - const expiry = new Promise<"timeout">((resolve) => { - timer = setTimeout(() => resolve("timeout"), ms) - }) - try { - return await Promise.race([work.then((): "settled" => "settled"), expiry]) - } finally { - if (timer) clearTimeout(timer) - } +function settleOrTimeout(work: Promise, ms: number): Promise<"settled" | "timeout"> { + return raceTimeout( + work.then((): "settled" => "settled"), + ms, + (): "timeout" => "timeout", + ) } function terminateWebsockets(app: FastifyInstance): void { @@ -88,14 +85,14 @@ async function closeServerBounded( sweep.unref() try { const forceAfterMs = Math.max(0, closeWaitMs - SHUTDOWN_FORCE_GRACE_MS) - if ((await settleWithin(closing, forceAfterMs)) === "settled") return + if ((await settleOrTimeout(closing, forceAfterMs)) === "settled") return app.log.error( { forceAfterMs }, "shutdown: connections outlived the close wait; terminating websockets and destroying sockets", ) terminateWebsockets(app) app.server.closeAllConnections() - if ((await settleWithin(closing, SHUTDOWN_FORCE_GRACE_MS)) === "timeout") { + if ((await settleOrTimeout(closing, SHUTDOWN_FORCE_GRACE_MS)) === "timeout") { app.log.error( { closeWaitMs }, "shutdown: server close did not settle after forcing; proceeding to teardown", @@ -152,7 +149,7 @@ export function makeShutdown( await options.closeContainer() await flushErrorReporting() })() - if ((await settleWithin(teardown, teardownWatchdogMs)) === "timeout") { + if ((await settleOrTimeout(teardown, teardownWatchdogMs)) === "timeout") { app.log.error({ teardownWatchdogMs }, "shutdown: teardown timed out; forcing exit") exit(EXIT_FAILURE) return diff --git a/services/api/src/routes/chat-gateway-wiring.ts b/services/api/src/routes/chat-gateway-wiring.ts index ff38a355..9b79442f 100644 --- a/services/api/src/routes/chat-gateway-wiring.ts +++ b/services/api/src/routes/chat-gateway-wiring.ts @@ -61,12 +61,10 @@ import { makeDrizzleChatReadState, monotonicReadWatermarkUpdate, } from "../services/read-watermark-repository.drizzle.js" +import type { NotificationService } from "../services/notification-service.js" +import { makeRouteNotificationService } from "../services/route-notifier.js" import { - makeNotificationService, - type NotificationService, -} from "../services/notification-service.js" -import { makeDrizzleNotificationRepository } from "../services/notification-repository.drizzle.js" -import { + bindMutedUserIdsFor, makeConversationMutesRepository, makeFailOpenMuteCheck, type ConversationMutesRepository, @@ -85,7 +83,10 @@ import { } from "../services/conversation-bell.js" import type { DmRepository } from "../services/dm-repository.drizzle.js" import { makeDmPeerOf } from "../services/dm-peer.js" -import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" +import { + bindBlockedIdsAmong, + type BlocksRepository, +} from "../services/blocks-repository.drizzle.js" import { InMemoryChatPresence, RedisChatPresence, @@ -293,10 +294,7 @@ function buildChatRepos({ container, overrides, useFakeChat }: WiringContext): C const blocksRepo: BlocksRepository = overrides?.blocksRepo ?? container.getBlocksRepo() const dmRepo: DmRepository = overrides?.dmRepo ?? container.getDmRepo() - const batchBlocked = blocksRepo.blockedIdsAmong - const blockedIdsFor: BatchIdLookup | undefined = batchBlocked - ? (actorId, candidateIds) => batchBlocked.call(blocksRepo, actorId, candidateIds) - : undefined + const blockedIdsFor: BatchIdLookup | undefined = bindBlockedIdsAmong(blocksRepo) const presignMedia = makePrivateMediaPresigner(container.storage) @@ -381,14 +379,7 @@ function buildChatNotifications({ }: WiringContext): ChatNotifications { const notificationService: NotificationService | undefined = overrides?.notificationService ?? - (useFakeChat - ? undefined - : makeNotificationService({ - repo: makeDrizzleNotificationRepository(container.getDb().sql), - pushSender: container.pushSender, - userChannel: container.userChannel, - logger: app.log, - })) + (useFakeChat ? undefined : makeRouteNotificationService(container, app.log)) const conversationMutes: ConversationMutesRepository | undefined = overrides?.conversationMutes ?? @@ -396,20 +387,14 @@ function buildChatNotifications({ const isMutedFor = makeFailOpenMuteCheck(conversationMutes, app.log) - const mutedUserIdsForRoom = ( - kind: FanoutRoomKind, - ): ((roomId: string, userIds: string[]) => Promise>) | undefined => { - const repo = conversationMutes - const batch = repo?.mutedUserIdsFor - if (!repo || !batch) return undefined - return (roomId, userIds) => batch.call(repo, kind, roomId, userIds) - } - return { notificationService, conversationMutes, isMutedFor, - mutedUserIdsFor: { report: mutedUserIdsForRoom("report"), group: mutedUserIdsForRoom("group") }, + mutedUserIdsFor: { + report: bindMutedUserIdsFor(conversationMutes, "report"), + group: bindMutedUserIdsFor(conversationMutes, "group"), + }, } } diff --git a/services/api/src/routes/chat-groups.routes.ts b/services/api/src/routes/chat-groups.routes.ts index ca94596e..6fd0f80b 100644 --- a/services/api/src/routes/chat-groups.routes.ts +++ b/services/api/src/routes/chat-groups.routes.ts @@ -32,6 +32,7 @@ import { type ChatGroupRepository, } from "../services/chat-group-repository.drizzle.js" import { makeChatGroupService, type ChatGroupService } from "../services/chat-group-service.js" +import { nudgeThreads } from "../services/threads-nudge.js" import { makeDrizzleChatRepository, type ChatRepository, @@ -102,16 +103,6 @@ export async function registerChatGroupRoutes( ? overrides.conversationMutes : (mutesRepo ??= makeConversationMutesRepository(container.getDb().sql)) - /** - * A room the user just joined or was added to otherwise only surfaces on the client's next manual - * refresh. Carries no room id, so it is safe to fire at anyone whose membership may have changed. - */ - const nudgeThreads = (userId: string): void => { - void Promise.resolve(container.userChannel?.publishToUser(userId, { topic: "threads" })).catch( - () => {}, - ) - } - const svc = (): ChatGroupService => { const mutes = getMutes() return makeChatGroupService({ @@ -161,7 +152,7 @@ export async function registerChatGroupRoutes( const userId = requireAuth(request) const { id } = parse(GetChatGroupRequestSchema, request.params) const dto = await svc().joinGroup(userId, id) - nudgeThreads(userId) + nudgeThreads(container.userChannel, userId) reply.status(200).send(dto) }, ) @@ -185,7 +176,7 @@ export async function registerChatGroupRoutes( // `added`, not the returned page, decides who is nudged: an invitee the block filter dropped must // learn nothing, and in a group at or over GROUP_MEMBERS_DEFAULT_LIMIT members a fresh member sorts // past page one, so a page-derived set skips exactly the big rooms. - for (const memberId of added) nudgeThreads(memberId) + for (const memberId of added) nudgeThreads(container.userChannel, memberId) reply.status(200).send(page) }, ) diff --git a/services/api/src/routes/report-chat.routes.ts b/services/api/src/routes/report-chat.routes.ts index d38e0536..4e0e07da 100644 --- a/services/api/src/routes/report-chat.routes.ts +++ b/services/api/src/routes/report-chat.routes.ts @@ -39,6 +39,7 @@ import { isReportVisibleTo } from "../services/report-visibility.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" import { withAffiliation } from "../services/affiliation.js" import { makeChatReactionService } from "../services/chat-reaction-service.js" +import { nudgeThreads } from "../services/threads-nudge.js" import { wireChatPowers } from "./chat-powers-wiring.js" export interface DiscussionServiceOverrides { @@ -92,12 +93,6 @@ export async function registerReportChatRoutes( if (!isReportVisibleTo(report, viewerUserId)) throw AppError.notFound(REPORT_NOT_FOUND) } - const nudgeThreads = (userId: string): void => { - void Promise.resolve(container.userChannel?.publishToUser(userId, { topic: "threads" })).catch( - () => {}, - ) - } - route(app, "reportMessages", async (request, reply) => { const { id } = parse(ReportChatIdParamsSchema, request.params) const q = parse(ReportChatHistoryRequestSchema, { ...(request.query as object), id }) @@ -190,7 +185,7 @@ export async function registerReportChatRoutes( parse(JoinReportChatRequestSchema, { id }) await requireVisibleReport(id, userId) await getReportChatRepo().join(id, userId, "member") - nudgeThreads(userId) + nudgeThreads(container.userChannel, userId) reply.status(200).send({ ok: true }) }, ) @@ -228,7 +223,7 @@ export async function registerReportChatRoutes( const { id } = parse(ReportChatIdParamsSchema, request.params) parse(LeaveReportChatRequestSchema, { id }) await getReportChatRepo().leave(id, userId) - nudgeThreads(userId) + nudgeThreads(container.userChannel, userId) reply.status(200).send({ ok: true }) }, ) diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index 9d9084df..17a3406a 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -30,6 +30,7 @@ import { sanitizeInboundHtml } from "./inbound-html-sanitizer.js" import { htmlToText } from "./mail-preview.js" import { ATTACHMENT_FILENAME_MAX_CHARS, safeFilenameChars } from "../../lib/filename.js" import { sha256HexSync } from "../../lib/hash.js" +import { passThroughRejection, settleWithin } from "../../lib/timeout.js" export { detectBounce, resolveMessageId } @@ -69,20 +70,6 @@ function contentDigest(bytes: Uint8Array): string { return sha256HexSync(bytes).slice(0, CONTENT_DIGEST_HEX_CHARS) } -async function withTimeout(work: Promise, ms: number): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - work, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error("inbound parse timed out")), ms) - }), - ]) - } finally { - if (timer !== undefined) clearTimeout(timer) - } -} - export type ProcessOutcome = "threaded" | "inbox" | "replay" | "skipped" | "failed" export interface ProcessResult { @@ -133,7 +120,10 @@ export async function processInboundObject( let mail: ParsedMail try { - mail = await withTimeout(inboundMail.parse(bytes), INBOUND_PARSE_TIMEOUT_MS) + mail = await settleWithin(inboundMail.parse(bytes), INBOUND_PARSE_TIMEOUT_MS, { + timeoutError: () => new Error("inbound parse timed out"), + normalizeError: passThroughRejection, + }) } catch (err) { logger.warn({ key, err: errorText(err) }, "inbound: parse failed; parked under inbound/failed/") await moveToFailed(storage, key, bytes) diff --git a/services/api/src/services/admin/outbound-mail-service.ts b/services/api/src/services/admin/outbound-mail-service.ts index 813fed5a..64425e6c 100644 --- a/services/api/src/services/admin/outbound-mail-service.ts +++ b/services/api/src/services/admin/outbound-mail-service.ts @@ -19,6 +19,7 @@ import { OUTBOUND_SEND_MIN_THROUGHPUT_BPS, OUTBOUND_SEND_PHASE_BUDGET_MS, } from "./outbound-send-policy.js" +import { passThroughRejection, settleWithin } from "../../lib/timeout.js" export interface OutboundMailEnv { MAIL_FROM_OUTREACH: string @@ -175,13 +176,10 @@ export function outboundPayloadBytes(input: { } function raceDeadline(work: Promise, ms: number): Promise { - let timer: ReturnType | undefined - const deadline = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new OutboundSendDeadlineError(ms)), ms) + return settleWithin(work, ms, { + timeoutError: () => new OutboundSendDeadlineError(ms), + normalizeError: passThroughRejection, }) - return Promise.race([work, deadline]).finally(() => { - if (timer !== undefined) clearTimeout(timer) - }) as Promise } interface DeliveryArgs { diff --git a/services/api/src/services/blocks-repository.drizzle.ts b/services/api/src/services/blocks-repository.drizzle.ts index af0114a2..b9d071df 100644 --- a/services/api/src/services/blocks-repository.drizzle.ts +++ b/services/api/src/services/blocks-repository.drizzle.ts @@ -34,6 +34,14 @@ export interface BlocksRepository { listBlocked(blockerId: string, args?: ListBlockedArgs): Promise } +export function bindBlockedIdsAmong( + repo: BlocksRepository, +): ((actorId: string, candidateIds: string[]) => Promise>) | undefined { + const batch = repo.blockedIdsAmong + if (!batch) return undefined + return (actorId, candidateIds) => batch.call(repo, actorId, candidateIds) +} + export function makeDrizzleBlocksRepository(sql: Sql): BlocksRepository { return { async block(blockerId: string, blockedId: string): Promise { diff --git a/services/api/src/services/chat-message-core.drizzle.ts b/services/api/src/services/chat-message-core.drizzle.ts new file mode 100644 index 00000000..2377500e --- /dev/null +++ b/services/api/src/services/chat-message-core.drizzle.ts @@ -0,0 +1,137 @@ +// Room chat and DMs serialize one ChatMessageDTO contract, so the sender projection, the shared DTO +// fields and the page-level loaders live here once: a deleted author then redacts identically in both. + +import type { + ChatMessageDTO, + ChatMessageKind, + MediaDTO, + ReactionSummaryDTO, + ReplyToDTO, + UserMentionDTO, +} from "@civfix/shared" +import type { Queryable } from "../db/client.js" +import { publicAuthorIdentity } from "./public-author.js" +import { loadChatReactionsFor } from "./chat-reactions.drizzle.js" +import { loadChatMentionsFor } from "./chat-mentions-repository.drizzle.js" +import { loadChatAttachments } from "./chat-attachments.drizzle.js" +import { replyMapForRows } from "./chat-reply-hydration.js" +import type { ReplyTable } from "./reply-targets-repository.drizzle.js" +import type { PresignMedia } from "./media-presign.js" +import { liveMessageIds } from "./chat-tombstone.js" + +export interface MessageCoreRow { + id: string + body: string | null + kind: ChatMessageKind + created_at: Date + edited_at: Date | null + deleted_at: Date | null + reply_to_id: string | null + pinned_at: Date | null + sender_display_name: string | null + sender_handle: string | null + sender_bio: string | null + sender_avatar_url: string | null + sender_deleted_at: Date | null +} + +export interface MessageParts { + attachments: MediaDTO[] + reactions: ReactionSummaryDTO[] + mentions: UserMentionDTO[] + replyTo: ReplyToDTO | null | undefined +} + +export function senderColumns(tag: Queryable) { + return tag` + u.display_name AS sender_display_name, + u.handle AS sender_handle, + u.bio AS sender_bio, + u.avatar_url AS sender_avatar_url, + u.deleted_at AS sender_deleted_at + ` +} + +export function messageCoreFields( + r: MessageCoreRow, + senderId: string, + { attachments, reactions, mentions, replyTo }: MessageParts, +): Pick< + ChatMessageDTO, + | "from" + | "body" + | "kind" + | "attachments" + | "reactions" + | "mentions" + | "createdAt" + | "editedAt" + | "deletedAt" + | "replyToId" + | "replyTo" + | "pinnedAt" +> { + const author = publicAuthorIdentity({ + id: senderId, + displayName: r.sender_display_name, + handle: r.sender_handle, + avatarUrl: r.sender_avatar_url, + deletedAt: r.sender_deleted_at, + }) + return { + from: { + id: senderId, + name: author.name, + handle: author.handle, + bio: author.deleted ? null : r.sender_bio, + avatar: author.avatar, + ...(author.avatarUrl !== undefined ? { avatarUrl: author.avatarUrl } : {}), + followers: 0, + following: 0, + isFollowing: false, + ...(author.deleted ? { deleted: true } : {}), + }, + ...(r.body !== null ? { body: r.body } : {}), + kind: r.kind, + attachments, + reactions, + mentions, + createdAt: r.created_at.toISOString(), + ...(r.edited_at !== null ? { editedAt: r.edited_at.toISOString() } : {}), + ...(r.deleted_at !== null ? { deletedAt: r.deleted_at.toISOString() } : {}), + ...(r.reply_to_id !== null ? { replyToId: r.reply_to_id, replyTo: replyTo ?? null } : {}), + ...(r.pinned_at != null ? { pinnedAt: r.pinned_at.toISOString() } : {}), + } +} + +export function replyFor( + row: Pick, + replyByTarget: Map, +): ReplyToDTO | null { + return row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null +} + +export async function loadMessageExtras( + sql: Queryable, + table: ReplyTable, + page: readonly R[], + viewerUserId: string | null, + presign: PresignMedia | undefined, +): Promise<(row: R) => MessageParts> { + const ids = liveMessageIds(page) + const [attachmentsByMessage, reactionsByMessage, mentionsByMessage, replyByTarget] = + await Promise.all([ + presign + ? loadChatAttachments(sql, ids, presign, viewerUserId) + : Promise.resolve(new Map()), + loadChatReactionsFor(sql, ids, viewerUserId), + loadChatMentionsFor(sql, ids), + replyMapForRows(sql, table, page), + ]) + return (r) => ({ + attachments: attachmentsByMessage.get(r.id) ?? [], + reactions: reactionsByMessage.get(r.id) ?? [], + mentions: mentionsByMessage.get(r.id) ?? [], + replyTo: replyFor(r, replyByTarget), + }) +} diff --git a/services/api/src/services/chat-repository.drizzle.ts b/services/api/src/services/chat-repository.drizzle.ts index bb60eb44..a7cc3d24 100644 --- a/services/api/src/services/chat-repository.drizzle.ts +++ b/services/api/src/services/chat-repository.drizzle.ts @@ -1,5 +1,4 @@ import type { Queryable, Sql } from "../db/client.js" -import { publicAuthorIdentity } from "./public-author.js" import type { ChatMessageDTO, ChatMessageKind, @@ -11,12 +10,8 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { ChatHistoryPage, PersistChatInput } from "@civfix/shared/interfaces" -import { - loadChatReactions, - loadChatReactionsFor, - toggleChatReaction, -} from "./chat-reactions.drizzle.js" -import { loadChatMentions, loadChatMentionsFor } from "./chat-mentions-repository.drizzle.js" +import { loadChatReactions, toggleChatReaction } from "./chat-reactions.drizzle.js" +import { loadChatMentions } from "./chat-mentions-repository.drizzle.js" import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" import type { PresignMedia } from "./media-presign.js" import { mapSystemRow } from "./report-chat-repository.drizzle.js" @@ -29,6 +24,13 @@ import { type RoomScopeSql, } from "./room-messages-repository.drizzle.js" import { liveMessageIds, toTombstoneDTO } from "./chat-tombstone.js" +import { + loadMessageExtras, + messageCoreFields, + replyFor, + senderColumns, + type MessageCoreRow, +} from "./chat-message-core.drizzle.js" export interface ReportCityContext { geoid: string @@ -166,28 +168,15 @@ export interface SoftDeleteOpts { export { PIN_LIST_CAP } -interface ChatRowSelect { - id: string +interface ChatRowSelect extends MessageCoreRow { cleanup_id: string | null report_id: string | null group_id: string | null sender_id: string | null - body: string | null - kind: ChatMessageKind attachments: unknown[] | null - created_at: Date - edited_at: Date | null - deleted_at: Date | null - reply_to_id: string | null - pinned_at: Date | null system_status: string | null system_kind: string | null system_body: string | null - sender_display_name: string | null - sender_handle: string | null - sender_bio: string | null - sender_avatar_url: string | null - sender_deleted_at: Date | null forwarded_to_city?: boolean } @@ -231,13 +220,6 @@ function buildMessageDTO( system_body: r.system_body, }) } - const author = publicAuthorIdentity({ - id: r.sender_id!, - displayName: r.sender_display_name ?? "", - handle: r.sender_handle, - avatarUrl: r.sender_avatar_url, - deletedAt: r.sender_deleted_at, - }) const isReport = r.report_id !== null const isGroup = r.group_id !== null return { @@ -245,28 +227,7 @@ function buildMessageDTO( cleanupId: r.cleanup_id ?? r.report_id ?? r.group_id!, ...(isReport ? { roomKind: "report" as const } : {}), ...(isGroup ? { roomKind: "group" as const } : {}), - from: { - id: r.sender_id!, - name: author.name, - handle: author.handle, - bio: author.deleted ? null : r.sender_bio, - avatar: author.avatar, - ...(author.avatarUrl !== undefined ? { avatarUrl: author.avatarUrl } : {}), - followers: 0, - following: 0, - isFollowing: false, - ...(author.deleted ? { deleted: true } : {}), - }, - ...(r.body !== null ? { body: r.body } : {}), - kind: r.kind, - attachments, - reactions, - mentions, - createdAt: r.created_at.toISOString(), - ...(r.edited_at !== null ? { editedAt: r.edited_at.toISOString() } : {}), - ...(r.deleted_at !== null ? { deletedAt: r.deleted_at.toISOString() } : {}), - ...(r.reply_to_id !== null ? { replyToId: r.reply_to_id, replyTo: replyTo ?? null } : {}), - ...(r.pinned_at != null ? { pinnedAt: r.pinned_at.toISOString() } : {}), + ...messageCoreFields(r, r.sender_id!, { attachments, reactions, mentions, replyTo }), ...(poll != null ? { poll } : {}), mine: viewerUserId != null && r.sender_id === viewerUserId, ...(isReport ? cityForwardFields(r, reportCity) : {}), @@ -293,13 +254,6 @@ export function cityForwardFields( return { forwardedToCity, cityMention } } -function replyFor( - row: Pick, - replyByTarget: Map, -): ReplyToDTO | null { - return row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null -} - function forwardedColumn(sql: Queryable, alias: string) { return sql`EXISTS ( SELECT 1 FROM report_message_forwards f @@ -326,11 +280,7 @@ function chatColumns(sql: Queryable, includeForward: boolean) { cm.system_status, cm.system_kind, cm.system_body, - u.display_name AS sender_display_name, - u.handle AS sender_handle, - u.bio AS sender_bio, - u.avatar_url AS sender_avatar_url, - u.deleted_at AS sender_deleted_at + ${senderColumns(sql)} ${forward} ` } @@ -355,11 +305,7 @@ function selectChatRowFrom(tag: Queryable, cte: string, includeForward: boolean) ${tag(cte)}.system_status, ${tag(cte)}.system_kind, ${tag(cte)}.system_body, - u.display_name AS sender_display_name, - u.handle AS sender_handle, - u.bio AS sender_bio, - u.avatar_url AS sender_avatar_url, - u.deleted_at AS sender_deleted_at + ${senderColumns(tag)} ${forward} FROM ${tag(cte)} LEFT JOIN users u ON u.id = ${tag(cte)}.sender_id @@ -390,31 +336,16 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha viewerUserId: string | null, reportCity: ReportCityContext | null, ): Promise { - const ids = liveMessageIds(page) const pollIds = page.filter((r) => r.kind === "poll" && r.deleted_at === null).map((r) => r.id) - const [ - attachmentsByMessage, - reactionsByMessage, - mentionsByMessage, - replyByTarget, - pollsByMessage, - ] = await Promise.all([ - presign - ? loadChatAttachments(sql, ids, presign, viewerUserId) - : Promise.resolve(new Map()), - loadChatReactionsFor(sql, ids, viewerUserId), - loadChatMentionsFor(sql, ids), - replyMapForRows(sql, "chat_messages", page), + const [partsFor, pollsByMessage] = await Promise.all([ + loadMessageExtras(sql, "chat_messages", page, viewerUserId, presign), loadPollsFor(sql, pollIds, viewerUserId), ]) return page.map((r) => toMessageDTO(r, { - reactions: reactionsByMessage.get(r.id) ?? [], - mentions: mentionsByMessage.get(r.id) ?? [], + ...partsFor(r), viewerUserId, - attachments: attachmentsByMessage.get(r.id) ?? [], reportCity, - replyTo: replyFor(r, replyByTarget), poll: pollsByMessage.get(r.id) ?? null, }), ) diff --git a/services/api/src/services/chat-room-notifier-wiring.ts b/services/api/src/services/chat-room-notifier-wiring.ts index 9f92f6e9..174fb925 100644 --- a/services/api/src/services/chat-room-notifier-wiring.ts +++ b/services/api/src/services/chat-room-notifier-wiring.ts @@ -2,9 +2,12 @@ import type { FastifyBaseLogger } from "fastify" import type { Container } from "../di.js" import { makeReportChatRepository } from "./report-chat-repository.drizzle.js" import { makeChatGroupRepository } from "./chat-group-repository.drizzle.js" -import { makeNotificationService } from "./notification-service.js" -import { makeDrizzleNotificationRepository } from "./notification-repository.drizzle.js" -import { makeConversationMutesRepository } from "./conversation-mutes-repository.drizzle.js" +import { makeRouteNotificationService } from "./route-notifier.js" +import { + bindMutedUserIdsFor, + makeConversationMutesRepository, +} from "./conversation-mutes-repository.drizzle.js" +import { bindBlockedIdsAmong } from "./blocks-repository.drizzle.js" import { RedisChatPresence } from "../adapters/chat-presence.js" import { roomKeyFor } from "../ws/gateway.js" import { @@ -28,50 +31,35 @@ export function makeContainerRoomFanoutDeps( const reportChatRepo = makeReportChatRepository(sql) const groupRepo = makeChatGroupRepository(sql) - const notificationService = makeNotificationService({ - repo: makeDrizzleNotificationRepository(sql), - pushSender: container.pushSender, - userChannel: container.userChannel, - ...(logger !== undefined ? { logger } : {}), - }) + const notificationService = makeRouteNotificationService(container, logger) const conversationMutes = makeConversationMutesRepository(sql) - const mutedUserIdsForRoom = ( - kind: RoomFanoutKind, - ): ((roomId: string, userIds: string[]) => Promise>) | undefined => { - const batch = conversationMutes.mutedUserIdsFor - if (!batch) return undefined - return (roomId, userIds) => batch.call(conversationMutes, kind, roomId, userIds) - } const blocksRepo = container.getBlocksRepo() const isBlockedEitherWay = (a: string, b: string): Promise => blocksRepo.isBlockedEitherWay(a, b) - const blockedIdsFor = ((): - | ((actorId: string, candidateIds: string[]) => Promise>) - | undefined => { - const batch = blocksRepo.blockedIdsAmong - if (!batch) return undefined - return (actorId, candidateIds) => batch.call(blocksRepo, actorId, candidateIds) - })() + const blockedIdsFor = bindBlockedIdsAmong(blocksRepo) const redisBacked = !container.env.USE_FAKE_CHAT && container.usesRealRedis === true const presence = redisBacked ? new RedisChatPresence(container.getRedis()) : undefined const claimWindow = redisBacked ? makeWindowClaim(container, logger) : undefined - const common = (kind: RoomFanoutKind): Omit => ({ - notificationService, - isMuted: (userId, roomId) => conversationMutes.isMuted(userId, kind, roomId), - ...(mutedUserIdsForRoom(kind) ? { mutedUserIdsFor: mutedUserIdsForRoom(kind) } : {}), - ...(presence !== undefined ? { presence } : {}), - roomKey: (roomId) => roomKeyFor(kind, roomId), - isBlockedEitherWay, - ...(blockedIdsFor ? { blockedIdsFor } : {}), - ...(claimWindow !== undefined - ? { claimWindow: (roomId: string, windowMs: number) => claimWindow(kind, roomId, windowMs) } - : {}), - ...(logger !== undefined ? { logger } : {}), - }) + const common = (kind: RoomFanoutKind): Omit => { + const mutedUserIdsFor = bindMutedUserIdsFor(conversationMutes, kind) + return { + notificationService, + isMuted: (userId, roomId) => conversationMutes.isMuted(userId, kind, roomId), + ...(mutedUserIdsFor ? { mutedUserIdsFor } : {}), + ...(presence !== undefined ? { presence } : {}), + roomKey: (roomId) => roomKeyFor(kind, roomId), + isBlockedEitherWay, + ...(blockedIdsFor ? { blockedIdsFor } : {}), + ...(claimWindow !== undefined + ? { claimWindow: (roomId: string, windowMs: number) => claimWindow(kind, roomId, windowMs) } + : {}), + ...(logger !== undefined ? { logger } : {}), + } + } return { report: { diff --git a/services/api/src/services/conversation-mutes-repository.drizzle.ts b/services/api/src/services/conversation-mutes-repository.drizzle.ts index 5c290003..8a936abc 100644 --- a/services/api/src/services/conversation-mutes-repository.drizzle.ts +++ b/services/api/src/services/conversation-mutes-repository.drizzle.ts @@ -52,6 +52,20 @@ export function makeFailOpenMuteCheck( } } +/** + * Probed, never bound to an empty-Set default: the fan-out treats a present `mutedUserIdsFor` as + * authoritative and skips the per-user `isMuted`, so a `new Set()` fallback would silently unmute the + * whole room over a mutes store without the batch method. + */ +export function bindMutedUserIdsFor( + repo: ConversationMutesRepository | undefined, + roomKind: ConversationMuteRoomKind, +): ((roomId: string, userIds: string[]) => Promise>) | undefined { + const batch = repo?.mutedUserIdsFor + if (!repo || !batch) return undefined + return (roomId, userIds) => batch.call(repo, roomKind, roomId, userIds) +} + export function makeConversationMutesRepository(sql: Sql): ConversationMutesRepository { return { async isMuted( diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index 6c15b9f1..31c5e52b 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -10,12 +10,8 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { ChatHistoryPage } from "@civfix/shared/interfaces" -import { - loadChatReactions, - loadChatReactionsFor, - toggleChatReaction, -} from "./chat-reactions.drizzle.js" -import { loadChatMentions, loadChatMentionsFor } from "./chat-mentions-repository.drizzle.js" +import { loadChatReactions, toggleChatReaction } from "./chat-reactions.drizzle.js" +import { loadChatMentions } from "./chat-mentions-repository.drizzle.js" import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" import { monotonicReadWatermark } from "./read-watermark-repository.drizzle.js" import { assertReplyTarget, replyMapForRows } from "./chat-reply-hydration.js" @@ -26,6 +22,13 @@ import { type RoomScopeSql, } from "./room-messages-repository.drizzle.js" import { liveMessageIds, toTombstoneDTO } from "./chat-tombstone.js" +import { + loadMessageExtras, + messageCoreFields, + replyFor, + senderColumns, + type MessageCoreRow, +} from "./chat-message-core.drizzle.js" // dm_messages is range-partitioned on created_at and an ack carries only the message id, so the bound // lets the planner prune the lookup to recent partitions instead of probing every month ever created. @@ -122,23 +125,11 @@ export interface DmRepository { ): Promise } -interface DmRowSelect { - id: string +interface DmRowSelect extends MessageCoreRow { thread_id: string sender_id: string - body: string | null - kind: ChatMessageKind attachments: unknown[] | null - created_at: Date - edited_at: Date | null - deleted_at: Date | null - reply_to_id: string | null - pinned_at: Date | null sender_display_name: string - sender_handle: string | null - sender_bio: string | null - sender_avatar_url: string | null - sender_deleted_at: Date | null } interface MessageExtras { @@ -166,51 +157,16 @@ function buildMessageDTO( replyTo, }: MessageExtras, ): ChatMessageDTO { - const author = publicAuthorIdentity({ - id: r.sender_id, - displayName: r.sender_display_name, - handle: r.sender_handle, - avatarUrl: r.sender_avatar_url, - deletedAt: r.sender_deleted_at, - }) return { id: r.id, cleanupId: r.thread_id, roomKind: "dm", - from: { - id: r.sender_id, - name: author.name, - handle: author.handle, - bio: author.deleted ? null : r.sender_bio, - avatar: author.avatar, - ...(author.avatarUrl !== undefined ? { avatarUrl: author.avatarUrl } : {}), - followers: 0, - following: 0, - isFollowing: false, - ...(author.deleted ? { deleted: true } : {}), - }, - ...(r.body !== null ? { body: r.body } : {}), - kind: r.kind, - attachments, - reactions, - mentions, - createdAt: r.created_at.toISOString(), - ...(r.edited_at !== null ? { editedAt: r.edited_at.toISOString() } : {}), - ...(r.deleted_at !== null ? { deletedAt: r.deleted_at.toISOString() } : {}), - ...(r.reply_to_id !== null ? { replyToId: r.reply_to_id, replyTo: replyTo ?? null } : {}), - ...(r.pinned_at != null ? { pinnedAt: r.pinned_at.toISOString() } : {}), + ...messageCoreFields(r, r.sender_id, { attachments, reactions, mentions, replyTo }), mine: viewerUserId != null && r.sender_id === viewerUserId, ...(clientId !== undefined ? { clientId } : {}), } } -function replyFor( - row: Pick, - replyByTarget: Map, -): ReplyToDTO | null { - return row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null -} - // dm_threads stores each pair once as (user_lo, user_hi), so both lookups and the insert order the ids. function orderedPair(userA: string, userB: string): [string, string] { return userA < userB ? [userA, userB] : [userB, userA] @@ -230,11 +186,7 @@ function selectDmRowFrom(tag: Queryable, cte: string) { ${tag(cte)}.deleted_at, ${tag(cte)}.reply_to_id, ${tag(cte)}.pinned_at, - u.display_name AS sender_display_name, - u.handle AS sender_handle, - u.bio AS sender_bio, - u.avatar_url AS sender_avatar_url, - u.deleted_at AS sender_deleted_at + ${senderColumns(tag)} FROM ${tag(cte)} JOIN users u ON u.id = ${tag(cte)}.sender_id ` @@ -253,36 +205,15 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep dm.deleted_at, dm.reply_to_id, dm.pinned_at, - u.display_name AS sender_display_name, - u.handle AS sender_handle, - u.bio AS sender_bio, - u.avatar_url AS sender_avatar_url, - u.deleted_at AS sender_deleted_at + ${senderColumns(sql)} ` async function hydrateDmRows( page: DmRowSelect[], viewerUserId: string | null, ): Promise { - const ids = liveMessageIds(page) - const [attachmentsByMessage, reactionsByMessage, mentionsByMessage, replyByTarget] = - await Promise.all([ - presign - ? loadChatAttachments(sql, ids, presign, viewerUserId) - : Promise.resolve(new Map()), - loadChatReactionsFor(sql, ids, viewerUserId), - loadChatMentionsFor(sql, ids), - replyMapForRows(sql, "dm_messages", page), - ]) - return page.map((r) => - toMessageDTO(r, { - reactions: reactionsByMessage.get(r.id) ?? [], - mentions: mentionsByMessage.get(r.id) ?? [], - viewerUserId, - attachments: attachmentsByMessage.get(r.id) ?? [], - replyTo: replyFor(r, replyByTarget), - }), - ) + const partsFor = await loadMessageExtras(sql, "dm_messages", page, viewerUserId, presign) + return page.map((r) => toMessageDTO(r, { ...partsFor(r), viewerUserId })) } async function hydrateDmRow( diff --git a/services/api/src/services/host/analytics-service.ts b/services/api/src/services/host/analytics-service.ts index 47905c14..ec12320b 100644 --- a/services/api/src/services/host/analytics-service.ts +++ b/services/api/src/services/host/analytics-service.ts @@ -2,7 +2,6 @@ import { ANALYTICS_SUPPRESSION_K, MAX_PORTFOLIO_TOP_VOLUNTEERS, type AnalyticsRange, - type BreakdownRow, type BroadcastChannel, type EventAnalyticsBroadcastsResponse, type EventAnalyticsCheckinsResponse, @@ -28,8 +27,6 @@ import { suppressRate, type DayCount, type DayRange, - type DerivedBreakdownRow, - type DerivedPanel, type KeyCount, } from "@civfix/shared/host" import type { AnalyticsRepository, LabeledKeyCount } from "./analytics-repository.drizzle.js" @@ -58,6 +55,7 @@ import { seriesOf, shiftDayKey, toFunnelSteps, + toPanel, toRate, toSeries, toSuppressedRate, @@ -540,17 +538,3 @@ function sumMetric(rows: readonly MetricRow[], metric: string, bucket?: string): .filter((row) => row.metric === metric && (bucket === undefined || row.bucket === bucket)) .reduce((acc, row) => acc + row.value, 0) } - -function toPanel(panel: DerivedPanel): Panel { - return { - panelSuppressed: panel.panelSuppressed, - rows: panel.rows.map( - (row): BreakdownRow => ({ - key: row.key, - label: row.key, - value: row.value, - suppressed: row.suppressed, - }), - ), - } -} diff --git a/services/api/src/services/host/event-analytics-service.ts b/services/api/src/services/host/event-analytics-service.ts index 86bc007b..00a7fbe2 100644 --- a/services/api/src/services/host/event-analytics-service.ts +++ b/services/api/src/services/host/event-analytics-service.ts @@ -8,7 +8,6 @@ import { MAX_EVENT_ANALYTICS_ARRIVAL_BUCKETS, MAX_EVENT_ANALYTICS_PANEL_ROWS, MAX_EVENT_ANALYTICS_SERIES_POINTS, - type BreakdownRow, type EventAnalyticsPhase, type EventAnalyticsScope, type GetEventAnalyticsResponse, @@ -55,6 +54,7 @@ import { seriesOf, shiftDayKey, toFunnelSteps, + toPanel, toRate, toSeries, } from "./host-analytics-shaping.js" @@ -89,18 +89,8 @@ export interface EventAnalyticsService { ): Promise } -function toPanel(panel: DerivedPanel): Panel { - return { - panelSuppressed: panel.panelSuppressed, - rows: panel.rows.slice(0, MAX_EVENT_ANALYTICS_PANEL_ROWS).map( - (row): BreakdownRow => ({ - key: row.key, - label: row.key, - value: row.value, - suppressed: row.suppressed, - }), - ), - } +function eventPanel(panel: DerivedPanel): Panel { + return toPanel(panel, MAX_EVENT_ANALYTICS_PANEL_ROWS) } function tailSum(points: readonly { value: number | null }[], days: number): number | null { @@ -118,7 +108,7 @@ function cardSlotPanel( registeredPublishable: boolean, full: boolean, ): Panel { - const panel = toPanel(breakdown(rows, { totalPublishable: registeredPublishable })) + const panel = eventPanel(breakdown(rows, { totalPublishable: registeredPublishable })) if (full) return panel return { ...panel, rows: panel.rows.slice(0, EVENT_ANALYTICS_CARD_SLOT_ROWS) } } @@ -269,7 +259,7 @@ function shapeResponse( bySlot: cardSlotPanel(inputs.registrationsBySlot, registeredPublishable, full), ...(full ? { - bySource: toPanel( + bySource: eventPanel( breakdown( inputs.bySource.map((row) => ({ key: row.source, count: row.seats })), { totalPublishable: registeredPublishable }, @@ -284,12 +274,12 @@ function shapeResponse( }, eventDay: { arrivals: full ? arrivals : arrivals.slice(-EVENT_ANALYTICS_CARD_SERIES_POINTS), - ...(full ? { bySlot: toPanel(breakdown(inputs.checkinsBySlot)) } : {}), + ...(full ? { bySlot: eventPanel(breakdown(inputs.checkinsBySlot)) } : {}), }, impact: full ? { - hoursBuckets: toPanel(breakdown(inputs.hoursBuckets)), - reportStatuses: toPanel(breakdown(inputs.reportStatuses)), + hoursBuckets: eventPanel(breakdown(inputs.hoursBuckets)), + reportStatuses: eventPanel(breakdown(inputs.reportStatuses)), } : {}, comparison: inputs.comparison, diff --git a/services/api/src/services/host/host-analytics-shaping.ts b/services/api/src/services/host/host-analytics-shaping.ts index 7f8befbb..47f1f7ae 100644 --- a/services/api/src/services/host/host-analytics-shaping.ts +++ b/services/api/src/services/host/host-analytics-shaping.ts @@ -1,8 +1,16 @@ -import type { EventPhase, FunnelStep, SeriesPoint, SuppressedRate } from "@civfix/shared" +import type { + BreakdownRow, + EventPhase, + FunnelStep, + Panel, + SeriesPoint, + SuppressedRate, +} from "@civfix/shared" import { eventPhase, suppressRate, type DayCount, + type DerivedBreakdownRow, type DerivedPanel, type SeriesClosure, } from "@civfix/shared/host" @@ -99,3 +107,18 @@ export function seriesOf(rows: readonly MetricRow[], metric: string): DayCount[] } return [...byDay].map(([day, count]) => ({ day, count })) } + +export function toPanel(panel: DerivedPanel, maxRows?: number): Panel { + const rows = maxRows === undefined ? panel.rows : panel.rows.slice(0, maxRows) + return { + panelSuppressed: panel.panelSuppressed, + rows: rows.map( + (row): BreakdownRow => ({ + key: row.key, + label: row.key, + value: row.value, + suppressed: row.suppressed, + }), + ), + } +} diff --git a/services/api/src/services/report-chat-emitter.ts b/services/api/src/services/report-chat-emitter.ts index 07bfc818..b6ceb3e4 100644 --- a/services/api/src/services/report-chat-emitter.ts +++ b/services/api/src/services/report-chat-emitter.ts @@ -11,9 +11,11 @@ import type { FastifyBaseLogger } from "fastify" import type { Container } from "../di.js" import { makeReportChatRepository } from "./report-chat-repository.drizzle.js" import { makeReportChatNotifier } from "./report-chat-notifier.js" -import { makeNotificationService } from "./notification-service.js" -import { makeDrizzleNotificationRepository } from "./notification-repository.drizzle.js" -import { makeConversationMutesRepository } from "./conversation-mutes-repository.drizzle.js" +import { makeRouteNotificationService } from "./route-notifier.js" +import { + bindMutedUserIdsFor, + makeConversationMutesRepository, +} from "./conversation-mutes-repository.drizzle.js" import { roomKeyFor } from "../ws/gateway.js" import { makeReportChatSystemEmitter, @@ -35,27 +37,11 @@ export function makeContainerReportChatEmitter( const sql = container.getDb().sql const reportChatRepo = makeReportChatRepository(sql) - const notificationService = makeNotificationService({ - repo: makeDrizzleNotificationRepository(sql), - pushSender: container.pushSender, - userChannel: container.userChannel, - ...(logger !== undefined ? { logger } : {}), - }) + const notificationService = makeRouteNotificationService(container, logger) const conversationMutes = makeConversationMutesRepository(sql) - /** - * Probed, never bound to an empty-Set default: the fan-out treats a present `mutedUserIdsFor` as - * authoritative and skips the per-user `isMuted`, so a `new Set()` fallback would silently unmute the - * whole room over a mutes store without the batch method. - */ - const mutedUserIdsFor = ((): - | ((roomId: string, userIds: string[]) => Promise>) - | undefined => { - const batch = conversationMutes.mutedUserIdsFor - if (!batch) return undefined - return (roomId, userIds) => batch.call(conversationMutes, "report", roomId, userIds) - })() + const mutedUserIdsFor = bindMutedUserIdsFor(conversationMutes, "report") const notify = makeReportChatNotifier({ notificationService, diff --git a/services/api/src/services/report-chat-send-wiring.ts b/services/api/src/services/report-chat-send-wiring.ts index 8ba40a64..33fc85a9 100644 --- a/services/api/src/services/report-chat-send-wiring.ts +++ b/services/api/src/services/report-chat-send-wiring.ts @@ -4,12 +4,13 @@ import type { Container } from "../di.js" import { makeReportChatRepository } from "./report-chat-repository.drizzle.js" import { makeReportChatNotifier } from "./report-chat-notifier.js" import { makeChatMentionNotifier, type ChatBellDeps } from "./chat-bells.js" -import { makeNotificationService } from "./notification-service.js" -import { makeDrizzleNotificationRepository } from "./notification-repository.drizzle.js" +import { makeRouteNotificationService } from "./route-notifier.js" import { + bindMutedUserIdsFor, makeConversationMutesRepository, makeFailOpenMuteCheck, } from "./conversation-mutes-repository.drizzle.js" +import { bindBlockedIdsAmong } from "./blocks-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "./cleanup-repository.drizzle.js" import { makeChatGroupRepository } from "./chat-group-repository.drizzle.js" import { makeContainerReportCityForward } from "./report-city-forward-wiring.js" @@ -59,35 +60,17 @@ export function makeContainerReportChatSendDeps( const reportChatRepo = makeReportChatRepository(sql) const logger = options.logger - const notificationService = makeNotificationService({ - repo: makeDrizzleNotificationRepository(sql), - pushSender: container.pushSender, - userChannel: container.userChannel, - ...(logger !== undefined ? { logger } : {}), - }) + const notificationService = makeRouteNotificationService(container, logger) const conversationMutes = makeConversationMutesRepository(sql) const isMutedFor = makeFailOpenMuteCheck(conversationMutes, logger) - const mutedUserIdsFor = ((): - | ((roomId: string, userIds: string[]) => Promise>) - | undefined => { - const batch = conversationMutes.mutedUserIdsFor - if (!batch) return undefined - return (roomId, userIds) => batch.call(conversationMutes, "report", roomId, userIds) - })() + const mutedUserIdsFor = bindMutedUserIdsFor(conversationMutes, "report") const isBlockedEitherWay = (a: string, b: string): Promise => container.getBlocksRepo().isBlockedEitherWay(a, b) - const blockedIdsFor = ((): - | ((actorId: string, candidateIds: string[]) => Promise>) - | undefined => { - const repo = container.getBlocksRepo() - const batch = repo.blockedIdsAmong - if (!batch) return undefined - return (actorId, candidateIds) => batch.call(repo, actorId, candidateIds) - })() + const blockedIdsFor = bindBlockedIdsAmong(container.getBlocksRepo()) let cleanupRepo: ReturnType | undefined let groupRepo: ReturnType | undefined diff --git a/services/api/src/services/threads-nudge.ts b/services/api/src/services/threads-nudge.ts new file mode 100644 index 00000000..32797aaa --- /dev/null +++ b/services/api/src/services/threads-nudge.ts @@ -0,0 +1,9 @@ +import type { UserChannel } from "@civfix/shared/interfaces" + +/** + * A room the user just joined or was added to otherwise only surfaces on the client's next manual + * refresh. Carries no room id, so it is safe to fire at anyone whose membership may have changed. + */ +export function nudgeThreads(userChannel: UserChannel | undefined, userId: string): void { + void Promise.resolve(userChannel?.publishToUser(userId, { topic: "threads" })).catch(() => {}) +} diff --git a/services/api/src/ws/send-resilience.ts b/services/api/src/ws/send-resilience.ts index a79d4735..dfac99b7 100644 --- a/services/api/src/ws/send-resilience.ts +++ b/services/api/src/ws/send-resilience.ts @@ -2,6 +2,7 @@ import type { FastifyBaseLogger } from "fastify" import type { ChatMessageDTO, RoomKind, WsServerMessage } from "@civfix/shared" import { unrefSleep } from "../lib/sleep.js" import { MS_PER_SECOND } from "../lib/time.js" +import { settleWithin } from "../lib/timeout.js" export const SEND_DEDUPE_TTL_SECONDS = 24 * 60 * 60 @@ -156,21 +157,7 @@ export interface SendResilience { const OPEN: SendReservation = { state: "open" } function withTimeout(work: Promise, ms: number): Promise { - work.catch(() => undefined) - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timed out")), ms) - if (typeof timer.unref === "function") timer.unref() - work.then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (err: unknown) => { - clearTimeout(timer) - reject(err instanceof Error ? err : new Error(String(err))) - }, - ) - }) + return settleWithin(work, ms, { timeoutError: () => new Error("timed out"), unref: true }) } export function makeRateLimitedWarn( diff --git a/services/media-worker/src/download.ts b/services/media-worker/src/download.ts index 0b1f8013..718dc2fd 100644 --- a/services/media-worker/src/download.ts +++ b/services/media-worker/src/download.ts @@ -1,5 +1,6 @@ import type { Storage } from "@civfix/shared/interfaces" import { normalizeEtag, readEtag } from "@civfix/api/media-repo" +import { concatChunks, readCappedChunks, type CappedChunks } from "@civfix/api/capped-body" import { loadHttpsProxy } from "./config.js" export interface DownloadedObject { @@ -130,8 +131,6 @@ async function readWholeBody(res: Response, r2Key: string, maxBytes: number): Pr return buf } -// The cap is enforced while streaming, so a body that lies about (or omits) its content-length is cut -// off at maxBytes instead of being buffered whole. async function readCappedStream( body: ReadableStream, r2Key: string, @@ -139,38 +138,16 @@ async function readCappedStream( controller: AbortController, ): Promise { const reader = body.getReader() - const chunks: Uint8Array[] = [] - let total = 0 + let read: CappedChunks | null try { - for (;;) { - const { done, value } = await reader.read() - if (done) break - if (value) { - total += value.byteLength - if (total > maxBytes) { - controller.abort() - throw new DownloadTooLargeError(maxBytes) - } - chunks.push(value) - } - } + read = await readCappedChunks(reader, maxBytes, () => controller.abort()) } catch (err) { - if (err instanceof DownloadTooLargeError) throw err throw new StorageUnavailableError(r2Key, err) } finally { reader.releaseLock() } - return concatChunks(chunks, total) -} - -function concatChunks(chunks: Uint8Array[], total: number): Uint8Array { - const out = new Uint8Array(total) - let offset = 0 - for (const c of chunks) { - out.set(c, offset) - offset += c.byteLength - } - return out + if (read === null) throw new DownloadTooLargeError(maxBytes) + return concatChunks(read.chunks, read.total) } async function headEtag(storage: Storage, r2Key: string): Promise { diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index e0a4bea6..6f880b7e 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -2,9 +2,9 @@ import type { MediaKind, MediaStatus } from "@civfix/shared" import type { AbuseChecks, Storage, StorageHead } from "@civfix/shared/interfaces" import type { MediaResultPatch, MediaWorkerAsset, MediaWorkerRepo } from "@civfix/api/media-repo" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" +import { settleWithin } from "@civfix/api/timeout" import type { WorkerLimits } from "../config.js" import { DownloadTooLargeError, type DownloadedObject, type DownloadFn } from "../download.js" -import { settleWithin } from "../timeout.js" import { resolveJobObs, type JobObs, diff --git a/services/media-worker/src/sandbox/image.ts b/services/media-worker/src/sandbox/image.ts index d66c7d20..4af41a1c 100644 --- a/services/media-worker/src/sandbox/image.ts +++ b/services/media-worker/src/sandbox/image.ts @@ -24,8 +24,8 @@ import sharp, { type Sharp } from "sharp" import exifr from "exifr" +import { settleWithin } from "@civfix/api/timeout" import type { WorkerLimits } from "../config.js" -import { settleWithin } from "../timeout.js" sharp.cache(false) sharp.concurrency(1) From 50689643cac67651c6d22ec4963b9aca6db8955b Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:27:33 +0000 Subject: [PATCH 07/20] one queue-name module, pg error codes in db, PR 11 delegates removed, insertAuditRow everywhere, like helpers in db, path-over-body helpers --- README.md | 7 +-- services/api/package.json | 3 +- services/api/src/adapters/jobs.pgboss.ts | 53 ++++++++++++------- services/api/src/auth/auth-services.ts | 4 +- services/api/src/auth/pg-stores.ts | 4 +- services/api/src/auth/reserved-handles.ts | 6 --- .../api/src/{services/admin => db}/like.ts | 0 services/api/src/db/pg-errors.ts | 40 ++++++++++++++ services/api/src/db/schema/gov_claims.ts | 2 +- services/api/src/lib/queue-names.ts | 35 ++++++++++++ services/api/src/routes/_validate.ts | 15 ++++++ services/api/src/routes/admin/auth.routes.ts | 5 +- .../api/src/routes/admin/broadcasts.routes.ts | 12 ++--- .../api/src/routes/admin/discovery.routes.ts | 2 +- services/api/src/routes/admin/orgs.routes.ts | 8 +-- services/api/src/routes/admin/pages.routes.ts | 19 +++---- services/api/src/routes/auth.routes.ts | 5 +- services/api/src/routes/claim.routes.ts | 3 +- .../api/src/routes/host/analytics.routes.ts | 12 ++--- .../src/routes/host/announcements.routes.ts | 22 ++------ .../api/src/routes/host/broadcasts.routes.ts | 40 +++++--------- .../api/src/routes/host/exports.routes.ts | 24 +++------ services/api/src/routes/map.routes.ts | 4 +- .../api/src/routes/report-content.routes.ts | 4 +- services/api/src/routes/users.routes.ts | 21 +++++--- services/api/src/server.ts | 3 +- .../admin/activity-repository.drizzle.ts | 2 +- .../admin/admin-event-repository.drizzle.ts | 14 ++--- .../admin/admin-event-repository.memory.ts | 2 +- .../admin-report-chat-repository.drizzle.ts | 4 +- .../admin/admin-report-repository.drizzle.ts | 14 ++--- .../services/admin/admin-report-service.ts | 4 +- .../admin/admin-user-repository.drizzle.ts | 14 ++--- .../admin/audit-repository.drizzle.ts | 2 +- services/api/src/services/admin/audit.ts | 7 --- .../src/services/admin/autoforward-jobs.ts | 3 +- .../api/src/services/admin/discovery-jobs.ts | 6 +-- .../admin/discovery-repository.drizzle.ts | 8 +-- .../forward-template-repository.drizzle.ts | 4 +- .../admin/gov-claims-repository.drizzle.ts | 8 +-- .../src/services/admin/gov-claims-service.ts | 2 +- .../api/src/services/admin/inbound-bounce.ts | 6 +-- .../api/src/services/admin/inbound-jobs.ts | 3 +- .../admin/inbound-repository.drizzle.ts | 6 +-- ...urisdiction-contacts-repository.drizzle.ts | 16 ++---- .../admin/jurisdiction-contacts-service.ts | 14 ++--- .../admin/jurisdiction-contacts-types.ts | 3 -- .../services/admin/mail-repository.drizzle.ts | 4 +- .../admin/moderation-repository.drizzle.ts | 10 ++-- .../api/src/services/admin/outreach-jobs.ts | 2 +- .../api/src/services/admin/sql-fragments.ts | 2 +- .../services/admin/system-health-probes.ts | 3 +- .../services/admin/system-health-service.ts | 14 +++-- .../affiliation-repository.drizzle.ts | 2 +- .../src/services/anon-repository.drizzle.ts | 5 +- services/api/src/services/avatar-media.ts | 6 --- services/api/src/services/chat-fanout-jobs.ts | 3 +- services/api/src/services/cleanup-jobs.ts | 3 +- .../api/src/services/cleanup-notifications.ts | 5 +- .../services/cleanup-repository.drizzle.ts | 21 ++++---- services/api/src/services/cleanup-service.ts | 2 +- .../src/services/content-report-subject.ts | 2 +- services/api/src/services/data-export-jobs.ts | 3 +- .../api/src/services/data-export-service.ts | 4 +- services/api/src/services/guest-jobs.ts | 7 +-- .../api/src/services/guest-rsvp-service.ts | 4 -- .../api/src/services/guest-rsvp-wiring.ts | 4 +- services/api/src/services/hidden-identity.ts | 2 - .../host/admin-pages-repository.drizzle.ts | 2 +- .../api/src/services/host/broadcast-queues.ts | 20 ------- .../host/broadcast-repository.drizzle.ts | 15 +++--- services/api/src/services/host/comms-jobs.ts | 2 + .../api/src/services/host/comms-wiring.ts | 7 +-- .../event-analytics-repository.drizzle.ts | 7 +-- .../host/export-repository.drizzle.ts | 5 +- services/api/src/services/host/host-audit.ts | 5 +- .../api/src/services/host/host-standing.ts | 1 - .../host/host-team-repository.drizzle.ts | 52 +++++++++--------- .../host/organization-admin.drizzle.ts | 7 ++- .../host/organization-profile.drizzle.ts | 11 ++-- .../host/organization-verification.drizzle.ts | 2 +- .../src/services/host/registration-jobs.ts | 2 +- .../host/registration-pages.drizzle.ts | 9 +--- .../src/services/host/registration-queues.ts | 11 ---- .../host/registration-register.drizzle.ts | 3 +- .../host/registration-roster.drizzle.ts | 2 +- .../api/src/services/host/registration-sql.ts | 37 +------------ .../host/registration-ticket-types.drizzle.ts | 7 +-- .../host/registration-waitlist.drizzle.ts | 7 ++- .../src/services/host/registration-wiring.ts | 4 +- .../src/services/host/waitlist-promotion.ts | 2 +- .../api/src/services/jurisdiction-service.ts | 3 +- services/api/src/services/media-bindings.ts | 2 - .../api/src/services/media-intake-service.ts | 3 +- .../api/src/services/media-worker-repo.ts | 1 - .../src/services/mention-resolver.drizzle.ts | 1 - .../message-attachments-repository.drizzle.ts | 5 +- .../src/services/message-mentions.drizzle.ts | 1 - .../notification-repository.drizzle.ts | 8 +-- .../src/services/post-repository.drizzle.ts | 23 ++++---- services/api/src/services/post-service.ts | 2 +- .../src/services/report-chat-send-wiring.ts | 4 +- .../services/report-city-forward-wiring.ts | 9 ++-- .../api/src/services/report-city-forward.ts | 6 +-- .../services/report-forward-audit.drizzle.ts | 4 -- .../src/services/report-repository.drizzle.ts | 4 +- services/api/src/services/report-service.ts | 2 +- .../api/src/services/report-service.types.ts | 1 - .../src/services/social-repository.drizzle.ts | 32 +++-------- .../user-search-repository.drizzle.ts | 6 +-- .../volunteer-hours-repository.drizzle.ts | 3 +- .../api/test/integration/admin-audit.test.ts | 25 +++++---- .../admin-keyset-precision-pg.test.ts | 8 +-- services/api/test/integration/auth-pg.test.ts | 4 +- services/api/test/integration/dm-pg.test.ts | 8 ++- .../api/test/integration/media-pg.test.ts | 2 +- .../report-forward-audit-pg.test.ts | 12 ++--- .../unit/admin-host-platform-routes.test.ts | 2 +- .../unit/admin-jurisdiction-contacts.test.ts | 2 +- .../api/test/unit/admin-read-audit.test.ts | 2 +- .../api/test/unit/chat-fanout-job.test.ts | 2 +- .../unit/chat-gateway-fanout-handoff.test.ts | 2 +- .../unit/city-forward-logger-wiring.test.ts | 4 +- .../test/unit/city-forward-logging.test.ts | 6 +-- .../api/test/unit/city-forward-wiring.test.ts | 4 +- .../cleanup-remove-member-waitlist.test.ts | 2 +- .../api/test/unit/cleanup-service.test.ts | 6 ++- .../test/unit/cleanup-update-location.test.ts | 2 +- .../test/unit/data-export-correctness.test.ts | 7 +-- .../api/test/unit/data-export-route.test.ts | 2 +- services/api/test/unit/discovery-jobs.test.ts | 2 +- .../host/host-service-correctness.test.ts | 2 +- .../test/unit/inbound-bounce-redrive.test.ts | 2 +- .../test/unit/jurisdiction-service.test.ts | 2 +- .../api/test/unit/media-claim-actor.test.ts | 6 +-- .../test/unit/media-finalize-retry.test.ts | 6 +-- .../test/unit/media-intake-service.test.ts | 2 +- services/api/test/unit/media-routes.test.ts | 2 +- .../test/unit/report-forward-audit.test.ts | 8 +-- .../api/test/unit/report-verification.test.ts | 3 +- .../src/jobs/hold-release-sweep.ts | 7 ++- .../media-worker/src/jobs/media-checks.ts | 3 +- .../media-worker/src/jobs/orphan-sweep.ts | 14 ++--- .../src/jobs/partition-maintenance.ts | 5 +- .../media-worker/src/jobs/reject-cleanup.ts | 3 +- .../media-worker/src/jobs/retention-sweep.ts | 9 ++-- services/media-worker/src/jobs/stuck-sweep.ts | 18 +++---- services/media-worker/src/jobs/upload-reap.ts | 3 +- services/media-worker/src/worker.ts | 33 ++++++------ .../test/unit/anon-hold-release.test.ts | 2 +- .../test/unit/maintenance.test.ts | 4 +- .../test/unit/stuck-sweep-etag.test.ts | 2 +- .../media-worker/test/unit/worker.test.ts | 14 ++--- 153 files changed, 558 insertions(+), 639 deletions(-) rename services/api/src/{services/admin => db}/like.ts (100%) create mode 100644 services/api/src/lib/queue-names.ts delete mode 100644 services/api/src/services/host/host-standing.ts delete mode 100644 services/api/src/services/host/registration-queues.ts delete mode 100644 services/api/src/services/mention-resolver.drizzle.ts delete mode 100644 services/api/src/services/message-mentions.drizzle.ts delete mode 100644 services/api/src/services/report-forward-audit.drizzle.ts diff --git a/README.md b/README.md index 1196de7b..c5ed0c51 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,10 @@ postgres-js client, the R2 adapter, or the GlitchTip reporter. It depends on `@c - `@civfix/api/db` - the schema barrel + `makeDb` + `Db`/`Sql` types. - `@civfix/api/media-repo` - the richer media-worker persistence seam (`MediaWorkerRepo`: - find / applyResult / insertAbuseFlag / findOrphans / deleteById), its Drizzle impl, the - `MEDIA_CHECKS_JOB` name, and `ensureNextMonthChatPartition` (so partition bounds/naming have one - source shared with the migrations). + find / applyResult / insertAbuseFlag / findOrphans / deleteById), its Drizzle impl, and + `ensureNextMonthChatPartition` (so partition bounds/naming have one source shared with the + migrations). +- `@civfix/api/queue-names` - every pg-boss queue name and the shared queue policy. - `@civfix/api/adapters/storage`, `@civfix/api/adapters/abuse-checks`, `@civfix/api/errors`, `@civfix/api/migrate` - the R2 adapter, the real AbuseChecks adapter, the GlitchTip helper, and the migration runner (the last reused only by the Docker-gated worker integration harness). diff --git a/services/api/package.json b/services/api/package.json index 0989e007..4c827f8a 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -25,7 +25,8 @@ "./concurrency": "./src/lib/concurrency.ts", "./time": "./src/lib/time.ts", "./capped-body": "./src/lib/capped-body.ts", - "./timeout": "./src/lib/timeout.ts" + "./timeout": "./src/lib/timeout.ts", + "./queue-names": "./src/lib/queue-names.ts" }, "scripts": { "build": "tsup", diff --git a/services/api/src/adapters/jobs.pgboss.ts b/services/api/src/adapters/jobs.pgboss.ts index 934d3294..9afe466b 100644 --- a/services/api/src/adapters/jobs.pgboss.ts +++ b/services/api/src/adapters/jobs.pgboss.ts @@ -1,20 +1,31 @@ import type PgBoss from "pg-boss" import type { Jobs, EnqueueOptions, JobHandler } from "@civfix/shared/interfaces" -import { REGISTRATION_QUEUE_NAMES } from "../services/host/registration-queues.js" -import { COMMS_QUEUE_NAMES } from "../services/host/broadcast-queues.js" import type { JobHandlerArgWithAttempt } from "../services/job-attempt.js" -import { MEDIA_CHECKS_JOB } from "../services/media-intake-service.js" -import { JURISDICTION_DISCOVERY_JOB } from "../services/jurisdiction-service.js" -import { OUTREACH_DIGEST_JOB } from "../services/admin/jurisdiction-contacts-types.js" -import { INBOUND_SWEEP_JOB } from "../services/admin/inbound-jobs.js" -import { REPORT_AUTOFORWARD_JOB } from "../services/report-service.types.js" -import { DATA_EXPORT_JOB } from "../services/data-export-jobs.js" -import { CLEANUP_CANCEL_FANOUT_JOB } from "../services/cleanup-notifications.js" import { + ANON_HOLD_RELEASE_JOB, + BROADCAST_CHUNK_JOB, + BROADCAST_PLAN_JOB, + BROADCAST_SCHEDULE_SWEEP_JOB, + CHAT_ROOM_FANOUT_JOB, + CHECKIN_NOSHOW_SWEEP_JOB, + CLEANUP_CANCEL_FANOUT_JOB, CLEANUP_GUEST_UPDATE_FANOUT_JOB, + DATA_EXPORT_JOB, + EVENT_METRICS_ROLLUP_JOB, + EVENT_REMINDERS_SWEEP_JOB, GUEST_RETENTION_SWEEP_JOB, -} from "../services/guest-rsvp-service.js" -import { CHAT_ROOM_FANOUT_JOB } from "../services/chat-fanout-jobs.js" + HOST_EXPORT_JOB, + HOST_EXPORT_REAP_JOB, + HOST_RETENTION_SWEEP_JOB, + INBOUND_SWEEP_JOB, + JURISDICTION_DISCOVERY_JOB, + MEDIA_CHECKS_JOB, + OUTREACH_DIGEST_JOB, + REPORT_AUTOFORWARD_JOB, + SHARED_QUEUE_POLICY, + WAITLIST_EXPIRE_SWEEP_JOB, + WAITLIST_PROMOTE_JOB, +} from "../lib/queue-names.js" import { SECONDS_PER_MINUTE } from "../lib/time.js" export interface PgBossJobsLogger { @@ -33,13 +44,6 @@ export interface PgBossJobsConfig { logger?: PgBossJobsLogger } -// Enqueued by the claim route and worked by the media worker, which owns the exported constant. -const ANON_HOLD_RELEASE_JOB = "anon.hold.release" - -// The media worker creates media.checks and anon.hold.release with this same policy. pg-boss keeps the -// last createQueue/updateQueue policy, so a mismatch silently breaks singletonKey dedup on those queues. -const SHARED_QUEUE_POLICY = "short" - export const API_QUEUE_NAMES = [ MEDIA_CHECKS_JOB, JURISDICTION_DISCOVERY_JOB, @@ -52,8 +56,17 @@ export const API_QUEUE_NAMES = [ CLEANUP_GUEST_UPDATE_FANOUT_JOB, GUEST_RETENTION_SWEEP_JOB, CHAT_ROOM_FANOUT_JOB, - ...REGISTRATION_QUEUE_NAMES, - ...COMMS_QUEUE_NAMES, + WAITLIST_PROMOTE_JOB, + WAITLIST_EXPIRE_SWEEP_JOB, + CHECKIN_NOSHOW_SWEEP_JOB, + BROADCAST_PLAN_JOB, + BROADCAST_CHUNK_JOB, + BROADCAST_SCHEDULE_SWEEP_JOB, + EVENT_REMINDERS_SWEEP_JOB, + EVENT_METRICS_ROLLUP_JOB, + HOST_EXPORT_JOB, + HOST_EXPORT_REAP_JOB, + HOST_RETENTION_SWEEP_JOB, ] as const type ApiQueueName = (typeof API_QUEUE_NAMES)[number] diff --git a/services/api/src/auth/auth-services.ts b/services/api/src/auth/auth-services.ts index 2f82ce30..541659c9 100644 --- a/services/api/src/auth/auth-services.ts +++ b/services/api/src/auth/auth-services.ts @@ -21,7 +21,7 @@ import { import { PgAuthStores } from "./pg-stores.js" import { REVIEWER_OTP_CODE_MIN_LENGTH } from "../env.js" import { resolveLocale } from "../i18n/locales.js" -import { writeAudit } from "../services/admin/audit.js" +import { insertAuditRow } from "../services/admin/audit-repository.drizzle.js" import { dataExportSupportEmail } from "../services/data-export-jobs.js" export interface AuthServices { @@ -110,7 +110,7 @@ export function buildAuthServicesFromContainer( oauthConfig: oauthConfigFromEnv(container.env), supportEmail: dataExportSupportEmail(container.env), audit: async (input) => { - await writeAudit(container.getDb().sql, input) + await insertAuditRow(container.getDb().sql, input) }, ...(opts.logger ? { logger: opts.logger } : {}), ...(reviewerConfig !== null ? { reviewer: reviewerConfig } : {}), diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index d197137c..b948c3f5 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -23,11 +23,11 @@ import { } from "@civfix/shared" import type { Jobs } from "@civfix/shared/interfaces" import { decideHandleWrite, handleChanged } from "./handle-policy.js" +import { avatarMediaRefOrThrow } from "../services/avatar-media.js" import { avatarClaimQuery, - avatarMediaRefOrThrow, type AvatarMediaRow, -} from "../services/avatar-media.js" +} from "../services/media-claim-repository.drizzle.js" import { userUploader } from "../services/media-uploader.js" import { enqueueWaitlistPromotion } from "../services/host/waitlist-promotion.js" import { diff --git a/services/api/src/auth/reserved-handles.ts b/services/api/src/auth/reserved-handles.ts index 236331e4..cebb47dc 100644 --- a/services/api/src/auth/reserved-handles.ts +++ b/services/api/src/auth/reserved-handles.ts @@ -7,9 +7,7 @@ * report discussions. */ -import type { Sql } from "../db/client.js" import { TOMBSTONE_HANDLE_RE } from "./stores.js" -import { makeDrizzleJurisdictionRepository } from "../services/jurisdiction-repository.drizzle.js" const RESERVED_HANDLES: readonly string[] = [ "admin", @@ -45,7 +43,3 @@ export function isReservedHandle(handle: string): boolean { const h = handle.trim().toLowerCase() return RESERVED_SET.has(h) || TOMBSTONE_HANDLE_RE.test(h) } - -export function handleCollidesWithJurisdiction(sql: Sql, handle: string): Promise { - return makeDrizzleJurisdictionRepository(sql).handleExists(handle) -} diff --git a/services/api/src/services/admin/like.ts b/services/api/src/db/like.ts similarity index 100% rename from services/api/src/services/admin/like.ts rename to services/api/src/db/like.ts diff --git a/services/api/src/db/pg-errors.ts b/services/api/src/db/pg-errors.ts index 94d17b8a..e222e680 100644 --- a/services/api/src/db/pg-errors.ts +++ b/services/api/src/db/pg-errors.ts @@ -1,5 +1,19 @@ export const PG_UNIQUE_VIOLATION = "23505" +export const PG_CHECK_VIOLATION = "23514" + +export const PG_SERIALIZATION_FAILURE = "40001" + +export const PG_DEADLOCK_DETECTED = "40P01" + +export const PG_UNDEFINED_TABLE = "42P01" + +export const PG_INVALID_AUTHORIZATION = "28000" + +export const PG_INVALID_PASSWORD = "28P01" + +export const PG_INVALID_CATALOG_NAME = "3D000" + export function isUniqueViolation(err: unknown): boolean { return ( typeof err === "object" && @@ -7,3 +21,29 @@ export function isUniqueViolation(err: unknown): boolean { (err as { code?: unknown }).code === PG_UNIQUE_VIOLATION ) } + +interface PgErrorShape { + code?: unknown + constraint_name?: unknown +} + +function pgErrorConstraint(err: unknown): { code: string; constraint: string } | null { + if (typeof err !== "object" || err === null) return null + const e = err as PgErrorShape + if (typeof e.code !== "string") return null + return { + code: e.code, + constraint: typeof e.constraint_name === "string" ? e.constraint_name : "", + } +} + +export function isUniqueViolationOn(err: unknown, ...constraints: string[]): boolean { + const parsed = pgErrorConstraint(err) + if (parsed === null || parsed.code !== PG_UNIQUE_VIOLATION) return false + return constraints.length === 0 || constraints.includes(parsed.constraint) +} + +export function isCheckViolationOn(err: unknown, constraint: string): boolean { + const parsed = pgErrorConstraint(err) + return parsed !== null && parsed.code === PG_CHECK_VIOLATION && parsed.constraint === constraint +} diff --git a/services/api/src/db/schema/gov_claims.ts b/services/api/src/db/schema/gov_claims.ts index 26d636ed..2f2b1002 100644 --- a/services/api/src/db/schema/gov_claims.ts +++ b/services/api/src/db/schema/gov_claims.ts @@ -1,6 +1,6 @@ /** * Government provisioning queue: approving a claim sets the linked user's role to gov_admin and links the - * jurisdiction, so every transition is audited via writeAudit. + * jurisdiction, so every transition is audited via insertAuditRow. * * `checks` jsonb shape: { linkedin|directory|callback: { status:'verified'|'pending', * evidence?:string, note?:string, at?:timestamp } }. diff --git a/services/api/src/lib/queue-names.ts b/services/api/src/lib/queue-names.ts new file mode 100644 index 00000000..59c0f58e --- /dev/null +++ b/services/api/src/lib/queue-names.ts @@ -0,0 +1,35 @@ +export const MEDIA_CHECKS_JOB = "media.checks" +export const ANON_HOLD_RELEASE_JOB = "anon.hold.release" + +// The API and the media worker both create media.checks and anon.hold.release with this policy. pg-boss +// keeps the last createQueue/updateQueue policy, so a mismatch silently breaks singletonKey dedup on +// those queues. +export const SHARED_QUEUE_POLICY = "short" + +export const JURISDICTION_DISCOVERY_JOB = "jurisdiction.discovery" +export const OUTREACH_DIGEST_JOB = "outreach.digest" +export const INBOUND_SWEEP_JOB = "inbound.sweep" +export const REPORT_AUTOFORWARD_JOB = "report.autoforward" +export const DATA_EXPORT_JOB = "data.export" +export const CLEANUP_CANCEL_FANOUT_JOB = "cleanup.cancel.fanout" +export const CLEANUP_GUEST_UPDATE_FANOUT_JOB = "cleanup.guest.update.fanout" +export const GUEST_RETENTION_SWEEP_JOB = "guest.retention.sweep" +export const CHAT_ROOM_FANOUT_JOB = "chat.room.fanout" +export const WAITLIST_PROMOTE_JOB = "waitlist.promote" +export const WAITLIST_EXPIRE_SWEEP_JOB = "waitlist.expire.sweep" +export const CHECKIN_NOSHOW_SWEEP_JOB = "checkin.noshow.sweep" +export const BROADCAST_PLAN_JOB = "broadcast.plan" +export const BROADCAST_CHUNK_JOB = "broadcast.chunk" +export const BROADCAST_SCHEDULE_SWEEP_JOB = "broadcast.schedule.sweep" +export const EVENT_REMINDERS_SWEEP_JOB = "event.reminders.sweep" +export const EVENT_METRICS_ROLLUP_JOB = "event.metrics.rollup" +export const HOST_EXPORT_JOB = "host.export" +export const HOST_EXPORT_REAP_JOB = "host.export.reap" +export const HOST_RETENTION_SWEEP_JOB = "host.retention.sweep" + +export const ORPHAN_SWEEP_JOB = "orphan.sweep" +export const CHAT_PARTITION_JOB = "chat.partition.maintenance" +export const ANON_HOLD_RELEASE_SWEEP_JOB = "anon.hold.release.sweep" +export const RETENTION_SWEEP_JOB = "retention.sweep" +export const MEDIA_STUCK_SWEEP_JOB = "media.stuck.sweep" +export const MEDIA_UPLOAD_REAP_JOB = "media.upload.reap" diff --git a/services/api/src/routes/_validate.ts b/services/api/src/routes/_validate.ts index 76e6ea77..bf6020e2 100644 --- a/services/api/src/routes/_validate.ts +++ b/services/api/src/routes/_validate.ts @@ -1,4 +1,5 @@ import { AppError } from "@civfix/shared" +import type { FastifyRequest } from "fastify" import { z, type ZodTypeAny } from "zod" // Field key for an issue on the value itself (a non-object body, a refine on the whole schema). @@ -49,3 +50,17 @@ export function trimTextFields( return trimmed }, schema) } + +// Path params spread last: the URL path is authoritative, so a body or query field can never address a +// different row than the path names. +export function paramsOverBody(request: FastifyRequest): Record { + const params = (request.params ?? {}) as Record + const body = (request.body ?? {}) as Record + return { ...body, ...params } +} + +export function paramsOverQuery(request: FastifyRequest): Record { + const params = (request.params ?? {}) as Record + const query = (request.query ?? {}) as Record + return { ...query, ...params } +} diff --git a/services/api/src/routes/admin/auth.routes.ts b/services/api/src/routes/admin/auth.routes.ts index 282e9ca4..068e703c 100644 --- a/services/api/src/routes/admin/auth.routes.ts +++ b/services/api/src/routes/admin/auth.routes.ts @@ -21,7 +21,8 @@ import { type AccessIdentity, type VerifyAccessJwt, } from "../../auth/cf-access.js" -import { writeAudit, type WriteAuditInput } from "../../services/admin/audit.js" +import type { WriteAuditInput } from "../../services/admin/audit.js" +import { insertAuditRow } from "../../services/admin/audit-repository.drizzle.js" import { generateCsrfToken, setCsrfCookie, clearCsrfCookie, type Csrf } from "../../auth/csrf.js" import { presentedSessionToken, @@ -195,7 +196,7 @@ async function auditOperatorAuth( await overrides.auditSink(input) return } - await writeAudit(container.getDb().sql, input) + await insertAuditRow(container.getDb().sql, input) } function restrictedAccountRefusal(accountStatus: AccountStatus): AppError | null { diff --git a/services/api/src/routes/admin/broadcasts.routes.ts b/services/api/src/routes/admin/broadcasts.routes.ts index 4998b21f..fd76981d 100644 --- a/services/api/src/routes/admin/broadcasts.routes.ts +++ b/services/api/src/routes/admin/broadcasts.routes.ts @@ -7,11 +7,11 @@ import { type AdminHostListResponse, type SetHostMessagingSuspendedResponse, } from "@civfix/shared" -import type { FastifyInstance, FastifyRequest } from "fastify" +import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { route } from "../../versioning/route.js" -import { parse } from "../_validate.js" +import { parse, paramsOverBody } from "../_validate.js" import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import { makeDrizzleBroadcastRepository } from "../../services/host/broadcast-repository.drizzle.js" import type { BroadcastRepository } from "../../services/host/broadcast-repository.js" @@ -41,12 +41,6 @@ function hashSubject(subject: string): string { return sha256HexSync(subject).slice(0, SUBJECT_HASH_HEX_LENGTH) } -function mergeParams(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const body = (request.body ?? {}) as Record - return { ...body, ...params } -} - export async function registerAdminBroadcastRoutes( app: FastifyInstance, container: Container, @@ -152,7 +146,7 @@ export async function registerAdminBroadcastRoutes( { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireAuth(request) - const body = parse(SetHostMessagingSuspendedRequestSchema, mergeParams(request)) + const body = parse(SetHostMessagingSuspendedRequestSchema, paramsOverBody(request)) const found = await broadcastRepo().setHostMessagingSuspended(body.id, body.suspended, { action: body.suspended ? "host.messaging_suspended" : "host.messaging_restored", actorId: operatorId, diff --git a/services/api/src/routes/admin/discovery.routes.ts b/services/api/src/routes/admin/discovery.routes.ts index c50c626f..c44a9784 100644 --- a/services/api/src/routes/admin/discovery.routes.ts +++ b/services/api/src/routes/admin/discovery.routes.ts @@ -76,7 +76,7 @@ export async function registerAdminDiscoveryRoutes( const actorId = requireOperator(request) const { id, body } = parseBodyWithId(AddNoteRequestSchema, request) const who = await operatorLabel(app, actorId) - // The note IS its audit_log row, so there is no separate writeAudit here. + // The note IS its audit_log row, so there is no separate insertAuditRow here. await service().addNote(id, { text: body.text, actorId, who }) sendOk(reply) }) diff --git a/services/api/src/routes/admin/orgs.routes.ts b/services/api/src/routes/admin/orgs.routes.ts index 3807a4c3..1d5a4b70 100644 --- a/services/api/src/routes/admin/orgs.routes.ts +++ b/services/api/src/routes/admin/orgs.routes.ts @@ -66,7 +66,7 @@ export async function registerAdminOrgRoutes( // Every org-management mutation writes its operator audit, with the mandatory `reason` in its meta, inside // the repository transaction, so the audit row and the state change commit or roll back together. The - // routes add no second writeAudit. + // routes add no second insertAuditRow. route(app, "adminListOrgs", async (request, reply) => { const operatorId = requireOperator(request) @@ -207,9 +207,9 @@ export async function registerAdminOrgRoutes( reply.status(200).send(payload) }) - // Unlike broadcasts/pages, which call writeAudit in the route after the effect, this decision is audited - // inside the repository transaction (decideVerificationTx), so the audit row and the state change commit - // or roll back together. A route-level writeAudit here would double-write the row. + // Unlike broadcasts/pages, which call insertAuditRow in the route after the effect, this decision is + // audited inside the repository transaction (decideVerificationTx), so the audit row and the state change + // commit or roll back together. A route-level insertAuditRow here would double-write the row. route(app, "adminDecideOrgVerification", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireOperator(request) const { id, body } = parseBodyWithId(DecideOrgVerificationRequestSchema, request) diff --git a/services/api/src/routes/admin/pages.routes.ts b/services/api/src/routes/admin/pages.routes.ts index 3853e9ab..489260e3 100644 --- a/services/api/src/routes/admin/pages.routes.ts +++ b/services/api/src/routes/admin/pages.routes.ts @@ -8,12 +8,13 @@ import { type AdminEventPageListResponse, type AdminGetEventPageResponse, } from "@civfix/shared" -import type { FastifyInstance, FastifyRequest } from "fastify" +import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { route } from "../../versioning/route.js" -import { parse } from "../_validate.js" -import { writeAudit, type WriteAuditInput } from "../../services/admin/audit.js" +import { parse, paramsOverBody } from "../_validate.js" +import type { WriteAuditInput } from "../../services/admin/audit.js" +import { insertAuditRow } from "../../services/admin/audit-repository.drizzle.js" import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" import { makeDrizzleAdminEventPageRepository, @@ -39,12 +40,6 @@ declare module "fastify" { } } -function mergeParams(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const body = (request.body ?? {}) as Record - return { ...body, ...params } -} - function toDTO(row: AdminEventPageRow): AdminEventPageListItemDTO { return { cleanupId: row.cleanupId, @@ -98,7 +93,7 @@ export async function registerAdminEventPageRoutes( return container .getDb() .sql.begin((tx) => - fn(makeDrizzleAdminEventPageRepository(tx), (entry) => writeAudit(tx, entry)), + fn(makeDrizzleAdminEventPageRepository(tx), (entry) => insertAuditRow(tx, entry)), ) as Promise } @@ -136,7 +131,7 @@ export async function registerAdminEventPageRoutes( route(app, "adminFlagEventPage", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireAuth(request) - const body = parse(FlagEventPageRequestSchema, mergeParams(request)) + const body = parse(FlagEventPageRequestSchema, paramsOverBody(request)) const row = await moderate(async (pages, recordAudit) => { const flagged = await pages.setFlagged(body.id, { flagged: body.flagged, @@ -158,7 +153,7 @@ export async function registerAdminEventPageRoutes( route(app, "adminUnpublishEventPage", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireAuth(request) - const body = parse(UnpublishEventPageRequestSchema, mergeParams(request)) + const body = parse(UnpublishEventPageRequestSchema, paramsOverBody(request)) const row = await moderate(async (pages, recordAudit) => { const unpublished = await pages.unpublish(body.id) if (unpublished === null) return null diff --git a/services/api/src/routes/auth.routes.ts b/services/api/src/routes/auth.routes.ts index 0738a0d8..124ec635 100644 --- a/services/api/src/routes/auth.routes.ts +++ b/services/api/src/routes/auth.routes.ts @@ -28,7 +28,8 @@ import { SUSPENDED_MESSAGE } from "../auth/account-status.js" import { makeWsTicketStore } from "../auth/ws-ticket.js" import { sha256Hex } from "../auth/crypto.js" import { assertNoSlur } from "../abuse/slur-filter.js" -import { isReservedHandle, handleCollidesWithJurisdiction } from "../auth/reserved-handles.js" +import { isReservedHandle } from "../auth/reserved-handles.js" +import { makeDrizzleJurisdictionRepository } from "../services/jurisdiction-repository.drizzle.js" import { handleChanged } from "../auth/handle-policy.js" import { impersonatesOfficialName } from "../auth/official-account.js" import { isProd } from "../env.js" @@ -311,7 +312,7 @@ function registerProfileRoutes(app: FastifyInstance, ctx: AuthRouteContext): voi async function isReservedOrJurisdiction(handle: string): Promise { if (isReservedHandle(handle)) return true if (!container.env.DATABASE_URL) return false - return handleCollidesWithJurisdiction(container.getDb().sql, handle) + return makeDrizzleJurisdictionRepository(container.getDb().sql).handleExists(handle) } async function handleAvailability( diff --git a/services/api/src/routes/claim.routes.ts b/services/api/src/routes/claim.routes.ts index 772a946b..53f30d68 100644 --- a/services/api/src/routes/claim.routes.ts +++ b/services/api/src/routes/claim.routes.ts @@ -16,11 +16,10 @@ import { makeDrizzleClaimRepository } from "../services/anon-repository.drizzle. import { makeDrizzleReportRepository } from "../services/report-repository.drizzle.js" import { makeReportService, type ReportService } from "../services/report-service.js" import { makeMediaPresigner, makePrivateMediaPresigner } from "../services/media-presign.js" +import { ANON_HOLD_RELEASE_JOB } from "../lib/queue-names.js" import { route } from "../versioning/route.js" import { parse } from "./_validate.js" -const ANON_HOLD_RELEASE_JOB = "anon.hold.release" - export interface ClaimServiceOverride { service: ClaimService } diff --git a/services/api/src/routes/host/analytics.routes.ts b/services/api/src/routes/host/analytics.routes.ts index 562f379c..7c91dc30 100644 --- a/services/api/src/routes/host/analytics.routes.ts +++ b/services/api/src/routes/host/analytics.routes.ts @@ -15,7 +15,7 @@ import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" -import { parse } from "../_validate.js" +import { parse, paramsOverQuery } from "../_validate.js" import { hostForbiddenCopy, requireCapability, @@ -47,12 +47,6 @@ declare module "fastify" { } } -function mergeQuery(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const query = (request.query ?? {}) as Record - return { ...query, ...params } -} - function viewerScopeOf(standing: HostStanding): string { return `${standing.eventRole ?? NO_ROLE}:${standing.orgRole ?? NO_ROLE}` } @@ -93,7 +87,7 @@ export async function registerHostAnalyticsRoutes( const { query, viewerScope } = await authorizeEventView( request, EventAnalyticsRequestSchema, - mergeQuery(request), + paramsOverQuery(request), ) return { cleanupId: query.id, range: query.range ?? DEFAULT_EVENT_RANGE, viewerScope } } @@ -117,7 +111,7 @@ export async function registerHostAnalyticsRoutes( const { userId, query, resolution, viewerScope } = await authorizeEventView( request, GetEventAnalyticsRequestSchema, - mergeQuery(request), + paramsOverQuery(request), ) reply.status(200).send( await eventAnalytics().analytics(query.id, query.scope ?? DEFAULT_EVENT_ANALYTICS_SCOPE, { diff --git a/services/api/src/routes/host/announcements.routes.ts b/services/api/src/routes/host/announcements.routes.ts index d0211eb0..473f2732 100644 --- a/services/api/src/routes/host/announcements.routes.ts +++ b/services/api/src/routes/host/announcements.routes.ts @@ -6,12 +6,12 @@ import { type ListEventAnnouncementsResponse, } from "@civfix/shared" import { can } from "@civfix/shared/host" -import type { FastifyInstance, FastifyRequest } from "fastify" +import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" -import { parse, trimTextFields } from "../_validate.js" +import { parse, paramsOverBody, paramsOverQuery, trimTextFields } from "../_validate.js" import { requireCapability, resolveVisibleStanding } from "../../services/host/authz.js" import { withCaps } from "../../services/host/broadcast-service.js" import { auditBestEffort, makeCommsRuntime } from "../../services/host/comms-wiring.js" @@ -24,18 +24,6 @@ import type { export const ANNOUNCEMENT_CREATE_RATE_LIMIT = perIdentity({ max: 10, timeWindow: "1 minute" }) export const ANNOUNCEMENT_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -function mergeParams(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const body = (request.body ?? {}) as Record - return { ...body, ...params } -} - -function mergeQuery(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const query = (request.query ?? {}) as Record - return { ...query, ...params } -} - export async function registerHostAnnouncementRoutes( app: FastifyInstance, container: Container, @@ -67,7 +55,7 @@ export async function registerHostAnnouncementRoutes( const userId = requireAuth(request) const body = parse( trimTextFields(CreateEventAnnouncementRequestSchema, "title", "bodyMd"), - mergeParams(request), + paramsOverBody(request), ) await requireCapability(container.getDb().sql, body.id, userId, "broadcast") const payload = await withCaps(() => service().create(body.id, userId, body)) @@ -91,7 +79,7 @@ export async function registerHostAnnouncementRoutes( { config: { rateLimit: ANNOUNCEMENT_READ_RATE_LIMIT } }, async (request, reply) => { const userId = request.auth?.userId ?? null - const query = parse(ListEventAnnouncementsRequestSchema, mergeQuery(request)) + const query = parse(ListEventAnnouncementsRequestSchema, paramsOverQuery(request)) const projection = await projectionFor(query.id, userId) const payload: ListEventAnnouncementsResponse = await service().list( query.id, @@ -108,7 +96,7 @@ export async function registerHostAnnouncementRoutes( { config: { rateLimit: ANNOUNCEMENT_READ_RATE_LIMIT } }, async (request, reply) => { const userId = request.auth?.userId ?? null - const params = parse(GetEventAnnouncementRequestSchema, mergeParams(request)) + const params = parse(GetEventAnnouncementRequestSchema, paramsOverBody(request)) const projection = await projectionFor(params.id, userId) const payload: AnnouncementDTO = await service().get( params.id, diff --git a/services/api/src/routes/host/broadcasts.routes.ts b/services/api/src/routes/host/broadcasts.routes.ts index aa8d4535..b1e42d0a 100644 --- a/services/api/src/routes/host/broadcasts.routes.ts +++ b/services/api/src/routes/host/broadcasts.routes.ts @@ -17,12 +17,12 @@ import { type ListEventBroadcastsResponse, type SetEventBroadcastMuteResponse, } from "@civfix/shared" -import type { FastifyInstance, FastifyRequest } from "fastify" +import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" -import { parse } from "../_validate.js" +import { parse, paramsOverBody, paramsOverQuery } from "../_validate.js" import { requireCapability, resolveVisibleStanding } from "../../services/host/authz.js" import { emailHashOf, @@ -46,18 +46,6 @@ declare module "fastify" { } } -function mergeParams(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const body = (request.body ?? {}) as Record - return { ...body, ...params } -} - -function mergeQuery(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const query = (request.query ?? {}) as Record - return { ...query, ...params } -} - export async function registerHostBroadcastRoutes( app: FastifyInstance, container: Container, @@ -81,7 +69,7 @@ export async function registerHostBroadcastRoutes( { config: { rateLimit: BROADCAST_READ_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const query = parse(ListEventBroadcastsRequestSchema, mergeQuery(request)) + const query = parse(ListEventBroadcastsRequestSchema, paramsOverQuery(request)) await requireCapability(container.getDb().sql, query.id, userId, "broadcast") const payload: ListEventBroadcastsResponse = await service().list(query.id, query) reply.status(200).send(payload) @@ -94,7 +82,7 @@ export async function registerHostBroadcastRoutes( { config: { rateLimit: BROADCAST_READ_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const params = parse(GetEventBroadcastRequestSchema, mergeParams(request)) + const params = parse(GetEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, params.id, userId, "broadcast") const payload: BroadcastDTO = await service().get(params.id, params.broadcastId) reply.status(200).send(payload) @@ -107,7 +95,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_WRITE_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const body = parse(CreateEventBroadcastRequestSchema, mergeParams(request)) + const body = parse(CreateEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, body.id, userId, "broadcast") const payload = await withCaps(() => service().create(body.id, userId, body)) reply.status(200).send(payload) @@ -120,7 +108,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_WRITE_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const body = parse(UpdateEventBroadcastRequestSchema, mergeParams(request)) + const body = parse(UpdateEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, body.id, userId, "broadcast") const payload = await withCaps(() => service().update(body.id, userId, body)) reply.status(200).send(payload) @@ -133,7 +121,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_WRITE_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const params = parse(DeleteEventBroadcastRequestSchema, mergeParams(request)) + const params = parse(DeleteEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, params.id, userId, "broadcast") reply.status(200).send(await service().remove(params.id, params.broadcastId)) }, @@ -145,7 +133,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_WRITE_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const body = parse(PreviewEventBroadcastRequestSchema, mergeParams(request)) + const body = parse(PreviewEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, body.id, userId, "broadcast") const payload: BroadcastPreviewDTO = await withCaps(() => service().preview(body.id, userId, body), @@ -160,7 +148,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_SEND_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const params = parse(TestSendEventBroadcastRequestSchema, mergeParams(request)) + const params = parse(TestSendEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, params.id, userId, "broadcast") const payload = await withCaps(() => service().testSend(params.id, userId, params.broadcastId), @@ -185,7 +173,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_SEND_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const params = parse(SendEventBroadcastRequestSchema, mergeParams(request)) + const params = parse(SendEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, params.id, userId, "broadcast") const payload = await withCaps(() => service().send(params.id, userId, params.broadcastId)) await auditBestEffort( @@ -216,7 +204,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_SEND_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const body = parse(ScheduleEventBroadcastRequestSchema, mergeParams(request)) + const body = parse(ScheduleEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, body.id, userId, "broadcast") const payload = await withCaps(() => service().schedule(body.id, userId, body.broadcastId, new Date(body.scheduledAt)), @@ -231,7 +219,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_SEND_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const params = parse(CancelEventBroadcastRequestSchema, mergeParams(request)) + const params = parse(CancelEventBroadcastRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, params.id, userId, "broadcast") reply.status(200).send(await service().cancel(params.id, params.broadcastId)) }, @@ -243,7 +231,7 @@ export async function registerHostBroadcastRoutes( { config: { rateLimit: BROADCAST_READ_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const query = parse(ListBroadcastDeliveriesRequestSchema, mergeQuery(request)) + const query = parse(ListBroadcastDeliveriesRequestSchema, paramsOverQuery(request)) await requireCapability(container.getDb().sql, query.id, userId, "broadcast") const payload: ListBroadcastDeliveriesResponse = await service().listDeliveries( query.id, @@ -259,7 +247,7 @@ export async function registerHostBroadcastRoutes( { preHandler: csrfProtect, config: { rateLimit: BROADCAST_WRITE_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const body = parse(SetEventBroadcastMuteRequestSchema, mergeParams(request)) + const body = parse(SetEventBroadcastMuteRequestSchema, paramsOverBody(request)) await resolveVisibleStanding(container.getDb().sql, body.id, userId) const payload: SetEventBroadcastMuteResponse = await service().setMute( body.id, diff --git a/services/api/src/routes/host/exports.routes.ts b/services/api/src/routes/host/exports.routes.ts index 056b5fb5..ea7ec1da 100644 --- a/services/api/src/routes/host/exports.routes.ts +++ b/services/api/src/routes/host/exports.routes.ts @@ -8,14 +8,14 @@ import { type ListEventExportsResponse, } from "@civfix/shared" import { AppError } from "@civfix/shared" -import type { FastifyInstance, FastifyRequest } from "fastify" +import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" -import { parse } from "../_validate.js" +import { parse, paramsOverBody, paramsOverQuery } from "../_validate.js" import { requireCapability } from "../../services/host/authz.js" -import { HOST_EXPORT_JOB } from "../../services/host/broadcast-queues.js" +import { HOST_EXPORT_JOB } from "../../lib/queue-names.js" import { makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" import { @@ -41,18 +41,6 @@ declare module "fastify" { } } -function mergeParams(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const body = (request.body ?? {}) as Record - return { ...body, ...params } -} - -function mergeQuery(request: FastifyRequest): Record { - const params = (request.params ?? {}) as Record - const query = (request.query ?? {}) as Record - return { ...query, ...params } -} - export async function registerHostExportRoutes( app: FastifyInstance, container: Container, @@ -72,7 +60,7 @@ export async function registerHostExportRoutes( { preHandler: csrfProtect, config: { rateLimit: EXPORT_REQUEST_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const body = parse(RequestEventExportRequestSchema, mergeParams(request)) + const body = parse(RequestEventExportRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, body.id, userId, "export") const payload: HostExportDTO = await exports().request({ cleanupId: body.id, @@ -102,7 +90,7 @@ export async function registerHostExportRoutes( { config: { rateLimit: EXPORT_READ_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const query = parse(ListEventExportsRequestSchema, mergeQuery(request)) + const query = parse(ListEventExportsRequestSchema, paramsOverQuery(request)) await requireCapability(container.getDb().sql, query.id, userId, "export") const items = await exports().listForEvent(query.id) const payload: ListEventExportsResponse = { items, nextCursor: null } @@ -116,7 +104,7 @@ export async function registerHostExportRoutes( { config: { rateLimit: EXPORT_READ_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const params = parse(GetEventExportRequestSchema, mergeParams(request)) + const params = parse(GetEventExportRequestSchema, paramsOverBody(request)) await requireCapability(container.getDb().sql, params.id, userId, "export") const record = await exports().get(params.exportId) if (record.cleanupId !== params.id) throw AppError.notFound(EXPORT_NOT_FOUND) diff --git a/services/api/src/routes/map.routes.ts b/services/api/src/routes/map.routes.ts index 11c85fc8..2cadf6d8 100644 --- a/services/api/src/routes/map.routes.ts +++ b/services/api/src/routes/map.routes.ts @@ -23,7 +23,7 @@ import { makeRouteJurisdictionService, } from "../services/route-geo-helpers.js" import { makeCleanupMapRepository, MAP_CLEANUPS_LIMIT } from "../services/cleanup-map-repository.js" -import { writeAudit } from "../services/admin/audit.js" +import { insertAuditRow } from "../services/admin/audit-repository.drizzle.js" import { CappedBBoxQueryParam } from "./query-encoding.js" import { parse, trimTextFields } from "./_validate.js" import { route } from "../versioning/route.js" @@ -224,7 +224,7 @@ export async function registerMapRoutes(app: FastifyInstance, container: Contain throw AppError.notFound("Jurisdiction not found") } - await writeAudit(container.getDb().sql, { + await insertAuditRow(container.getDb().sql, { actorId: null, action: CONTACT_SUGGESTED_AUDIT_ACTION, target: `jurisdiction:${geoid}`, diff --git a/services/api/src/routes/report-content.routes.ts b/services/api/src/routes/report-content.routes.ts index df3418d8..973f9b4b 100644 --- a/services/api/src/routes/report-content.routes.ts +++ b/services/api/src/routes/report-content.routes.ts @@ -5,7 +5,7 @@ import { requireAuth } from "../auth/context.js" import { route } from "../versioning/route.js" import { parse } from "./_validate.js" import { perIdentity } from "../plugins/rate-limit.js" -import { writeAudit } from "../services/admin/audit.js" +import { insertAuditRow } from "../services/admin/audit-repository.drizzle.js" import { makeModerationService, type ModerationService, @@ -92,7 +92,7 @@ export async function registerReportContentRoutes( }) if (isOwnerTakedown) { - await writeAudit(container.getDb().sql, { + await insertAuditRow(container.getDb().sql, { actorId: userId, action: TAKEDOWN_REQUESTED_AUDIT_ACTION, target: `report:${body.subjectId}`, diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index 34507ee6..b6ea5da4 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -21,11 +21,12 @@ import { requireAuth } from "../auth/context.js" import { isOfficialAccount } from "../auth/official-account.js" import { clearCsrfCookie } from "../auth/csrf.js" import { clearSessionCookie } from "../auth/transport.js" -import { searchByHandlePrefix, searchMentionable } from "../services/social-repository.drizzle.js" +import { makeDrizzleUserSearchRepository } from "../services/user-search-repository.drizzle.js" import { toUserDTO, type AuthServices } from "../auth/auth-services.js" import { exposeMessage } from "../errors/exposed-message.js" -import { writeAudit } from "../services/admin/audit.js" -import { DATA_EXPORT_JOB, dataExportSupportEmail } from "../services/data-export-jobs.js" +import { insertAuditRow } from "../services/admin/audit-repository.drizzle.js" +import { dataExportSupportEmail } from "../services/data-export-jobs.js" +import { DATA_EXPORT_JOB } from "../lib/queue-names.js" import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" import { dropContainerSuggestions } from "../services/social-suggestions-wiring.js" import { route } from "../versioning/route.js" @@ -87,7 +88,11 @@ export async function registerUsersRoutes( const results = term.length === 0 ? [] - : await searchByHandlePrefix(container.getDb().sql, term, userId, limit) + : await makeDrizzleUserSearchRepository(container.getDb().sql).searchByHandlePrefix( + term, + userId, + limit, + ) const payload: SearchUsersResponse = { results } reply.status(200).send(payload) }, @@ -104,7 +109,11 @@ export async function registerUsersRoutes( const results = term.length === 0 ? [] - : await searchMentionable(container.getDb().sql, term, userId, USER_SEARCH_DEFAULT_LIMIT) + : await makeDrizzleUserSearchRepository(container.getDb().sql).searchMentionable( + term, + userId, + USER_SEARCH_DEFAULT_LIMIT, + ) const payload: SearchUsersResponse = { results } reply.status(200).send(payload) }, @@ -228,7 +237,7 @@ export async function registerUsersRoutes( [ "audit.account-deleted", () => - writeAudit(container.getDb().sql, { + insertAuditRow(container.getDb().sql, { actorId: userId, action: ACCOUNT_DELETED_AUDIT_ACTION, target: `user:${userId}`, diff --git a/services/api/src/server.ts b/services/api/src/server.ts index 02a8969f..35bfbd28 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -45,7 +45,8 @@ import type { AdminBroadcastOverrides } from "./routes/admin/broadcasts.routes.j import type { ContentSubjectGate } from "./services/content-report-subject.js" import { registerRoutes } from "./routes/index.js" import { registerOutreachJobs } from "./services/admin/outreach-jobs.js" -import { registerInboundJobs, INBOUND_SWEEP_JOB } from "./services/admin/inbound-jobs.js" +import { registerInboundJobs } from "./services/admin/inbound-jobs.js" +import { INBOUND_SWEEP_JOB } from "./lib/queue-names.js" import { registerDiscoveryJobs } from "./services/admin/discovery-jobs.js" import { registerAutoForwardJobs } from "./services/admin/autoforward-jobs.js" import { registerDataExportJobs } from "./services/data-export-jobs.js" diff --git a/services/api/src/services/admin/activity-repository.drizzle.ts b/services/api/src/services/admin/activity-repository.drizzle.ts index cdd7cdd0..20bb49f1 100644 --- a/services/api/src/services/admin/activity-repository.drizzle.ts +++ b/services/api/src/services/admin/activity-repository.drizzle.ts @@ -30,7 +30,7 @@ import { type KeysetCursor, } from "../../db/cursor-helpers.js" import { AUDIT_READ_ACTIONS } from "./audit.js" -import { likePrefix } from "./like.js" +import { likePrefix } from "../../db/like.js" import { anyOf, ilikeAnyOf } from "./sql-fragments.js" import { AUDIT_ACTION_RULES, diff --git a/services/api/src/services/admin/admin-event-repository.drizzle.ts b/services/api/src/services/admin/admin-event-repository.drizzle.ts index 5d5598a3..533f22cb 100644 --- a/services/api/src/services/admin/admin-event-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-event-repository.drizzle.ts @@ -7,7 +7,7 @@ import { paginateKeyset, parseKeysetCursor, } from "../../db/cursor-helpers.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { adminEventStatusExpr } from "../cleanup-sql.js" import { personSelect } from "./admin-person-sql.js" import { toPersonRecord } from "./admin-person.js" @@ -267,7 +267,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository INSERT INTO cleanup_timeline (cleanup_id, kind, note, actor_id) VALUES (${id}, 'outcome', ${eventOutcomeNote(input.bags)}, ${input.actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "event.outcome_logged", target: `cleanup:${id}`, @@ -303,7 +303,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository ${input.actorId} ) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: nowFlagged ? "event.flagged" : "event.unflagged", target: `cleanup:${id}`, @@ -330,7 +330,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository INSERT INTO cleanup_timeline (cleanup_id, kind, note, actor_id) VALUES (${id}, 'cancel', ${input.note}, ${input.actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "event.cancelled", target: `cleanup:${id}`, @@ -363,7 +363,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository WHERE cm.cleanup_id = ${id} RETURNING user_id ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "event.message_posted", target: `cleanup:${id}`, @@ -454,7 +454,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository FROM unnest(${linked}::uuid[]) AS rid ` } - await writeAudit(tx, { + await insertAuditRow(tx, { actorId, action: "event.reports_linked", target: `cleanup:${id}`, @@ -482,7 +482,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository INSERT INTO cleanup_timeline (cleanup_id, kind, note, actor_id) VALUES (${id}, 'report_unlinked', ${`Unlinked report ${reportId}`}, ${actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId, action: "event.report_unlinked", target: `cleanup:${id}`, diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/src/services/admin/admin-event-repository.memory.ts index 07e1feba..0fdd5b55 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -66,7 +66,7 @@ export interface RecordedMemberNotification { link: string | null } -/** Mirrors the Drizzle repo's in-transaction writeAudit. */ +/** Mirrors the Drizzle repo's in-transaction insertAuditRow. */ export interface RecordedEventAudit { action: string target: string diff --git a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts index 9b161f9c..0d611171 100644 --- a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts @@ -1,6 +1,6 @@ import type { RoomKind } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" export interface RemoveReportMessageInput { reason: string | null @@ -60,7 +60,7 @@ export function makeDrizzleAdminReportChatRepository(sql: Sql): AdminReportChatR RETURNING id ` if (removed.length === 0) return false - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "report_message.removed", target: `message:${messageId}`, diff --git a/services/api/src/services/admin/admin-report-repository.drizzle.ts b/services/api/src/services/admin/admin-report-repository.drizzle.ts index 77d863e6..5be5cfdc 100644 --- a/services/api/src/services/admin/admin-report-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-repository.drizzle.ts @@ -8,7 +8,7 @@ import { paginateKeyset, parseKeysetCursor, } from "../../db/cursor-helpers.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { andAll, firstUsableLegacyContactExpr, @@ -448,7 +448,7 @@ export function makeDrizzleAdminReportRepository( INSERT INTO report_timeline (report_id, status, note, kind, actor_id) VALUES (${id}, ${input.to}, ${input.note}, ${input.kind ?? null}, ${input.actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "report.status_changed", target: `report:${id}`, @@ -511,7 +511,7 @@ export function makeDrizzleAdminReportRepository( INSERT INTO report_timeline (report_id, status, note, kind, body, actor_id) VALUES (${id}, ${input.status}, ${input.note}, ${input.kind ?? null}, ${input.body ?? null}, ${input.actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "report.status_changed", target: `report:${id}`, @@ -559,7 +559,7 @@ export function makeDrizzleAdminReportRepository( ${input.actorId} ) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: nowFlagged ? "report.flagged" : "report.unflagged", target: `report:${id}`, @@ -581,7 +581,7 @@ export function makeDrizzleAdminReportRepository( INSERT INTO report_timeline (report_id, status, note, kind, actor_id) VALUES (${id}, 'rejected', ${input.note}, 'remove', ${input.actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "report.removed", target: `report:${id}`, @@ -606,7 +606,7 @@ export function makeDrizzleAdminReportRepository( SELECT ${id}, r.status, ${input.note}, 'followup', ${input.actorId} FROM reports r WHERE r.id = ${id} ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "report.followup_sent", target: `report:${id}`, @@ -634,7 +634,7 @@ export function makeDrizzleAdminReportRepository( INSERT INTO report_timeline (report_id, status, note, kind, actor_id) VALUES (${id}, ${row.status}, ${input.note}, 'status', ${input.actorId}) ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "report.verdict_set", target: `report:${id}`, diff --git a/services/api/src/services/admin/admin-report-service.ts b/services/api/src/services/admin/admin-report-service.ts index 47f059b8..28fa3ced 100644 --- a/services/api/src/services/admin/admin-report-service.ts +++ b/services/api/src/services/admin/admin-report-service.ts @@ -25,6 +25,7 @@ import { mapWithLimit } from "../../lib/concurrency.js" import { PRESIGN_CONCURRENCY, type PresignPacketMedia } from "../media-presign.js" import { isPubliclyVisibleStatus } from "../report-visibility.js" import { clampLimit } from "./pagination.js" +import { PG_DEADLOCK_DETECTED, PG_SERIALIZATION_FAILURE } from "../../db/pg-errors.js" import { JURISDICTION_REPLY_NOTE, resolveListFilter, @@ -94,9 +95,6 @@ const RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46] const WEBP_FORM_TYPE = [0x57, 0x45, 0x42, 0x50] const WEBP_FORM_TYPE_OFFSET = 8 -const PG_SERIALIZATION_FAILURE = "40001" -const PG_DEADLOCK_DETECTED = "40P01" - function firstTemplate(...candidates: (string | null | undefined)[]): string | null { for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim() !== "") return candidate diff --git a/services/api/src/services/admin/admin-user-repository.drizzle.ts b/services/api/src/services/admin/admin-user-repository.drizzle.ts index 1b260244..ff523a9b 100644 --- a/services/api/src/services/admin/admin-user-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-user-repository.drizzle.ts @@ -1,5 +1,5 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { assertTargetIsNotOperatorRole } from "../../auth/operator-target.js" import { clampLimit } from "./pagination.js" import { @@ -28,7 +28,7 @@ import type { Role, UserStatus, } from "@civfix/shared" -import { likeContains } from "./like.js" +import { likeContains } from "../../db/like.js" const ADMIN_USER_ORGANIZATIONS_LIMIT = 25 @@ -487,7 +487,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { WHERE subject_type = 'user' AND subject_id = ${id} AND resolved_at IS NULL ` } - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: nowFlagged ? "user.flagged" : "user.unflagged", target: `user:${id}`, @@ -516,7 +516,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { ON CONFLICT (user_id) DO UPDATE SET account_status = EXCLUDED.account_status, updated_at = now() ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: input.status === "banned" ? "user.banned" : "user.status_changed", target: `user:${id}`, @@ -535,7 +535,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { if (priorRole === undefined) return false assertTargetIsNotOperatorRole(priorRole, "change the role of") await tx`UPDATE users SET role = ${input.role} WHERE id = ${id}` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "user.role_changed", target: `user:${id}`, @@ -579,7 +579,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { report_verified_by = ${stampedBy}, updated_at = now() ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: input.value ? "user.report_verified" : "user.report_unverified", target: `user:${id}`, @@ -611,7 +611,7 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { RETURNING id ` if (chat.length === 0 && dm.length === 0) return false - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "message.removed", target: `message:${messageId}`, diff --git a/services/api/src/services/admin/audit-repository.drizzle.ts b/services/api/src/services/admin/audit-repository.drizzle.ts index 7725c6a2..44d45ccb 100644 --- a/services/api/src/services/admin/audit-repository.drizzle.ts +++ b/services/api/src/services/admin/audit-repository.drizzle.ts @@ -9,7 +9,7 @@ import { } from "../../db/cursor-helpers.js" import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" import type { WriteAuditInput } from "./audit.js" -import { likeContains } from "./like.js" +import { likeContains } from "../../db/like.js" interface AuditRowSelect { id: string diff --git a/services/api/src/services/admin/audit.ts b/services/api/src/services/admin/audit.ts index 9614d8de..c899c146 100644 --- a/services/api/src/services/admin/audit.ts +++ b/services/api/src/services/admin/audit.ts @@ -1,6 +1,3 @@ -import type { Queryable } from "../../db/client.js" -import { insertAuditRow } from "./audit-repository.drizzle.js" - export type AdminAuditAction = | "operator.login" | "operator.logout" @@ -132,7 +129,3 @@ export interface WriteAuditInput { target?: string | null meta?: Record | null } - -export function writeAudit(db: Queryable, input: WriteAuditInput): Promise { - return insertAuditRow(db, input) -} diff --git a/services/api/src/services/admin/autoforward-jobs.ts b/services/api/src/services/admin/autoforward-jobs.ts index 20fbb3ed..e8b46a92 100644 --- a/services/api/src/services/admin/autoforward-jobs.ts +++ b/services/api/src/services/admin/autoforward-jobs.ts @@ -1,6 +1,7 @@ import { AppError, ErrorCode } from "@civfix/shared" import type { Container } from "../../di.js" -import { REPORT_AUTOFORWARD_JOB, type ReportAutoForwardJob } from "../report-service.types.js" +import type { ReportAutoForwardJob } from "../report-service.types.js" +import { REPORT_AUTOFORWARD_JOB } from "../../lib/queue-names.js" import { isAlreadyRoutedConflict, makeAdminReportService, diff --git a/services/api/src/services/admin/discovery-jobs.ts b/services/api/src/services/admin/discovery-jobs.ts index 356efe1d..ec6d5208 100644 --- a/services/api/src/services/admin/discovery-jobs.ts +++ b/services/api/src/services/admin/discovery-jobs.ts @@ -3,10 +3,8 @@ // unknown or already-onboarded geoid as a no-op, so a retry-on-throw is safe. import type { Container } from "../../di.js" -import { - JURISDICTION_DISCOVERY_JOB, - type JurisdictionDiscoveryJob, -} from "../../services/jurisdiction-service.js" +import type { JurisdictionDiscoveryJob } from "../../services/jurisdiction-service.js" +import { JURISDICTION_DISCOVERY_JOB } from "../../lib/queue-names.js" import { hasUsableRoutingContact, makeDrizzleDiscoveryRepository, diff --git a/services/api/src/services/admin/discovery-repository.drizzle.ts b/services/api/src/services/admin/discovery-repository.drizzle.ts index 4f1cd0b7..19834d2b 100644 --- a/services/api/src/services/admin/discovery-repository.drizzle.ts +++ b/services/api/src/services/admin/discovery-repository.drizzle.ts @@ -2,7 +2,7 @@ import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { clampLimit } from "./pagination.js" import { paginate, parseKeysetCursor } from "../../db/cursor-helpers.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { andAll, ilikeAnyOf, legacyContactEmailUsable } from "./sql-fragments.js" import { ADMIN_CATEGORIES, @@ -274,7 +274,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { id: string, input: { text: string; actorId: string | null; who: string }, ): Promise { - const auditId = await writeAudit(sql, { + const auditId = await insertAuditRow(sql, { actorId: input.actorId, action: "discovery.note_added", target: `discovery:${id}`, @@ -315,7 +315,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { UPDATE jurisdiction_discovery_tasks SET status = 'in_progress' WHERE id = ${id} AND status <> 'done' ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "discovery.flagged", target: `discovery:${id}`, @@ -349,7 +349,7 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { input.defaultEmails, input.formUrl, ) - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "discovery.draft_saved", target: `discovery:${id}`, diff --git a/services/api/src/services/admin/forward-template-repository.drizzle.ts b/services/api/src/services/admin/forward-template-repository.drizzle.ts index 7ae14d75..c227a4e8 100644 --- a/services/api/src/services/admin/forward-template-repository.drizzle.ts +++ b/services/api/src/services/admin/forward-template-repository.drizzle.ts @@ -1,5 +1,5 @@ import type { Sql } from "../../db/client.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, @@ -51,7 +51,7 @@ export function makeDrizzleForwardTemplateRepository(sql: Sql): ForwardTemplateR ` const row = rows[0] if (row === undefined) throw new Error("forward_template_settings upsert returned no row") - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "mail.forward_template_set", target: FORWARD_TEMPLATE_AUDIT_TARGET, diff --git a/services/api/src/services/admin/gov-claims-repository.drizzle.ts b/services/api/src/services/admin/gov-claims-repository.drizzle.ts index 1b94cabc..27b37af2 100644 --- a/services/api/src/services/admin/gov-claims-repository.drizzle.ts +++ b/services/api/src/services/admin/gov-claims-repository.drizzle.ts @@ -4,7 +4,7 @@ // membership table. import type { Sql } from "../../db/client.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { clampLimit } from "./pagination.js" import { keysetInstant, @@ -164,7 +164,7 @@ export function makeDrizzleGovClaimsRepository(sql: Sql): GovClaimsRepository { ` const row = rows[0] if (!row) return null - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "gov_claim.verified", target: claimAuditTarget(id), @@ -188,7 +188,7 @@ export function makeDrizzleGovClaimsRepository(sql: Sql): GovClaimsRepository { ` const row = rows[0] if (!row) return null - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "gov_claim.approved", target: claimAuditTarget(id), @@ -216,7 +216,7 @@ export function makeDrizzleGovClaimsRepository(sql: Sql): GovClaimsRepository { ` const row = rows[0] if (!row) return null - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "gov_claim.rejected", target: claimAuditTarget(id), diff --git a/services/api/src/services/admin/gov-claims-service.ts b/services/api/src/services/admin/gov-claims-service.ts index 27b673b1..564ebe74 100644 --- a/services/api/src/services/admin/gov-claims-service.ts +++ b/services/api/src/services/admin/gov-claims-service.ts @@ -149,7 +149,7 @@ export interface GovClaimsServiceDeps { /** * `actorId` is non-null here because each of these writes an audit row for a privilege decision that must - * be attributable. The repository keeps a nullable slot because `writeAudit` accepts a system actor. + * be attributable. The repository keeps a nullable slot because `insertAuditRow` accepts a system actor. */ export interface GovClaimsService { list(query: GovClaimListQuery): Promise diff --git a/services/api/src/services/admin/inbound-bounce.ts b/services/api/src/services/admin/inbound-bounce.ts index e84bb8a6..1b489a17 100644 --- a/services/api/src/services/admin/inbound-bounce.ts +++ b/services/api/src/services/admin/inbound-bounce.ts @@ -3,10 +3,8 @@ import type { ParsedMail } from "@civfix/shared/interfaces" import { threadSentTo, type MailRepository } from "./mail-repository.drizzle.js" import { BOUNCE_DISCOVERY_PENDING_META_KEY } from "./mail-repository.js" import { geoidForContact, markBouncedContact } from "./jurisdiction-contacts-repository.drizzle.js" -import { - JURISDICTION_DISCOVERY_JOB, - type JurisdictionDiscoveryJob, -} from "../../services/jurisdiction-service.js" +import type { JurisdictionDiscoveryJob } from "../../services/jurisdiction-service.js" +import { JURISDICTION_DISCOVERY_JOB } from "../../lib/queue-names.js" import { domainOf, domainsAligned, type MailAuthVerdict } from "../../adapters/inbound-mail.cf.js" function ourMailDomains(container: Container): string[] { diff --git a/services/api/src/services/admin/inbound-jobs.ts b/services/api/src/services/admin/inbound-jobs.ts index d05649aa..0bbefdfb 100644 --- a/services/api/src/services/admin/inbound-jobs.ts +++ b/services/api/src/services/admin/inbound-jobs.ts @@ -1,7 +1,6 @@ import type { Container } from "../../di.js" import { runInboundSweep } from "./inbound-sweep.js" - -export const INBOUND_SWEEP_JOB = "inbound.sweep" +import { INBOUND_SWEEP_JOB } from "../../lib/queue-names.js" export async function registerInboundJobs(container: Container): Promise { await container.jobs.schedule(INBOUND_SWEEP_JOB, container.env.INBOUND_SWEEP_CRON) diff --git a/services/api/src/services/admin/inbound-repository.drizzle.ts b/services/api/src/services/admin/inbound-repository.drizzle.ts index 0e4d0e8b..d9396e51 100644 --- a/services/api/src/services/admin/inbound-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-repository.drizzle.ts @@ -6,8 +6,8 @@ import { paginateKeyset, parseKeysetCursor, } from "../../db/cursor-helpers.js" -import { likeContains } from "./like.js" -import { writeAudit } from "./audit.js" +import { likeContains } from "../../db/like.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { HTML_PREVIEW_SOURCE_CHARS, PREVIEW_SOURCE_CHARS, toPreview } from "./mail-preview.js" import type { InboundEmailDTO, @@ -200,7 +200,7 @@ export function makeDrizzleInboundRepository(sql: Sql): InboundRepository { archived_at = CASE WHEN ${status === "archived"} THEN COALESCE(archived_at, now()) ELSE NULL END WHERE id = ${id} ` - await writeAudit(tx, { + await insertAuditRow(tx, { actorId, action: "inbox.status_changed", target: `inbound_email:${id}`, diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts index ba4fa7eb..a89f9980 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts @@ -1,7 +1,7 @@ import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { clampLimit } from "./pagination.js" import { decodeOffsetCursor, encodeOffsetCursor } from "../../db/cursor-helpers.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { buildUnmappedRecord, shouldIncludeUnmapped } from "./jurisdiction-directory-projection.js" import { ADMIN_CATEGORIES, @@ -24,18 +24,12 @@ import type { import { AppError } from "@civfix/shared" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" import { ilikeAnyOf } from "./sql-fragments.js" -import { PG_UNIQUE_VIOLATION } from "../../db/pg-errors.js" +import { isUniqueViolationOn } from "../../db/pg-errors.js" const JURISDICTION_HANDLE_CONSTRAINT = "jurisdictions_handle_lower_key" const HANDLE_TAKEN_BY_JURISDICTION = "That @handle is already used by another jurisdiction." -function isJurisdictionHandleConflict(err: unknown): boolean { - if (typeof err !== "object" || err === null) return false - const e = err as { code?: unknown; constraint_name?: unknown } - return e.code === PG_UNIQUE_VIOLATION && e.constraint_name === JURISDICTION_HANDLE_CONSTRAINT -} - interface DirectoryRow extends CategoryCountRow { geoid: string name: string @@ -222,7 +216,7 @@ export function makeDrizzleJurisdictionContactsRepository( ` const taskResolved = tasks.length > 0 - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: audit.actorId, action: "discovery.contacts_saved", target: `jurisdiction:${geoid}`, @@ -297,7 +291,7 @@ export function makeDrizzleJurisdictionContactsRepository( try { await tx`UPDATE jurisdictions SET handle = ${handle} WHERE geoid = ${geoid}` } catch (err) { - if (isJurisdictionHandleConflict(err)) { + if (isUniqueViolationOn(err, JURISDICTION_HANDLE_CONSTRAINT)) { throw AppError.conflict(HANDLE_TAKEN_BY_JURISDICTION) } throw err @@ -312,7 +306,7 @@ export function makeDrizzleJurisdictionContactsRepository( const t = input.forwardBodyTemplate await tx`UPDATE jurisdictions SET forward_body_template = ${t === null || t === "" ? null : t} WHERE geoid = ${geoid}` } - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: audit.actorId, action: "jurisdiction.patched", target: `jurisdiction:${geoid}`, diff --git a/services/api/src/services/admin/jurisdiction-contacts-service.ts b/services/api/src/services/admin/jurisdiction-contacts-service.ts index ebb69bc5..5bf78819 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-service.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-service.ts @@ -8,14 +8,14 @@ import type { DirectoryFilter, DirectorySort } from "./jurisdiction-contacts-typ import { isReservedHandle } from "../../auth/reserved-handles.js" import { clampLimit } from "./pagination.js" import { isThrottled } from "./outreach-service.js" -import { - OUTREACH_DIGEST_JOB, - type JurisdictionContactsService, - type JurisdictionContactsServiceDeps, - type PatchContactsInput, - type SaveAndRouteResult, - type SaveContactsInput, +import type { + JurisdictionContactsService, + JurisdictionContactsServiceDeps, + PatchContactsInput, + SaveAndRouteResult, + SaveContactsInput, } from "./jurisdiction-contacts-types.js" +import { OUTREACH_DIGEST_JOB } from "../../lib/queue-names.js" export * from "./jurisdiction-contacts-types.js" export * from "./jurisdiction-directory-projection.js" diff --git a/services/api/src/services/admin/jurisdiction-contacts-types.ts b/services/api/src/services/admin/jurisdiction-contacts-types.ts index 40950955..a49eb463 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-types.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-types.ts @@ -6,9 +6,6 @@ import type { ReportCategory, } from "@civfix/shared" -/** Lives in this zero-runtime module so the service and the job registration share one literal. */ -export const OUTREACH_DIGEST_JOB = "outreach.digest" - /** * Sentinel geoid for the synthetic directory row that aggregates waiting reports with no jurisdiction or * an orphaned geoid. The directory is sourced from jurisdictions, so without it those reports would be diff --git a/services/api/src/services/admin/mail-repository.drizzle.ts b/services/api/src/services/admin/mail-repository.drizzle.ts index 9ab2a690..3ec58e7d 100644 --- a/services/api/src/services/admin/mail-repository.drizzle.ts +++ b/services/api/src/services/admin/mail-repository.drizzle.ts @@ -7,7 +7,7 @@ import { parseKeysetCursor, } from "../../db/cursor-helpers.js" import { PREVIEW_SOURCE_CHARS } from "./mail-preview.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { ilikeAnyOf } from "./sql-fragments.js" import { mintThreadToken, @@ -183,7 +183,7 @@ function latestPreviewMessage( async function writeMailAudit(tx: Queryable, audit: MailAuditInput | undefined): Promise { if (!audit) return - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: audit.actorId, action: audit.action, target: audit.target, diff --git a/services/api/src/services/admin/moderation-repository.drizzle.ts b/services/api/src/services/admin/moderation-repository.drizzle.ts index 1a5d1040..9b768879 100644 --- a/services/api/src/services/admin/moderation-repository.drizzle.ts +++ b/services/api/src/services/admin/moderation-repository.drizzle.ts @@ -1,6 +1,6 @@ import type { ReportCategory } from "@civfix/shared" import type { Queryable, Sql, SqlFragment } from "../../db/client.js" -import { writeAudit } from "./audit.js" +import { insertAuditRow } from "./audit-repository.drizzle.js" import { clampLimit } from "./pagination.js" import { keysetInstant, @@ -358,7 +358,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository ` } } - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "moderation.approved", target: `moderation:${id}`, @@ -403,7 +403,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository ) if (authorId != null) await incrementUserModeration(tx, authorId) } - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "moderation.removed", target: `moderation:${id}`, @@ -428,7 +428,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository return sql.begin(async (tx) => { const resolved = await resolveItem(tx, id, "held", input.actorId) if (!resolved) return null - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "moderation.held", target: `moderation:${id}`, @@ -467,7 +467,7 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository AND resolved_at IS NULL ` } - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: input.actorId, action: "moderation.appeal_decided", target: `moderation:${id}`, diff --git a/services/api/src/services/admin/outreach-jobs.ts b/services/api/src/services/admin/outreach-jobs.ts index 7e9ac123..8b71ac9a 100644 --- a/services/api/src/services/admin/outreach-jobs.ts +++ b/services/api/src/services/admin/outreach-jobs.ts @@ -1,6 +1,6 @@ import type { Container } from "../../di.js" import { insertAuditRow } from "./audit-repository.drizzle.js" -import { OUTREACH_DIGEST_JOB } from "./jurisdiction-contacts-types.js" +import { OUTREACH_DIGEST_JOB } from "../../lib/queue-names.js" import { makeDrizzleMailRepository } from "./mail-repository.drizzle.js" import { makeContainerOutboundMailService, diff --git a/services/api/src/services/admin/sql-fragments.ts b/services/api/src/services/admin/sql-fragments.ts index fb15cf35..118d6340 100644 --- a/services/api/src/services/admin/sql-fragments.ts +++ b/services/api/src/services/admin/sql-fragments.ts @@ -1,5 +1,5 @@ import type { Queryable, SqlFragment } from "../../db/client.js" -import { likeContains } from "./like.js" +import { likeContains } from "../../db/like.js" export function ilikeAnyOf( sql: Queryable, diff --git a/services/api/src/services/admin/system-health-probes.ts b/services/api/src/services/admin/system-health-probes.ts index 1ac2510f..1e62ce14 100644 --- a/services/api/src/services/admin/system-health-probes.ts +++ b/services/api/src/services/admin/system-health-probes.ts @@ -1,6 +1,7 @@ import type { Sql } from "../../db/client.js" import type { ProbeResult, SystemHealthProbes } from "./system-health-service.js" -import { MEDIA_WORKER_BACKLOG_WARN, PG_UNDEFINED_TABLE } from "./system-health-service.js" +import { MEDIA_WORKER_BACKLOG_WARN } from "./system-health-service.js" +import { PG_UNDEFINED_TABLE } from "../../db/pg-errors.js" import { makeDrizzleSystemHealthRepository } from "./system-health-repository.drizzle.js" const PROBE_TIMEOUT_MS = 2000 diff --git a/services/api/src/services/admin/system-health-service.ts b/services/api/src/services/admin/system-health-service.ts index 66a2785b..3d996f92 100644 --- a/services/api/src/services/admin/system-health-service.ts +++ b/services/api/src/services/admin/system-health-service.ts @@ -1,4 +1,10 @@ import type { SystemHealthResponse, SystemService } from "@civfix/shared" +import { + PG_INVALID_AUTHORIZATION, + PG_INVALID_CATALOG_NAME, + PG_INVALID_PASSWORD, + PG_UNDEFINED_TABLE, +} from "../../db/pg-errors.js" export type HealthStatus = "ok" | "warn" | "down" | "not_deployed" @@ -33,14 +39,6 @@ export interface SystemHealthService { export const MEDIA_WORKER_BACKLOG_WARN = 50 -export const PG_UNDEFINED_TABLE = "42P01" - -const PG_INVALID_PASSWORD = "28P01" - -const PG_INVALID_AUTHORIZATION = "28000" - -const PG_INVALID_CATALOG_NAME = "3D000" - const NO_PROBE: { status: HealthStatus; val: string } = { status: "down", val: "No probe" } const ROUTING_NOT_DEPLOYED_VAL = "Phase 3" diff --git a/services/api/src/services/affiliation-repository.drizzle.ts b/services/api/src/services/affiliation-repository.drizzle.ts index a09107eb..867c17c4 100644 --- a/services/api/src/services/affiliation-repository.drizzle.ts +++ b/services/api/src/services/affiliation-repository.drizzle.ts @@ -1,6 +1,6 @@ import type { OrganizationRefDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" -import { blockedPairExpr } from "./hidden-identity.js" +import { blockedPairExpr } from "./blocks-sql.js" import { publicServedKeyExpr } from "./media-served-key.js" export interface AffiliationRow { diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 6e505332..089dc316 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -42,7 +42,8 @@ import type { ClaimRepository, PendingAnonReport } from "./claim-service.js" import { AppError } from "@civfix/shared" import type { AnonReportResponse, ReportStatus } from "@civfix/shared" import { insertModerationItem } from "./admin/moderation-repository.drizzle.js" -import { claimableAsReportMedia, lockUploadsForClaim } from "./media-bindings.js" +import { claimableAsReportMedia } from "./media-bindings.js" +import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" import { isUniqueViolation } from "../db/pg-errors.js" const HELD_REVIEW_NOTE = "Awaiting automated review" @@ -307,7 +308,7 @@ async function insertHeldReport( // token row locks; skipped with no ids because `IN ()` is invalid SQL. async function attachReportMedia(tx: TransactionSql, args: CreateAnonReportTxArgs): Promise { if (args.mediaUploadIds.length === 0) return - await lockUploadsForClaim(tx, args.mediaUploadIds) + await lockUploadsForClaimIn(tx, args.mediaUploadIds) const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET report_id = ${args.reportId} diff --git a/services/api/src/services/avatar-media.ts b/services/api/src/services/avatar-media.ts index ee1f42fd..d2abbe9b 100644 --- a/services/api/src/services/avatar-media.ts +++ b/services/api/src/services/avatar-media.ts @@ -6,12 +6,6 @@ import { type AvatarMediaRow, } from "./media-claim-repository.drizzle.js" -export { - avatarClaimQuery, - type AvatarClaimant, - type AvatarMediaRow, -} from "./media-claim-repository.drizzle.js" - export interface AvatarMediaRef { id: string r2Key: string diff --git a/services/api/src/services/chat-fanout-jobs.ts b/services/api/src/services/chat-fanout-jobs.ts index f932a0a3..b8da44f9 100644 --- a/services/api/src/services/chat-fanout-jobs.ts +++ b/services/api/src/services/chat-fanout-jobs.ts @@ -14,8 +14,7 @@ import { type ContainerRoomFanoutDeps, type RoomFanoutLogger, } from "./chat-room-notifier-wiring.js" - -export const CHAT_ROOM_FANOUT_JOB = "chat.room.fanout" +import { CHAT_ROOM_FANOUT_JOB } from "../lib/queue-names.js" export interface ChatRoomFanoutJob { kind: RoomFanoutKind diff --git a/services/api/src/services/cleanup-jobs.ts b/services/api/src/services/cleanup-jobs.ts index 96e3425f..9b2e646a 100644 --- a/services/api/src/services/cleanup-jobs.ts +++ b/services/api/src/services/cleanup-jobs.ts @@ -5,7 +5,8 @@ import { makeRouteNotificationService } from "./route-notifier.js" import { makeCommsRuntime } from "./host/comms-wiring.js" import { makeContainerGuestRsvpService } from "./guest-rsvp-wiring.js" import { makeCleanupService } from "./cleanup-service.js" -import { CLEANUP_CANCEL_FANOUT_JOB, type CleanupCancelFanoutJob } from "./cleanup-notifications.js" +import type { CleanupCancelFanoutJob } from "./cleanup-notifications.js" +import { CLEANUP_CANCEL_FANOUT_JOB } from "../lib/queue-names.js" export async function registerCleanupCancelFanoutJob( container: Container, diff --git a/services/api/src/services/cleanup-notifications.ts b/services/api/src/services/cleanup-notifications.ts index 9c438bf1..b27d4531 100644 --- a/services/api/src/services/cleanup-notifications.ts +++ b/services/api/src/services/cleanup-notifications.ts @@ -4,14 +4,13 @@ import type { NotificationService } from "./notification-service.js" import { mapWithLimit } from "../lib/concurrency.js" import { MS_PER_HOUR } from "../lib/time.js" import type { CleanupRepository, SlotReconcileResult } from "./cleanup-repository.types.js" -import { CLEANUP_GUEST_UPDATE_FANOUT_JOB, type GuestUpdateFanoutJob } from "./guest-rsvp-service.js" +import type { GuestUpdateFanoutJob } from "./guest-rsvp-service.js" +import { CLEANUP_CANCEL_FANOUT_JOB, CLEANUP_GUEST_UPDATE_FANOUT_JOB } from "../lib/queue-names.js" export const CANCEL_FANOUT_MEMBER_CAP = 2000 const CANCEL_FANOUT_CONCURRENCY = 8 -export const CLEANUP_CANCEL_FANOUT_JOB = "cleanup.cancel.fanout" - const CANCEL_FANOUT_DEDUPE_WINDOW_MS = MS_PER_HOUR export interface CleanupCancelFanoutJob { diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index 41f9e57b..57eea789 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -5,8 +5,7 @@ import { MAX_LINKED_REPORTS, MAX_TICKET_TYPES_PER_EVENT, } from "@civfix/shared" -import type postgres from "postgres" -import type { Queryable, Sql } from "../db/client.js" +import type { Queryable, Sql, SqlFragment } from "../db/client.js" import { encodeNearCursor, encodeTimeCursor, @@ -25,7 +24,6 @@ import { NO_HOST_STANDING } from "@civfix/shared/host" import { publicServedKeyExpr } from "./media-served-key.js" import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "./media-bindings.js" import { userUploader } from "./media-uploader.js" -import { isUniqueViolationOn } from "./host/registration-sql.js" import { applyBanIn } from "./host/registration-repository.drizzle.js" import { deterministicUuid } from "./deterministic-uuid.js" import { firstUsableLegacyContactExpr, usableContactRowExpr } from "./admin/sql-fragments.js" @@ -60,7 +58,8 @@ import type { UpdateCleanupPatch, } from "./cleanup-repository.types.js" import { eventWindowOfRow, hasEventEnded } from "./cleanup-rules.js" -import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" +import { hiddenIdentity } from "./hidden-identity.js" +import { blockedPairExpr } from "./blocks-sql.js" import { buildBboxFilter, buildMembershipFilter, @@ -89,7 +88,7 @@ import type { } from "@civfix/shared" import type { HostStanding } from "@civfix/shared/host" import { touchUserActivity } from "../db/sql/user-activity.js" -import { PG_UNIQUE_VIOLATION } from "../db/pg-errors.js" +import { isUniqueViolationOn, PG_UNIQUE_VIOLATION } from "../db/pg-errors.js" export const LINKED_EVENTS_PER_REPORT_CAP = 20 export const MAX_EVENTS_PER_REPORT = 50 @@ -355,7 +354,7 @@ function timeCursorOrder( sql: Queryable, past: boolean, rawCursor: string | null | undefined, -): { cursorFilter: postgres.Fragment; order: postgres.Fragment } { +): { cursorFilter: SqlFragment; order: SqlFragment } { const cursor = parseTimeCursor(rawCursor) const cursorFilter = cursor !== null @@ -369,8 +368,8 @@ function timeCursorOrder( return { cursorFilter, order } } -function hostSetFragments(sql: Queryable, patch: EventHostWrite): postgres.Fragment[] { - const sets: postgres.Fragment[] = [] +function hostSetFragments(sql: Queryable, patch: EventHostWrite): SqlFragment[] { + const sets: SqlFragment[] = [] if (patch.endsAt !== undefined) sets.push(sql`ends_at = ${patch.endsAt}`) if (patch.timezone !== undefined) sets.push(sql`timezone = ${patch.timezone}`) if (patch.visibility !== undefined) sets.push(sql`visibility = ${patch.visibility}`) @@ -398,8 +397,8 @@ function hostSetFragments(sql: Queryable, patch: EventHostWrite): postgres.Fragm return sets } -function cleanupSetList(sql: Queryable, patch: UpdateCleanupPatch): postgres.Fragment | null { - const sets: postgres.Fragment[] = hostSetFragments(sql, patch) +function cleanupSetList(sql: Queryable, patch: UpdateCleanupPatch): SqlFragment | null { + const sets: SqlFragment[] = hostSetFragments(sql, patch) if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`) if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) if (patch.eventKind !== undefined) sets.push(sql`event_kind = ${patch.eventKind}`) @@ -425,7 +424,7 @@ function cleanupSetList(sql: Queryable, patch: UpdateCleanupPatch): postgres.Fra async function updateCleanupInTx( tx: Queryable, id: string, - setList: postgres.Fragment, + setList: SqlFragment, patch: UpdateCleanupPatch, actorUserId: string, ): Promise { diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index 9f0ad9c5..1f72e93a 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -93,7 +93,7 @@ import { assertKnownSlotIds, assertTimedSlotsFitWindow, toDesiredSlots } from ". import { makeCleanupNotifications, type CleanupCancelFanoutJob } from "./cleanup-notifications.js" export * from "./cleanup-repository.types.js" -export { CANCEL_FANOUT_MEMBER_CAP, CLEANUP_CANCEL_FANOUT_JOB } from "./cleanup-notifications.js" +export { CANCEL_FANOUT_MEMBER_CAP } from "./cleanup-notifications.js" export { CLEANUPS_DEFAULT_LIMIT, ATTENDEES_DEFAULT_LIMIT, diff --git a/services/api/src/services/content-report-subject.ts b/services/api/src/services/content-report-subject.ts index 78494b8e..64827b1c 100644 --- a/services/api/src/services/content-report-subject.ts +++ b/services/api/src/services/content-report-subject.ts @@ -13,7 +13,7 @@ import { import { makeDrizzleMediaRepository } from "./media-repository.drizzle.js" import { isPubliclyVisibleStatus } from "./report-visibility.js" import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" -import { hostStandingOf } from "./host/host-standing.js" +import { hostStandingOf } from "./host/host-standing-repository.drizzle.js" export interface ContentSubjectGate { assertReportable( diff --git a/services/api/src/services/data-export-jobs.ts b/services/api/src/services/data-export-jobs.ts index cf8535c3..53dcfd69 100644 --- a/services/api/src/services/data-export-jobs.ts +++ b/services/api/src/services/data-export-jobs.ts @@ -4,8 +4,7 @@ import type { Env } from "../env.js" import type { Container } from "../di.js" import { makeDataExportService, type DataExportService } from "./data-export-service.js" import { isFinalJobAttempt } from "./job-attempt.js" - -export const DATA_EXPORT_JOB = "data.export" +import { DATA_EXPORT_JOB } from "../lib/queue-names.js" const SUPPORT_MAILBOX_LOCAL_PART = "support" diff --git a/services/api/src/services/data-export-service.ts b/services/api/src/services/data-export-service.ts index b4a9620f..9e9800e4 100644 --- a/services/api/src/services/data-export-service.ts +++ b/services/api/src/services/data-export-service.ts @@ -5,7 +5,7 @@ import type { UserStore } from "../auth/stores.js" import { heading, paragraph } from "../adapters/email-blocks.js" import { renderEmailBody } from "../adapters/email-layout.js" import { mailFailure } from "../adapters/mail-failure.js" -import { writeAudit } from "./admin/audit.js" +import { insertAuditRow } from "./admin/audit-repository.drizzle.js" import { makeDrizzleDataExportRepository, type BlockExportRow, @@ -405,7 +405,7 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe * so the request is on record even if the notice fails. Carries no address or export content. */ async function recordUndeliverable(userId: string, kind: DataExportUndeliverable): Promise { - await writeAudit(sql, { + await insertAuditRow(sql, { actorId: null, action: "data_export.undeliverable", target: `user:${userId}`, diff --git a/services/api/src/services/guest-jobs.ts b/services/api/src/services/guest-jobs.ts index fd8cb0a8..7bf36a45 100644 --- a/services/api/src/services/guest-jobs.ts +++ b/services/api/src/services/guest-jobs.ts @@ -3,11 +3,8 @@ import type { Container } from "../di.js" import { makeContainerGuestRsvpService } from "./guest-rsvp-wiring.js" import { makeCommsRuntime } from "./host/comms-wiring.js" import type { EventUpdateVerdict } from "./host/broadcast-lanes.js" -import { - CLEANUP_GUEST_UPDATE_FANOUT_JOB, - GUEST_RETENTION_SWEEP_JOB, - type GuestUpdateFanoutJob, -} from "./guest-rsvp-service.js" +import type { GuestUpdateFanoutJob } from "./guest-rsvp-service.js" +import { CLEANUP_GUEST_UPDATE_FANOUT_JOB, GUEST_RETENTION_SWEEP_JOB } from "../lib/queue-names.js" const GUEST_UPDATE_SINGLETON_PREFIX = "guest-update:" diff --git a/services/api/src/services/guest-rsvp-service.ts b/services/api/src/services/guest-rsvp-service.ts index 263883b5..6872617a 100644 --- a/services/api/src/services/guest-rsvp-service.ts +++ b/services/api/src/services/guest-rsvp-service.ts @@ -280,10 +280,6 @@ export interface GuestUpdateFanoutJob { cleanupId: string } -export const CLEANUP_GUEST_UPDATE_FANOUT_JOB = "cleanup.guest.update.fanout" - -export const GUEST_RETENTION_SWEEP_JOB = "guest.retention.sweep" - export interface GuestRsvpService { requestCode( input: GuestRsvpRequestRequest, diff --git a/services/api/src/services/guest-rsvp-wiring.ts b/services/api/src/services/guest-rsvp-wiring.ts index 3d17ae99..36875bad 100644 --- a/services/api/src/services/guest-rsvp-wiring.ts +++ b/services/api/src/services/guest-rsvp-wiring.ts @@ -1,6 +1,6 @@ import type { Container } from "../di.js" import { REVIEWER_OTP_EMAIL } from "../auth/otp.js" -import { writeAudit } from "./admin/audit.js" +import { insertAuditRow } from "./admin/audit-repository.drizzle.js" import { requireCapability } from "./host/authz.js" import { makeContainerRegistrationServices } from "./host/registration-wiring.js" import { webBaseUrlOf } from "../lib/base-url.js" @@ -97,7 +97,7 @@ export function makeContainerGuestRsvpService( const audit: GuestRsvpServiceDeps["audit"] = overrides?.audit ?? (async (input) => { - await writeAudit(container.getDb().sql, { + await insertAuditRow(container.getDb().sql, { actorId: input.actorId, action: input.action, target: input.target, diff --git a/services/api/src/services/hidden-identity.ts b/services/api/src/services/hidden-identity.ts index 06510e4d..ac83e055 100644 --- a/services/api/src/services/hidden-identity.ts +++ b/services/api/src/services/hidden-identity.ts @@ -1,7 +1,5 @@ import { avatarGradient } from "@civfix/shared" -export { blockedPairExpr } from "./blocks-sql.js" - export const HIDDEN_USER_LABEL = "Community member" type AvatarPair = ReturnType diff --git a/services/api/src/services/host/admin-pages-repository.drizzle.ts b/services/api/src/services/host/admin-pages-repository.drizzle.ts index ce25c90c..aa4e12ba 100644 --- a/services/api/src/services/host/admin-pages-repository.drizzle.ts +++ b/services/api/src/services/host/admin-pages-repository.drizzle.ts @@ -1,7 +1,7 @@ import type { EventPageStatus, EventVisibility } from "@civfix/shared" import type { Queryable } from "../../db/client.js" import { keysetInstant, keysetPredicate, type KeysetCursor } from "../../db/cursor-helpers.js" -import { likeContains } from "../admin/like.js" +import { likeContains } from "../../db/like.js" export interface AdminEventPageRow { cleanupId: string diff --git a/services/api/src/services/host/broadcast-queues.ts b/services/api/src/services/host/broadcast-queues.ts index 8d9af147..ce7948e9 100644 --- a/services/api/src/services/host/broadcast-queues.ts +++ b/services/api/src/services/host/broadcast-queues.ts @@ -1,23 +1,3 @@ -export const BROADCAST_PLAN_JOB = "broadcast.plan" -export const BROADCAST_CHUNK_JOB = "broadcast.chunk" -export const BROADCAST_SCHEDULE_SWEEP_JOB = "broadcast.schedule.sweep" -export const EVENT_REMINDERS_SWEEP_JOB = "event.reminders.sweep" -export const EVENT_METRICS_ROLLUP_JOB = "event.metrics.rollup" -export const HOST_EXPORT_JOB = "host.export" -export const HOST_EXPORT_REAP_JOB = "host.export.reap" -export const HOST_RETENTION_SWEEP_JOB = "host.retention.sweep" - -export const COMMS_QUEUE_NAMES = [ - BROADCAST_PLAN_JOB, - BROADCAST_CHUNK_JOB, - BROADCAST_SCHEDULE_SWEEP_JOB, - EVENT_REMINDERS_SWEEP_JOB, - EVENT_METRICS_ROLLUP_JOB, - HOST_EXPORT_JOB, - HOST_EXPORT_REAP_JOB, - HOST_RETENTION_SWEEP_JOB, -] as const - export interface BroadcastPlanJob { broadcastId: string } diff --git a/services/api/src/services/host/broadcast-repository.drizzle.ts b/services/api/src/services/host/broadcast-repository.drizzle.ts index a3a90937..8b7f8b7b 100644 --- a/services/api/src/services/host/broadcast-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-repository.drizzle.ts @@ -6,10 +6,11 @@ import type { BroadcastStatus, DeliveryStatus, } from "@civfix/shared" -import type { Queryable, Sql } from "../../db/client.js" +import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { keysetInstant, keysetPredicate } from "../../db/cursor-helpers.js" -import { writeAudit, type WriteAuditInput } from "../admin/audit.js" -import { likeContains } from "../admin/like.js" +import type { WriteAuditInput } from "../admin/audit.js" +import { insertAuditRow } from "../admin/audit-repository.drizzle.js" +import { likeContains } from "../../db/like.js" import { makeDrizzleBroadcastAudienceRepository } from "./broadcast-audience-repository.drizzle.js" import type { AdminBroadcastListQuery, @@ -116,7 +117,7 @@ function broadcastColumns(tag: Queryable) { suppressed_count, content_scrubbed_at, created_at, updated_at` } -function joinSet(sql: Sql, fragments: Array>): ReturnType { +function joinSet(sql: Sql, fragments: SqlFragment[]): SqlFragment { const [first, ...rest] = fragments if (first === undefined) throw new Error("broadcast UPDATE needs at least one column to set") return rest.reduce((acc, frag) => sql`${acc}, ${frag}`, first) @@ -431,7 +432,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { broadcastId: string, patch: BroadcastDraftPatch, ): Promise { - const sets: Array> = [] + const sets: SqlFragment[] = [] if (patch.subject !== undefined) sets.push(sql`subject = ${patch.subject}`) if (patch.bodyMd !== undefined) sets.push(sql`body_md = ${patch.bodyMd}`) if ("ctaLabel" in patch) sets.push(sql`cta_label = ${patch.ctaLabel ?? null}`) @@ -462,7 +463,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { to: BroadcastStatus, fields = {}, ): Promise { - const sets: Array> = [sql`status = ${to}`] + const sets: SqlFragment[] = [sql`status = ${to}`] if ("scheduledAt" in fields) sets.push(sql`scheduled_at = ${fields.scheduledAt ?? null}`) if ("startedAt" in fields) sets.push(sql`started_at = ${fields.startedAt ?? null}`) if ("finishedAt" in fields) sets.push(sql`finished_at = ${fields.finishedAt ?? null}`) @@ -896,7 +897,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { SET host_messaging_suspended = ${suspended}, updated_at = now() RETURNING user_id` if (rows.length === 0) return false - await writeAudit(tx, audit) + await insertAuditRow(tx, audit) return true }) as Promise }, diff --git a/services/api/src/services/host/comms-jobs.ts b/services/api/src/services/host/comms-jobs.ts index 089a7c17..3819c546 100644 --- a/services/api/src/services/host/comms-jobs.ts +++ b/services/api/src/services/host/comms-jobs.ts @@ -9,6 +9,8 @@ import { HOST_EXPORT_JOB, HOST_EXPORT_REAP_JOB, HOST_RETENTION_SWEEP_JOB, +} from "../../lib/queue-names.js" +import { parseBroadcastChunkJob, parseBroadcastPlanJob, parseHostExportJob, diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index 4139f2d0..ccde6cff 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -4,7 +4,8 @@ import type { Container } from "../../di.js" import type { Queryable } from "../../db/client.js" import { domainOf } from "../../adapters/mail-text.js" import { apiBaseUrlOf, webBaseUrlOf } from "../../lib/base-url.js" -import { writeAudit, type WriteAuditInput } from "../admin/audit.js" +import type { WriteAuditInput } from "../admin/audit.js" +import { insertAuditRow } from "../admin/audit-repository.drizzle.js" import { makeRouteNotificationService } from "../route-notifier.js" import { makeDrizzleAnalyticsRepository } from "./analytics-repository.drizzle.js" import { makeAnalyticsService, type AnalyticsService } from "./analytics-service.js" @@ -25,7 +26,7 @@ import { } from "./broadcast-service.js" import { makeBroadcastPipeline, type BroadcastPipeline } from "./broadcast-pipeline.js" import { makeBroadcastLanes, type BroadcastLanes } from "./broadcast-lanes.js" -import { BROADCAST_CHUNK_JOB, BROADCAST_PLAN_JOB } from "./broadcast-queues.js" +import { BROADCAST_CHUNK_JOB, BROADCAST_PLAN_JOB } from "../../lib/queue-names.js" import { makeDrizzleMetricsRepository, type MetricsRepository, @@ -72,7 +73,7 @@ export async function auditBestEffort( logger: Pick | undefined, ): Promise { try { - await writeAudit(sql, entry) + await insertAuditRow(sql, entry) } catch (err) { logger?.warn( { err, action: entry.action, target: entry.target ?? null }, diff --git a/services/api/src/services/host/event-analytics-repository.drizzle.ts b/services/api/src/services/host/event-analytics-repository.drizzle.ts index 9c28e36d..bd924c0d 100644 --- a/services/api/src/services/host/event-analytics-repository.drizzle.ts +++ b/services/api/src/services/host/event-analytics-repository.drizzle.ts @@ -1,8 +1,5 @@ -import type postgres from "postgres" import type { KeyCount } from "@civfix/shared/host" -import type { Sql } from "../../db/client.js" - -type Fragment = postgres.Fragment +import type { Sql, SqlFragment } from "../../db/client.js" const EVENT_ANALYTICS_SLOT_ROW_LIMIT = 50 @@ -57,7 +54,7 @@ function keyCounts(rows: { key: string; n: number }[]): KeyCount[] { export function makeDrizzleEventAnalyticsRepository(sql: Sql): EventAnalyticsRepository { return { async previousCompletedEventIds(args) { - const orgFilter = (): Fragment => + const orgFilter = (): SqlFragment => args.organizationId !== null ? sql`AND c.organization_id = ${args.organizationId}` : sql`` const rows = await sql<{ id: string }[]>` WITH hosted AS ( diff --git a/services/api/src/services/host/export-repository.drizzle.ts b/services/api/src/services/host/export-repository.drizzle.ts index f4d58201..7befff4b 100644 --- a/services/api/src/services/host/export-repository.drizzle.ts +++ b/services/api/src/services/host/export-repository.drizzle.ts @@ -1,6 +1,7 @@ import type { HostExportKind, HostExportStatus } from "@civfix/shared" import type { Sql } from "../../db/client.js" -import { writeAudit, type WriteAuditInput } from "../admin/audit.js" +import type { WriteAuditInput } from "../admin/audit.js" +import { insertAuditRow } from "../admin/audit-repository.drizzle.js" export interface HostExportRecord { id: string @@ -124,7 +125,7 @@ export function makeDrizzleHostExportRepository(sql: Sql): HostExportRepository row_count, byte_size, truncated, error_code, run_token, requested_at, started_at, completed_at, expires_at` const record = toRecord(rows[0]!) - if (audit !== undefined) await writeAudit(tx, audit(record.id)) + if (audit !== undefined) await insertAuditRow(tx, audit(record.id)) return record }) as Promise }, diff --git a/services/api/src/services/host/host-audit.ts b/services/api/src/services/host/host-audit.ts index 677db332..53fb225e 100644 --- a/services/api/src/services/host/host-audit.ts +++ b/services/api/src/services/host/host-audit.ts @@ -1,5 +1,6 @@ import type { Queryable } from "../../db/client.js" -import { writeAudit, type WriteAuditInput } from "../admin/audit.js" +import type { WriteAuditInput } from "../admin/audit.js" +import { insertAuditRow } from "../admin/audit-repository.drizzle.js" export type HostAuditAction = | "event.guests_viewed" @@ -50,7 +51,7 @@ export interface HostAuditSink { } export function writeHostAudit(tx: Queryable, input: HostAuditInput): Promise { - return writeAudit(tx, input satisfies WriteAuditInput) + return insertAuditRow(tx, input satisfies WriteAuditInput) } export function makeHostAuditSink(sql: Queryable, logger?: HostAuditLogger): HostAuditSink { diff --git a/services/api/src/services/host/host-standing.ts b/services/api/src/services/host/host-standing.ts deleted file mode 100644 index b4f4e2ce..00000000 --- a/services/api/src/services/host/host-standing.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./host-standing-repository.drizzle.js" diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 4b9d396b..98189c1a 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -17,7 +17,7 @@ import { import { publicServedKeyExpr } from "../media-served-key.js" import { cleanupStatusExpr } from "../cleanup-sql.js" import { writeHostAudit } from "./host-audit.js" -import { isUniqueViolationOn } from "./registration-sql.js" +import { isUniqueViolationOn } from "../../db/pg-errors.js" import type { AcceptTeamInviteByIdOutcome, AcceptTeamInviteOutcome, @@ -33,9 +33,7 @@ import type { RevokeTeamInviteOutcome, } from "./host-team-repository.types.js" import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" -import type { CleanupPersonView } from "../cleanup-repository.types.js" - -const UNKNOWN_PERSON_NAME = "Unknown" +import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-rows.drizzle.js" const TEAM_INVITE_PENDING_CONSTRAINTS = [ "cleanup_team_invites_pending_user_uidx", @@ -82,16 +80,6 @@ function inviteColumns(sql: Queryable) { ` } -function personViewOf( - id: string | null, - name: string | null, - handle: string | null, - avatarUrl: string | null, -): CleanupPersonView | null { - if (id === null) return null - return { id, displayName: name ?? UNKNOWN_PERSON_NAME, handle, bio: null, avatarUrl } -} - function toInviteRecord(row: InviteRowSelect): EventTeamInviteRecord { return { id: row.id, @@ -99,17 +87,25 @@ function toInviteRecord(row: InviteRowSelect): EventTeamInviteRecord { role: row.role, status: row.status, invitee: personViewOf( - row.invitee_id, - row.invitee_name, - row.invitee_handle, - row.invitee_avatar_url, + { + id: row.invitee_id, + name: row.invitee_name, + handle: row.invitee_handle, + bio: null, + avatarUrl: row.invitee_avatar_url, + }, + UNKNOWN_PERSON_NAME, ), invitedEmail: row.invited_email, invitedBy: personViewOf( - row.inviter_id, - row.inviter_name, - row.inviter_handle, - row.inviter_avatar_url, + { + id: row.inviter_id, + name: row.inviter_name, + handle: row.inviter_handle, + bio: null, + avatarUrl: row.inviter_avatar_url, + }, + UNKNOWN_PERSON_NAME, ), createdAt: row.created_at, expiresAt: row.expires_at, @@ -151,10 +147,14 @@ function toPendingInviteForUser(row: PendingInviteForUserRowSelect): PendingInvi address: row.address, }, invitedBy: personViewOf( - row.inviter_id, - row.inviter_name, - row.inviter_handle, - row.inviter_avatar_url, + { + id: row.inviter_id, + name: row.inviter_name, + handle: row.inviter_handle, + bio: null, + avatarUrl: row.inviter_avatar_url, + }, + UNKNOWN_PERSON_NAME, ), createdAt: row.created_at, expiresAt: row.expires_at, diff --git a/services/api/src/services/host/organization-admin.drizzle.ts b/services/api/src/services/host/organization-admin.drizzle.ts index cfb37243..8d4e6456 100644 --- a/services/api/src/services/host/organization-admin.drizzle.ts +++ b/services/api/src/services/host/organization-admin.drizzle.ts @@ -1,5 +1,4 @@ -import type postgres from "postgres" -import type { Queryable, Sql } from "../../db/client.js" +import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { isUuid, keysetInstant, @@ -7,7 +6,7 @@ import { paginateKeyset, parseKeysetCursor, } from "../../db/cursor-helpers.js" -import { likeContains } from "../admin/like.js" +import { likeContains } from "../../db/like.js" import { writeHostAudit } from "./host-audit.js" import { adminActorOf, @@ -94,7 +93,7 @@ export function makeOrganizationAdminMethods( * Facet counts span the SEARCHED set but ignore the facets, and only on page one (the shared * admin-list policy: the console reads the chip numbers off the first page). */ - async function facetCounts(search: postgres.Fragment): Promise { + async function facetCounts(search: SqlFragment): Promise { const totals = await sql` SELECT count(*)::int AS all, diff --git a/services/api/src/services/host/organization-profile.drizzle.ts b/services/api/src/services/host/organization-profile.drizzle.ts index 21d3130b..86689fbe 100644 --- a/services/api/src/services/host/organization-profile.drizzle.ts +++ b/services/api/src/services/host/organization-profile.drizzle.ts @@ -1,6 +1,5 @@ import { AppError } from "@civfix/shared" -import type postgres from "postgres" -import type { Queryable, Sql } from "../../db/client.js" +import type { Queryable, Sql, SqlFragment } from "../../db/client.js" import { uploadedByClaimant } from "../media-bindings.js" import { userUploader } from "../media-uploader.js" import { writeHostAudit } from "./host-audit.js" @@ -18,7 +17,7 @@ import type { UpdateOrganizationOutcome, UpdateOrganizationPatch, } from "./organization-repository.types.js" -import { isUniqueViolation } from "../../db/pg-errors.js" +import { isUniqueViolationOn } from "../../db/pg-errors.js" /** The citext unique index on organizations.slug (0105). */ const ORG_SLUG_INDEX = "organizations_slug_uidx" @@ -29,9 +28,7 @@ const ORG_SLUG_INDEX = "organizations_slug_uidx" * partial index, a media claim, ...) is a bug to surface, not a 409 to hand the caller. */ function isSlugTaken(err: unknown): boolean { - if (!isUniqueViolation(err)) return false - const e = err as { constraint_name?: unknown } - return typeof e.constraint_name === "string" && e.constraint_name === ORG_SLUG_INDEX + return isUniqueViolationOn(err, ORG_SLUG_INDEX) } async function claimOrgLogoInTx( @@ -124,7 +121,7 @@ async function recordOperatorVerificationInTx( } function organizationPatchSet(sql: Sql, patch: UpdateOrganizationPatch, now: Date) { - const sets: postgres.Fragment[] = [sql`updated_at = ${now}`] + const sets: SqlFragment[] = [sql`updated_at = ${now}`] if (patch.name !== undefined) sets.push(sql`name = ${patch.name}`) if (patch.slug !== undefined) sets.push(sql`slug = ${patch.slug}`) if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) diff --git a/services/api/src/services/host/organization-verification.drizzle.ts b/services/api/src/services/host/organization-verification.drizzle.ts index ed5f98cf..a5f3ac6a 100644 --- a/services/api/src/services/host/organization-verification.drizzle.ts +++ b/services/api/src/services/host/organization-verification.drizzle.ts @@ -1,7 +1,7 @@ import { AppError, type OrgVerificationKind, type OrgVerificationStatus } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" -import { likeContains } from "../admin/like.js" +import { likeContains } from "../../db/like.js" import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" import { userUploader } from "../media-uploader.js" import { writeHostAudit } from "./host-audit.js" diff --git a/services/api/src/services/host/registration-jobs.ts b/services/api/src/services/host/registration-jobs.ts index 5eda9b81..1155f6c2 100644 --- a/services/api/src/services/host/registration-jobs.ts +++ b/services/api/src/services/host/registration-jobs.ts @@ -4,7 +4,7 @@ import { CHECKIN_NOSHOW_SWEEP_JOB, WAITLIST_EXPIRE_SWEEP_JOB, WAITLIST_PROMOTE_JOB, -} from "./registration-queues.js" +} from "../../lib/queue-names.js" import { makeContainerRegistrationServices } from "./registration-wiring.js" import type { WaitlistPromoteJob } from "./waitlist-service.js" diff --git a/services/api/src/services/host/registration-pages.drizzle.ts b/services/api/src/services/host/registration-pages.drizzle.ts index d5674344..47227232 100644 --- a/services/api/src/services/host/registration-pages.drizzle.ts +++ b/services/api/src/services/host/registration-pages.drizzle.ts @@ -2,13 +2,8 @@ import type { Queryable, Sql, TransactionSql } from "../../db/client.js" import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "../media-bindings.js" import { userUploader } from "../media-uploader.js" import { publicServedKeyExpr } from "../media-served-key.js" -import { - isUniqueViolationOn, - pageColumns, - pageJoins, - toPageRecord, - type PageRowSelect, -} from "./registration-sql.js" +import { isUniqueViolationOn } from "../../db/pg-errors.js" +import { pageColumns, pageJoins, toPageRecord, type PageRowSelect } from "./registration-sql.js" import { eventContextIn, loadLiveQuestions, diff --git a/services/api/src/services/host/registration-queues.ts b/services/api/src/services/host/registration-queues.ts deleted file mode 100644 index 7e4799dc..00000000 --- a/services/api/src/services/host/registration-queues.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const WAITLIST_PROMOTE_JOB = "waitlist.promote" - -export const WAITLIST_EXPIRE_SWEEP_JOB = "waitlist.expire.sweep" - -export const CHECKIN_NOSHOW_SWEEP_JOB = "checkin.noshow.sweep" - -export const REGISTRATION_QUEUE_NAMES = [ - WAITLIST_PROMOTE_JOB, - WAITLIST_EXPIRE_SWEEP_JOB, - CHECKIN_NOSHOW_SWEEP_JOB, -] as const diff --git a/services/api/src/services/host/registration-register.drizzle.ts b/services/api/src/services/host/registration-register.drizzle.ts index 880770f7..2ff58ba0 100644 --- a/services/api/src/services/host/registration-register.drizzle.ts +++ b/services/api/src/services/host/registration-register.drizzle.ts @@ -4,7 +4,8 @@ import type { Queryable, Sql, TransactionSql } from "../../db/client.js" import { constantTimeStringEqual } from "../../auth/crypto.js" import { deterministicUuid } from "../deterministic-uuid.js" import { isEventPubliclyVisible } from "./authz.js" -import { isReservedSeatsBackstopViolation, isUniqueViolationOn } from "./registration-sql.js" +import { isUniqueViolationOn } from "../../db/pg-errors.js" +import { isReservedSeatsBackstopViolation } from "./registration-sql.js" import { isBannedIn, loadRegistrationById, subjectIs } from "./registration-load.drizzle.js" import type { EventRegistrationContext, diff --git a/services/api/src/services/host/registration-roster.drizzle.ts b/services/api/src/services/host/registration-roster.drizzle.ts index 03bf9387..c506d3c2 100644 --- a/services/api/src/services/host/registration-roster.drizzle.ts +++ b/services/api/src/services/host/registration-roster.drizzle.ts @@ -9,7 +9,7 @@ import { parseKeysetCursor, parseNameCursor, } from "../../db/cursor-helpers.js" -import { likeContains } from "../admin/like.js" +import { likeContains } from "../../db/like.js" import { registrationColumns, registrationJoins, diff --git a/services/api/src/services/host/registration-sql.ts b/services/api/src/services/host/registration-sql.ts index 43068aab..1cabbe0f 100644 --- a/services/api/src/services/host/registration-sql.ts +++ b/services/api/src/services/host/registration-sql.ts @@ -25,49 +25,16 @@ import type { TicketTypeRecord, WaitlistRecord, } from "./registration-repository.types.js" -import { PG_UNIQUE_VIOLATION } from "../../db/pg-errors.js" - -const PG_CHECK_VIOLATION = "23514" +import { isCheckViolationOn } from "../../db/pg-errors.js" const RESERVED_SEATS_BACKSTOP_CONSTRAINT = "cleanup_ticket_types_reserved_bounds" const ANSWER_PREVIEW_MAX = 120 -interface PgErrorShape { - code?: unknown - constraint_name?: unknown -} - -function pgErrorConstraint(err: unknown): { code: string; constraint: string } | null { - if (typeof err !== "object" || err === null) return null - const e = err as PgErrorShape - if (typeof e.code !== "string") return null - return { - code: e.code, - constraint: typeof e.constraint_name === "string" ? e.constraint_name : "", - } -} - -export function isUniqueViolationOn(err: unknown, ...constraints: string[]): boolean { - const parsed = pgErrorConstraint(err) - if (parsed === null || parsed.code !== PG_UNIQUE_VIOLATION) return false - return constraints.length === 0 || constraints.includes(parsed.constraint) -} - export const SALES_WINDOW_CONSTRAINT = "cleanup_ticket_types_sales_window" -export function isCheckViolationOn(err: unknown, constraint: string): boolean { - const parsed = pgErrorConstraint(err) - return parsed !== null && parsed.code === PG_CHECK_VIOLATION && parsed.constraint === constraint -} - export function isReservedSeatsBackstopViolation(err: unknown): boolean { - const parsed = pgErrorConstraint(err) - return ( - parsed !== null && - parsed.code === PG_CHECK_VIOLATION && - parsed.constraint === RESERVED_SEATS_BACKSTOP_CONSTRAINT - ) + return isCheckViolationOn(err, RESERVED_SEATS_BACKSTOP_CONSTRAINT) } export interface TicketTypeRowSelect { diff --git a/services/api/src/services/host/registration-ticket-types.drizzle.ts b/services/api/src/services/host/registration-ticket-types.drizzle.ts index 98e73eca..e76ac94b 100644 --- a/services/api/src/services/host/registration-ticket-types.drizzle.ts +++ b/services/api/src/services/host/registration-ticket-types.drizzle.ts @@ -1,10 +1,7 @@ import type { Queryable, Sql, TransactionSql } from "../../db/client.js" import { constantTimeStringEqual } from "../../auth/crypto.js" -import { - isCheckViolationOn, - isUniqueViolationOn, - SALES_WINDOW_CONSTRAINT, -} from "./registration-sql.js" +import { isCheckViolationOn, isUniqueViolationOn } from "../../db/pg-errors.js" +import { SALES_WINDOW_CONSTRAINT } from "./registration-sql.js" import { loadTicketType, loadTicketTypes } from "./registration-load.drizzle.js" import type { CreateTicketTypeOutcome, diff --git a/services/api/src/services/host/registration-waitlist.drizzle.ts b/services/api/src/services/host/registration-waitlist.drizzle.ts index f25f1945..8e18fe9f 100644 --- a/services/api/src/services/host/registration-waitlist.drizzle.ts +++ b/services/api/src/services/host/registration-waitlist.drizzle.ts @@ -1,5 +1,4 @@ -import type postgres from "postgres" -import type { Queryable, Sql, TransactionSql } from "../../db/client.js" +import type { Queryable, Sql, SqlFragment, TransactionSql } from "../../db/client.js" import { constantTimeStringEqual } from "../../auth/crypto.js" import { keysetInstant, @@ -8,8 +7,8 @@ import { parseKeysetCursor, } from "../../db/cursor-helpers.js" import { DEFAULT_EVENT_DURATION_MS, eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" +import { isUniqueViolationOn } from "../../db/pg-errors.js" import { - isUniqueViolationOn, toWaitlistRecord, waitlistColumns, waitlistEntryNotBanned, @@ -382,7 +381,7 @@ async function joinWaitlistIn( async function reserveAndOffer( tx: TransactionSql, candidate: OfferCandidate, - cleanupScope: postgres.Fragment, + cleanupScope: SqlFragment, now: Date, claimExpiresAt: Date, ): Promise { diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index ce7d8b57..e307692e 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -5,7 +5,7 @@ import { makeMediaPresigner } from "../media-presign.js" import { makeGuestPromotionNotifier, type GuestPromotionNotifier } from "../guest-notify.js" import { stripTrailingSlashes, webBaseUrlOf } from "../../lib/base-url.js" import { makeDrizzleGuestRsvpRepository } from "../guest-rsvp-repository.drizzle.js" -import { writeAudit } from "../admin/audit.js" +import { insertAuditRow } from "../admin/audit-repository.drizzle.js" import type { HostCapability } from "@civfix/shared" import { can, type HostStanding } from "@civfix/shared/host" import { makeDrizzleHostStandingRepository } from "./host-standing-repository.drizzle.js" @@ -92,7 +92,7 @@ function lazyNotifier(container: Container, logger?: HostServiceLogger): Registr function auditWriter(sql: Sql, logger?: HostServiceLogger): RegistrationAudit { return async (input) => { try { - await writeAudit(sql, { + await insertAuditRow(sql, { actorId: input.actorId, action: input.action, target: input.target, diff --git a/services/api/src/services/host/waitlist-promotion.ts b/services/api/src/services/host/waitlist-promotion.ts index 7859a3d3..d68f94f5 100644 --- a/services/api/src/services/host/waitlist-promotion.ts +++ b/services/api/src/services/host/waitlist-promotion.ts @@ -1,5 +1,5 @@ import type { Jobs } from "@civfix/shared/interfaces" -import { WAITLIST_PROMOTE_JOB } from "./registration-queues.js" +import { WAITLIST_PROMOTE_JOB } from "../../lib/queue-names.js" export interface WaitlistPromotionLogger { warn(obj: unknown, msg?: string): void diff --git a/services/api/src/services/jurisdiction-service.ts b/services/api/src/services/jurisdiction-service.ts index abf62c13..fe9a4a4c 100644 --- a/services/api/src/services/jurisdiction-service.ts +++ b/services/api/src/services/jurisdiction-service.ts @@ -10,8 +10,7 @@ import { makeDrizzleJurisdictionRepository, type JurisdictionRepository, } from "./jurisdiction-repository.drizzle.js" - -export const JURISDICTION_DISCOVERY_JOB = "jurisdiction.discovery" +import { JURISDICTION_DISCOVERY_JOB } from "../lib/queue-names.js" const CONTACT_STALE_MONTHS = 18 diff --git a/services/api/src/services/media-bindings.ts b/services/api/src/services/media-bindings.ts index c168052d..aa563ef1 100644 --- a/services/api/src/services/media-bindings.ts +++ b/services/api/src/services/media-bindings.ts @@ -1,8 +1,6 @@ import type { Queryable } from "../db/client.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" -export { lockUploadsForClaimIn as lockUploadsForClaim } from "./media-claim-repository.drizzle.js" - export const MEDIA_BINDING_RELATIONS = [ "users.avatar_media_id", "chat_groups.avatar_media_id", diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index 8a7cd71a..24b6ccc7 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -21,6 +21,7 @@ import { type MediaViewAuthorizer, } from "./media-authorization.js" import { MS_PER_SECOND } from "../lib/time.js" +import { MEDIA_CHECKS_JOB } from "../lib/queue-names.js" type MediaPurpose = (typeof MEDIA_PURPOSE_VALUES)[number] @@ -28,8 +29,6 @@ interface IntakeLogger { warn(obj: unknown, msg?: string): void } -export const MEDIA_CHECKS_JOB = "media.checks" - export const MEDIA_GET_URL_TTL_SEC = 15 * 60 export const MEDIA_PRIVATE_GET_URL_TTL_SEC = 5 * 60 diff --git a/services/api/src/services/media-worker-repo.ts b/services/api/src/services/media-worker-repo.ts index 8ac6f615..6af47259 100644 --- a/services/api/src/services/media-worker-repo.ts +++ b/services/api/src/services/media-worker-repo.ts @@ -542,5 +542,4 @@ export function ensureDmPartitionWindow( return ensurePartitionWindow(sqlTag, "dm_messages", now, monthsAhead) } -export { MEDIA_CHECKS_JOB } from "./media-intake-service.js" export type { MediaChecksJob } from "./media-intake-service.js" diff --git a/services/api/src/services/mention-resolver.drizzle.ts b/services/api/src/services/mention-resolver.drizzle.ts deleted file mode 100644 index 2dedd49e..00000000 --- a/services/api/src/services/mention-resolver.drizzle.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveMentionTargets } from "./mention-targets-repository.drizzle.js" diff --git a/services/api/src/services/message-attachments-repository.drizzle.ts b/services/api/src/services/message-attachments-repository.drizzle.ts index ecd440dc..6f8ea88e 100644 --- a/services/api/src/services/message-attachments-repository.drizzle.ts +++ b/services/api/src/services/message-attachments-repository.drizzle.ts @@ -1,7 +1,8 @@ import { AppError } from "@civfix/shared" import type { Queryable } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus } from "@civfix/shared" -import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" +import { claimableAsAttachment } from "./media-bindings.js" +import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" import { mapWithLimit } from "../lib/concurrency.js" import { PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { uploaderServableFilter, uploaderServedKeyExpr } from "./media-served-key.js" @@ -41,7 +42,7 @@ export function makeAttachmentRepo(column: MessageMediaColumn): MessageAttachmen (acc, c) => tx`${acc} AND ${tx(c)} IS NULL`, tx``, ) - await lockUploadsForClaim(tx, uploadIds) + await lockUploadsForClaimIn(tx, uploadIds) const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET ${tx(column)} = ${messageId}, chat_message_created_at = ${messageCreatedAt} diff --git a/services/api/src/services/message-mentions.drizzle.ts b/services/api/src/services/message-mentions.drizzle.ts deleted file mode 100644 index 03488415..00000000 --- a/services/api/src/services/message-mentions.drizzle.ts +++ /dev/null @@ -1 +0,0 @@ -export { loadMentionsFor, makeMentionRepo } from "./message-mentions-repository.drizzle.js" diff --git a/services/api/src/services/notification-repository.drizzle.ts b/services/api/src/services/notification-repository.drizzle.ts index 753495b7..5f03c1f6 100644 --- a/services/api/src/services/notification-repository.drizzle.ts +++ b/services/api/src/services/notification-repository.drizzle.ts @@ -1,4 +1,4 @@ -import type { Sql } from "../db/client.js" +import type { Sql, SqlFragment } from "../db/client.js" import { keysetInstant, keysetPredicate, @@ -384,8 +384,8 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit } } -function prefsSetFragments(sql: Sql, patch: NotificationPrefsPatch): Array> { - const setFragments: Array> = [] +function prefsSetFragments(sql: Sql, patch: NotificationPrefsPatch): SqlFragment[] { + const setFragments: SqlFragment[] = [] if (patch.push !== undefined) setFragments.push(sql`push = ${patch.push}`) if (patch.cleanupChat !== undefined) setFragments.push(sql`cleanup_chat = ${patch.cleanupChat}`) if (patch.reportUpdates !== undefined) @@ -426,6 +426,6 @@ function isEmptyPatch(patch: NotificationPrefsPatch): boolean { ) } -function joinSet(sql: Sql, fragments: Array>): ReturnType { +function joinSet(sql: Sql, fragments: SqlFragment[]): SqlFragment { return fragments.reduce((acc, frag, i) => (i === 0 ? frag : sql`${acc}, ${frag}`)) } diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index e5f774dd..d982e268 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -1,4 +1,3 @@ -import type postgres from "postgres" import { AppError, avatarGradient } from "@civfix/shared" import type { LinkedEventRef, @@ -10,7 +9,7 @@ import type { PostRefDTO, UserMentionDTO, } from "@civfix/shared" -import type { Queryable, Sql } from "../db/client.js" +import type { Queryable, Sql, SqlFragment } from "../db/client.js" import type { POST_KIND_VALUES, REPORT_VISIBILITY_VALUES } from "../db/schema/types.js" import { keysetInstant, @@ -18,9 +17,10 @@ import { paginateKeyset, parseKeysetCursor, } from "../db/cursor-helpers.js" -import { loadMentionsFor, makeMentionRepo } from "./message-mentions.drizzle.js" +import { loadMentionsFor, makeMentionRepo } from "./message-mentions-repository.drizzle.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" -import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" +import { claimableAsAttachment } from "./media-bindings.js" +import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" import { uploadersOf } from "./media-uploader.js" import { publicServedKeyExpr } from "./media-served-key.js" import { mapWithLimit } from "../lib/concurrency.js" @@ -51,7 +51,7 @@ const SHARED_EVENT_EXCERPT = "Shared an event" const SHARED_REPORT_EXCERPT = "Shared a report" const UNTITLED_REPORT_TITLE = "Report" -function postColumns(sql: Queryable): postgres.Fragment { +function postColumns(sql: Queryable): SqlFragment { return sql` p.id, p.author_id, p.kind, p.body, p.reply_to_id, p.thread_root_id, p.repost_of_id, p.event_id, p.report_id, p.like_count, p.repost_count, p.reply_count, p.save_count, @@ -463,7 +463,7 @@ export async function tombstonePostInTx(tx: Queryable, postId: string): Promise< return true } -function feedFilterClause(sql: Queryable, filter: FeedFilter): postgres.Fragment { +function feedFilterClause(sql: Queryable, filter: FeedFilter): SqlFragment { if (filter === "events") return sql`AND p.event_id IS NOT NULL` if (filter === "fixes") { return sql`AND EXISTS (SELECT 1 FROM reports fr WHERE fr.id = p.report_id AND fr.status = 'resolved')` @@ -471,7 +471,7 @@ function feedFilterClause(sql: Queryable, filter: FeedFilter): postgres.Fragment return sql`` } -function viewerBlockClause(sql: Queryable, viewerId: string): postgres.Fragment { +function viewerBlockClause(sql: Queryable, viewerId: string): SqlFragment { return sql`AND NOT EXISTS ( SELECT 1 FROM user_blocks b WHERE (b.blocker_id = ${viewerId} AND b.blocked_id = p.author_id) @@ -479,13 +479,10 @@ function viewerBlockClause(sql: Queryable, viewerId: string): postgres.Fragment )` } -export function feedCandidatesStatement( - sql: Queryable, - args: FeedCandidateArgs, -): postgres.Fragment { +export function feedCandidatesStatement(sql: Queryable, args: FeedCandidateArgs): SqlFragment { const viewerId = args.viewerId const filterClause = feedFilterClause(sql, args.filter) - const eligible = (): postgres.Fragment => sql` + const eligible = (): SqlFragment => sql` p.deleted_at IS NULL AND p.reply_to_id IS NULL AND p.visibility = 'public' @@ -1135,7 +1132,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep const postId = inserted[0]!.id if (args.mediaUploadIds.length > 0) { - await lockUploadsForClaim(tx, args.mediaUploadIds) + await lockUploadsForClaimIn(tx, args.mediaUploadIds) const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET post_id = ${postId}, purpose = 'post' diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index d4b4c279..d3593c10 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -17,7 +17,7 @@ import type { UserChannel } from "@civfix/shared/interfaces" import { randomInt } from "node:crypto" import type { Sql } from "../db/client.js" import { assertNoSlur } from "../abuse/slur-filter.js" -import { resolveMentionTargets } from "./mention-resolver.drizzle.js" +import { resolveMentionTargets } from "./mention-targets-repository.drizzle.js" import { parseTimeCursor } from "../db/cursor-helpers.js" import { NIL_VIEWER_ID, diff --git a/services/api/src/services/report-chat-send-wiring.ts b/services/api/src/services/report-chat-send-wiring.ts index 33fc85a9..2fee1f48 100644 --- a/services/api/src/services/report-chat-send-wiring.ts +++ b/services/api/src/services/report-chat-send-wiring.ts @@ -18,7 +18,7 @@ import { roomKeyFor } from "../ws/gateway.js" import type { ChatMentionRecordSeam } from "./chat-mention-resolver.js" import type { ReportChatSendDeps } from "./report-chat-send.js" import type { ChatRepository } from "./chat-repository.drizzle.js" -import { writeAudit } from "./admin/audit.js" +import { insertAuditRow } from "./admin/audit-repository.drizzle.js" const REPORT_MESSAGE_POSTED_AUDIT_ACTION = "report.message_posted" @@ -34,7 +34,7 @@ export function makeAuditedReportChatPersist( return (input, { actingUserId }) => chatRepo().insertMessage(input, randomUUID(), { inTx: async (tx, row) => { - await writeAudit(tx, { + await insertAuditRow(tx, { actorId: actingUserId, action: REPORT_MESSAGE_POSTED_AUDIT_ACTION, target: `report:${input.cleanupId}`, diff --git a/services/api/src/services/report-city-forward-wiring.ts b/services/api/src/services/report-city-forward-wiring.ts index 1366384b..3a40cb29 100644 --- a/services/api/src/services/report-city-forward-wiring.ts +++ b/services/api/src/services/report-city-forward-wiring.ts @@ -2,7 +2,10 @@ import type { ChatMessageDTO } from "@civfix/shared" import type { Container } from "../di.js" import { makeOutboundMailService, type OutboundMailService } from "./admin/outbound-mail-service.js" import { makeDrizzleMailRepository } from "./admin/mail-repository.drizzle.js" -import { makeReportForwardAudit, type ReportForwardAudit } from "./report-forward-audit.drizzle.js" +import { + makeDrizzleReportForwardAuditRepository, + type ReportForwardAuditRepository, +} from "./report-forward-audit-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "./discussion-repository.drizzle.js" import type { DiscussionRepository } from "./discussion-types.js" import { @@ -40,7 +43,7 @@ export function makeContainerReportCityForward( overrides: ReportCityForwardWiringOverrides = {}, ): ReportCityForwardEffect { let outboundMail: OutboundMailService | undefined - let audit: ReportForwardAudit | undefined + let audit: ReportForwardAuditRepository | undefined let reportRepo: DiscussionRepository | undefined let throttle: CityForwardGate | undefined @@ -54,7 +57,7 @@ export function makeContainerReportCityForward( return async (reportId, message, actorUserId) => { outboundMail ??= makeContainerOutboundMail(container) - audit ??= makeReportForwardAudit(container.getDb().sql) + audit ??= makeDrizzleReportForwardAuditRepository(container.getDb().sql) const report = await getReportRepo().findReportForDiscussion(reportId) if (report === null) return const body = typeof message.body === "string" ? message.body : "" diff --git a/services/api/src/services/report-city-forward.ts b/services/api/src/services/report-city-forward.ts index 1d7e176f..0628c24b 100644 --- a/services/api/src/services/report-city-forward.ts +++ b/services/api/src/services/report-city-forward.ts @@ -2,7 +2,7 @@ import { buildDiscussionForwardPacket } from "./admin/mail-format.js" import { parseCityMention, effectiveJurisdictionHandle } from "./discussion-mentions.js" import { replySubject, type OutboundMailService } from "./admin/outbound-mail-service.js" import type { MailThreadRecord } from "./admin/mail-repository.drizzle.js" -import type { ReportForwardAudit } from "./report-forward-audit.drizzle.js" +import type { ReportForwardAuditRepository } from "./report-forward-audit-repository.drizzle.js" import type { ReportJurisdictionView } from "./discussion-types.js" import type { CounterStore } from "../abuse/counter-store.js" @@ -45,7 +45,7 @@ export interface CityForwardLogger { export interface CityForwardOptions { canForward?: CityForwardGate - audit?: ReportForwardAudit + audit?: ReportForwardAuditRepository messageId?: string logger?: CityForwardLogger } @@ -160,7 +160,7 @@ async function existingReportThread( async function writeAudit( opts: CityForwardOptions, geoid: string, - write: (audit: ReportForwardAudit, messageId: string) => Promise, + write: (audit: ReportForwardAuditRepository, messageId: string) => Promise, failureMessage: string, ): Promise { if (opts.audit === undefined || opts.messageId === undefined) return diff --git a/services/api/src/services/report-forward-audit.drizzle.ts b/services/api/src/services/report-forward-audit.drizzle.ts deleted file mode 100644 index eb2cd8db..00000000 --- a/services/api/src/services/report-forward-audit.drizzle.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - makeDrizzleReportForwardAuditRepository as makeReportForwardAudit, - type ReportForwardAuditRepository as ReportForwardAudit, -} from "./report-forward-audit-repository.drizzle.js" diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index d31a5ec6..e4beb7fc 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -16,7 +16,7 @@ import { } from "../db/cursor-helpers.js" import { isPubliclyVisibleStatus, ownerStatusTransition } from "./report-visibility.js" import { allocateReportReferenceCode } from "../db/reference-code.js" -import { escapeLike } from "./admin/like.js" +import { likeContains } from "../db/like.js" import type { BBox, CreateReportTxArgs, @@ -421,7 +421,7 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { const textFilter: SqlFragment = args.q !== null ? (() => { - const needle = `%${escapeLike(args.q)}%` + const needle = likeContains(args.q) return sql`AND (r.title ILIKE ${needle} ESCAPE '\\' OR r.addr ILIKE ${needle} ESCAPE '\\')` })() : sql`` diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index 11ee3be4..1d7aba91 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -20,6 +20,7 @@ import { isUuid } from "../db/cursor-helpers.js" import { UNKNOWN_JURCODE } from "../db/reference-code.js" import { toLinkedEventRef, type LinkedEventView } from "./cleanup-service.js" import { mapWithLimit } from "../lib/concurrency.js" +import { REPORT_AUTOFORWARD_JOB } from "../lib/queue-names.js" import { PRESIGN_CONCURRENCY } from "./media-presign.js" import { clusterByZoom, @@ -37,7 +38,6 @@ import { type ResolvedAddress, } from "./address-resolver.js" import { - REPORT_AUTOFORWARD_JOB, REPORT_CREATE_SCOPE, REPORT_VISIBILITY_TIMELINE_KIND, REPORTS_DEFAULT_LIMIT, diff --git a/services/api/src/services/report-service.types.ts b/services/api/src/services/report-service.types.ts index 7cafeb0d..074e9e3d 100644 --- a/services/api/src/services/report-service.types.ts +++ b/services/api/src/services/report-service.types.ts @@ -20,7 +20,6 @@ import type { AddressResolver } from "./address-resolver.js" export const REPORT_CREATE_SCOPE = "report_create" -export const REPORT_AUTOFORWARD_JOB = "report.autoforward" export interface ReportAutoForwardJob { reportId: string } diff --git a/services/api/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index ad771f6b..1f8dfc6d 100644 --- a/services/api/src/services/social-repository.drizzle.ts +++ b/services/api/src/services/social-repository.drizzle.ts @@ -16,7 +16,6 @@ import type { EventKind, EventVisibility, SocialLinks, - UserSearchResultDTO, } from "@civfix/shared" import { encodeNameCursor, @@ -29,27 +28,10 @@ import { parseNameCursor, parseTimeCursor, } from "../db/cursor-helpers.js" -import { escapeLike } from "./admin/like.js" +import { likeContains } from "../db/like.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" import { publicServedKeyExpr } from "./media-served-key.js" import { CIVFIX_OFFICIAL_USER_ID } from "../auth/official-account.js" -import { makeDrizzleUserSearchRepository } from "./user-search-repository.drizzle.js" - -export const searchByHandlePrefix = ( - sql: Sql, - q: string, - viewerId: string, - limit: number, -): Promise => - makeDrizzleUserSearchRepository(sql).searchByHandlePrefix(q, viewerId, limit) - -export const searchMentionable = ( - sql: Sql, - q: string, - viewerId: string, - limit: number, -): Promise => - makeDrizzleUserSearchRepository(sql).searchMentionable(q, viewerId, limit) const SUGGEST_NEARBY_METERS = 25_000 @@ -183,11 +165,11 @@ function toCleanupRecord(r: CleanupRowSelect): CleanupRecord { } } -function organizedIds(sql: Sql, userId: string): ReturnType { +function organizedIds(sql: Sql, userId: string): SqlFragment { return sql`SELECT id AS cleanup_id FROM cleanups WHERE organizer_user_id = ${userId}` } -function organizedOrAttendedIds(sql: Sql, userId: string): ReturnType { +function organizedOrAttendedIds(sql: Sql, userId: string): SqlFragment { return sql` SELECT id AS cleanup_id FROM cleanups WHERE organizer_user_id = ${userId} UNION @@ -197,7 +179,7 @@ function organizedOrAttendedIds(sql: Sql, userId: string): ReturnType { function profileEventRows( sql: Sql, - args: { ids: ReturnType; where: ReturnType; order: ReturnType; limit: number }, + args: { ids: SqlFragment; where: SqlFragment; order: SqlFragment; limit: number }, ): Promise { return sql` WITH ids AS (${args.ids}) @@ -260,7 +242,7 @@ function pageConnections(rows: ConnectionRow[], limit: number): PeoplePage { async function connectionsPage( sql: Sql, args: { viewerId: string | null; cursor: string | null; limit: number }, - joinPredicate: ReturnType, + joinPredicate: SqlFragment, ): Promise { const cursor = parseKeysetCursor(args.cursor) const viewerId = args.viewerId @@ -453,7 +435,7 @@ export async function explainSuggestFollows( } export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { - async function findPerson(keyFilter: ReturnType): Promise { + async function findPerson(keyFilter: SqlFragment): Promise { const rows = await sql` SELECT u.id, @@ -486,7 +468,7 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { async listPeople(args): Promise { const cursor = parseNameCursor(args.cursor) const viewerId = args.viewerId - const term = args.q !== null ? "%" + escapeLike(args.q) + "%" : null + const term = args.q !== null ? likeContains(args.q) : null const qFilter = term !== null ? sql`AND ((u.handle::text) ILIKE ${term} ESCAPE '\\' OR u.display_name ILIKE ${term} ESCAPE '\\')` diff --git a/services/api/src/services/user-search-repository.drizzle.ts b/services/api/src/services/user-search-repository.drizzle.ts index 6cb0d5d5..f747492e 100644 --- a/services/api/src/services/user-search-repository.drizzle.ts +++ b/services/api/src/services/user-search-repository.drizzle.ts @@ -1,7 +1,7 @@ import type { Sql } from "../db/client.js" import { avatarGradient } from "@civfix/shared" import type { UserSearchResultDTO } from "@civfix/shared" -import { escapeLike } from "./admin/like.js" +import { likeContains, likePrefix } from "../db/like.js" interface UserSearchRow { id: string @@ -35,7 +35,7 @@ export function makeDrizzleUserSearchRepository(sql: Sql): UserSearchRepository viewerId: string, limit: number, ): Promise { - const prefix = escapeLike(q) + "%" + const prefix = likePrefix(q) const rows = await sql` SELECT u.id, u.handle, u.display_name, u.avatar_url FROM users u @@ -65,7 +65,7 @@ export function makeDrizzleUserSearchRepository(sql: Sql): UserSearchRepository viewerId: string, limit: number, ): Promise { - const term = "%" + escapeLike(q) + "%" + const term = likeContains(q) const rows = await sql` SELECT u.id, u.handle, u.display_name, u.avatar_url FROM users u diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 8d2b05d3..1a57f6fe 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -7,7 +7,8 @@ import type { } from "@civfix/shared" import type { Queryable, Sql } from "../db/client.js" import { keysetInstant, keysetPredicate, pageWith, paginateKeyset } from "../db/cursor-helpers.js" -import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" +import { hiddenIdentity } from "./hidden-identity.js" +import { blockedPairExpr } from "./blocks-sql.js" import { publicServedKeyExpr } from "./media-served-key.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" import { diff --git a/services/api/test/integration/admin-audit.test.ts b/services/api/test/integration/admin-audit.test.ts index 29becc98..538120f7 100644 --- a/services/api/test/integration/admin-audit.test.ts +++ b/services/api/test/integration/admin-audit.test.ts @@ -3,15 +3,18 @@ * (makeDrizzleAuditRepository) against a live Postgres/PostGIS container via withPg (canonical migrations + * the jurisdiction seed), so the audit_log read (LEFT JOIN users for the actor name + the actor/action/ * target filters + the keyset cursor + the jsonb meta round-trip) runs against the real schema. Rows are - * written through the production writeAudit helper so the read is proven end-to-end with the writer. + * written through the production insertAuditRow helper so the read is proven end-to-end with the writer. * * When Docker is unavailable the whole describe block SKIPS, so the local suite stays green; CI runs it. */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" -import { makeDrizzleAuditRepository } from "../../src/services/admin/audit-repository.drizzle.js" -import { writeAudit, type AdminAuditAction } from "../../src/services/admin/audit.js" +import { + insertAuditRow, + makeDrizzleAuditRepository, +} from "../../src/services/admin/audit-repository.drizzle.js" +import type { AdminAuditAction } from "../../src/services/admin/audit.js" import type { AuditRepository } from "../../src/services/admin/audit-service.js" const pg = await withPg() @@ -43,13 +46,13 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => it("reads rows newest-first, joining the actor display name + the jsonb meta", async () => { const actor = await insertUser(h, "Alice Operator") - await writeAudit(h.sql, { + await insertAuditRow(h.sql, { actorId: actor, action: "report.status_changed", target: "report:111", meta: { from: "submitted", to: "in_progress" }, }) - await writeAudit(h.sql, { actorId: actor, action: "user.banned", target: "user:222" }) + await insertAuditRow(h.sql, { actorId: actor, action: "user.banned", target: "user:222" }) const page = await repo.list({ actor: null, @@ -69,8 +72,8 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => it("filters by action (ILIKE substring)", async () => { const actor = await insertUser(h, "Op") - await writeAudit(h.sql, { actorId: actor, action: "report.flagged", target: "report:1" }) - await writeAudit(h.sql, { actorId: actor, action: "user.banned", target: "user:1" }) + await insertAuditRow(h.sql, { actorId: actor, action: "report.flagged", target: "report:1" }) + await insertAuditRow(h.sql, { actorId: actor, action: "user.banned", target: "user:1" }) const page = await repo.list({ actor: null, @@ -85,8 +88,8 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => it("filters by actor name OR exact actor id", async () => { const alice = await insertUser(h, "Alice") const bob = await insertUser(h, "Bob") - await writeAudit(h.sql, { actorId: alice, action: "report.flagged", target: "t1" }) - await writeAudit(h.sql, { actorId: bob, action: "event.flagged", target: "t2" }) + await insertAuditRow(h.sql, { actorId: alice, action: "report.flagged", target: "t1" }) + await insertAuditRow(h.sql, { actorId: bob, action: "event.flagged", target: "t2" }) const byName = await repo.list({ actor: "alice", @@ -109,7 +112,7 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => it("a non-uuid actor filter does not error (matches by name only)", async () => { const alice = await insertUser(h, "Alice") - await writeAudit(h.sql, { actorId: alice, action: "report.flagged", target: "t1" }) + await insertAuditRow(h.sql, { actorId: alice, action: "report.flagged", target: "t1" }) const page = await repo.list({ actor: "not-a-uuid", action: null, @@ -130,7 +133,7 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => "user.flagged", ] for (const [i, action] of actions.entries()) { - await writeAudit(h.sql, { actorId: actor, action, target: `t${i}` }) + await insertAuditRow(h.sql, { actorId: actor, action, target: `t${i}` }) } const first = await repo.list({ actor: null, diff --git a/services/api/test/integration/admin-keyset-precision-pg.test.ts b/services/api/test/integration/admin-keyset-precision-pg.test.ts index 41ee86c2..a30572a5 100644 --- a/services/api/test/integration/admin-keyset-precision-pg.test.ts +++ b/services/api/test/integration/admin-keyset-precision-pg.test.ts @@ -6,9 +6,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" -import { makeDrizzleAuditRepository } from "../../src/services/admin/audit-repository.drizzle.js" +import { + insertAuditRow, + makeDrizzleAuditRepository, +} from "../../src/services/admin/audit-repository.drizzle.js" import { makeDrizzleActivityRepository } from "../../src/services/admin/activity-repository.drizzle.js" -import { writeAudit } from "../../src/services/admin/audit.js" const pg = await withPg() @@ -18,7 +20,7 @@ const PAGE_GUARD = 10 async function writeBurst(h: PgHarness): Promise { await h.sql.begin(async (tx) => { for (let i = 0; i < ROWS_IN_ONE_TX; i++) { - await writeAudit(tx, { actorId: null, action: "user.banned", target: `user:${i}` }) + await insertAuditRow(tx, { actorId: null, action: "user.banned", target: `user:${i}` }) } }) } diff --git a/services/api/test/integration/auth-pg.test.ts b/services/api/test/integration/auth-pg.test.ts index 522d5abb..44f60e97 100644 --- a/services/api/test/integration/auth-pg.test.ts +++ b/services/api/test/integration/auth-pg.test.ts @@ -17,7 +17,7 @@ import { seedMediaAsset } from "../helpers/media-pg.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { SessionService } from "../../src/auth/session-service.js" import { OTP_REFUSED_UNVERIFIED_ACCOUNT_ACTION, OtpService } from "../../src/auth/otp.js" -import { writeAudit } from "../../src/services/admin/audit.js" +import { insertAuditRow } from "../../src/services/admin/audit-repository.drizzle.js" import { PgSessionStore, PgUserStore, @@ -224,7 +224,7 @@ describe.skipIf(!pg)("auth integration: Postgres stores", () => { cache: new InMemoryCacheClient(), mailer, audit: async (input) => { - await writeAudit(h.sql, input) + await insertAuditRow(h.sql, input) }, }) const email = "planted.otp.it@example.com" diff --git a/services/api/test/integration/dm-pg.test.ts b/services/api/test/integration/dm-pg.test.ts index a501027f..fdf22ab3 100644 --- a/services/api/test/integration/dm-pg.test.ts +++ b/services/api/test/integration/dm-pg.test.ts @@ -4,7 +4,7 @@ import { DELETED_USER_LABEL } from "@civfix/shared" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { makeDrizzleDmRepository } from "../../src/services/dm-repository.drizzle.js" import { makeDrizzleBlocksRepository } from "../../src/services/blocks-repository.drizzle.js" -import { searchByHandlePrefix } from "../../src/services/social-repository.drizzle.js" +import { makeDrizzleUserSearchRepository } from "../../src/services/user-search-repository.drizzle.js" const pg = await withPg() @@ -164,7 +164,11 @@ describe.skipIf(!pg)("direct messages (integration)", () => { const blocked = await newUser("Blocked", { handle: `zz${tag}_blocked` }) await makeDrizzleBlocksRepository(h.sql).block(blocked, viewer) - const results = await searchByHandlePrefix(h.sql, `zz${tag}_`, viewer, 20) + const results = await makeDrizzleUserSearchRepository(h.sql).searchByHandlePrefix( + `zz${tag}_`, + viewer, + 20, + ) const ids = new Set(results.map((r) => r.id)) expect(ids.has(match)).toBe(true) expect(ids.has(dmOff)).toBe(false) diff --git a/services/api/test/integration/media-pg.test.ts b/services/api/test/integration/media-pg.test.ts index 26d27040..a818b16b 100644 --- a/services/api/test/integration/media-pg.test.ts +++ b/services/api/test/integration/media-pg.test.ts @@ -5,7 +5,7 @@ import { publishMediaAsReady } from "../helpers/media-pg.js" import { buildServer } from "../../src/server.js" import { buildContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" -import { MEDIA_CHECKS_JOB } from "../../src/services/media-intake-service.js" +import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import type { FakeStorage, FakeJobs } from "@civfix/shared/fakes" const pg = await withPg() diff --git a/services/api/test/integration/report-forward-audit-pg.test.ts b/services/api/test/integration/report-forward-audit-pg.test.ts index 46d76ed0..73f0d04c 100644 --- a/services/api/test/integration/report-forward-audit-pg.test.ts +++ b/services/api/test/integration/report-forward-audit-pg.test.ts @@ -4,7 +4,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" -import { makeReportForwardAudit } from "../../src/services/report-forward-audit.drizzle.js" +import { makeDrizzleReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.drizzle.js" import { makeDrizzleChatRepository } from "../../src/services/chat-repository.drizzle.js" const pg = await withPg() @@ -27,7 +27,7 @@ describe.skipIf(!pg)("report_message_forwards audit writes (integration)", () => } it("recordMention inserts a mentioned-but-not-forwarded row (forwarded_at NULL)", async () => { - const audit = makeReportForwardAudit(h.sql) + const audit = makeDrizzleReportForwardAuditRepository(h.sql) const msg = randomUUID() await audit.recordMention(msg, GEO) @@ -35,7 +35,7 @@ describe.skipIf(!pg)("report_message_forwards audit writes (integration)", () => }) it("recordMention is idempotent and does not clobber an already-forwarded row", async () => { - const audit = makeReportForwardAudit(h.sql) + const audit = makeDrizzleReportForwardAuditRepository(h.sql) const msg = randomUUID() await audit.recordMention(msg, GEO) @@ -54,7 +54,7 @@ describe.skipIf(!pg)("report_message_forwards audit writes (integration)", () => }) it("markForwarded stamps forwarded_at and keeps the earliest time on a re-run", async () => { - const audit = makeReportForwardAudit(h.sql) + const audit = makeDrizzleReportForwardAuditRepository(h.sql) const msg = randomUUID() await audit.recordMention(msg, GEO) @@ -112,7 +112,7 @@ describe.skipIf(!pg)( it("reportHistory reports forwardedToCity:true + a forwarded cityMention once a matching forward is stamped", async () => { const chat = makeDrizzleChatRepository(h.sql) - const audit = makeReportForwardAudit(h.sql) + const audit = makeDrizzleReportForwardAuditRepository(h.sql) const geoid = await seedJurisdiction() const reportId = await newReport(geoid) const userId = await newUser() @@ -147,7 +147,7 @@ describe.skipIf(!pg)( it("a mentioned-but-not-forwarded audit row (forwarded_at NULL) keeps forwardedToCity false", async () => { const chat = makeDrizzleChatRepository(h.sql) - const audit = makeReportForwardAudit(h.sql) + const audit = makeDrizzleReportForwardAuditRepository(h.sql) const geoid = await seedJurisdiction() const reportId = await newReport(geoid) const userId = await newUser() diff --git a/services/api/test/unit/admin-host-platform-routes.test.ts b/services/api/test/unit/admin-host-platform-routes.test.ts index 5792a126..ebea3179 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -325,7 +325,7 @@ describe("admin legal versions", () => { describe("admin org verification decision", () => { // The operator audit is written inside the repository transaction, not by the route like sibling admin - // mutations, so the route's missing writeAudit call is a deliberate no-double-write, not a gap. + // mutations, so the route's missing insertAuditRow call is a deliberate no-double-write, not a gap. it("approves, audits the operator + decision, and notifies the owner", async () => { const h = await harness() const id = await seedPendingOrg(h.orgs) diff --git a/services/api/test/unit/admin-jurisdiction-contacts.test.ts b/services/api/test/unit/admin-jurisdiction-contacts.test.ts index ee989f58..47a8f6d3 100644 --- a/services/api/test/unit/admin-jurisdiction-contacts.test.ts +++ b/services/api/test/unit/admin-jurisdiction-contacts.test.ts @@ -12,10 +12,10 @@ import { directoryMethod, directoryStatus, hasAnyContact, - OUTREACH_DIGEST_JOB, type JurisdictionContactsService, type JurisdictionDirectoryRecord, } from "../../src/services/admin/jurisdiction-contacts-service.js" +import { OUTREACH_DIGEST_JOB } from "../../src/lib/queue-names.js" const NOW = new Date("2026-06-06T00:00:00.000Z") const THROTTLE_DAYS = 7 diff --git a/services/api/test/unit/admin-read-audit.test.ts b/services/api/test/unit/admin-read-audit.test.ts index 04377212..d93678c7 100644 --- a/services/api/test/unit/admin-read-audit.test.ts +++ b/services/api/test/unit/admin-read-audit.test.ts @@ -34,7 +34,7 @@ describe("L4: auditRead", () => { const insert = fake.statements.find((s) => /INSERT INTO audit_log/i.test(s.sql)) expect(insert).toBeDefined() - // actor_id, action, target, meta: the writeAudit column order. + // actor_id, action, target, meta: the insertAuditRow column order. expect(insert?.values.slice(0, 3)).toEqual(["op-1", "user.messages_viewed", "user:u-1"]) expect(insert?.values[3]).toMatchObject({ returned: 20 }) expect(warnings).toHaveLength(0) diff --git a/services/api/test/unit/chat-fanout-job.test.ts b/services/api/test/unit/chat-fanout-job.test.ts index 937270bc..5e331d63 100644 --- a/services/api/test/unit/chat-fanout-job.test.ts +++ b/services/api/test/unit/chat-fanout-job.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect } from "vitest" import { FakePushSender } from "@civfix/shared/fakes" import type { ChatMessageDTO, PersonDTO } from "@civfix/shared" import { - CHAT_ROOM_FANOUT_JOB, makeRoomFanoutDispatcher, parseChatRoomFanoutJob, roomFanoutSingletonKey, runChatRoomFanout, } from "../../src/services/chat-fanout-jobs.js" +import { CHAT_ROOM_FANOUT_JOB } from "../../src/lib/queue-names.js" import { makeRoomFanoutNotifier, runRoomFanout, diff --git a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts index 9967f0d1..095b998a 100644 --- a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts +++ b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts @@ -12,10 +12,10 @@ import { InMemoryChatRepository, MockConnection } from "../helpers/chat.js" import { makeGroupChatNotifier } from "../../src/services/group-chat-notifier.js" import { makeReportChatNotifier } from "../../src/services/report-chat-notifier.js" import { - CHAT_ROOM_FANOUT_JOB, makeRoomFanoutDispatcher, roomFanoutSingletonKey, } from "../../src/services/chat-fanout-jobs.js" +import { CHAT_ROOM_FANOUT_JOB } from "../../src/lib/queue-names.js" import { ROOM_FANOUT_THROTTLE_MS } from "../../src/services/chat-room-fanout-notifier.js" import { roomKeyFor } from "../../src/ws/gateway.js" import { roomFanoutMode } from "../../src/routes/chat-gateway-wiring.js" diff --git a/services/api/test/unit/city-forward-logger-wiring.test.ts b/services/api/test/unit/city-forward-logger-wiring.test.ts index 003b095e..eff698ab 100644 --- a/services/api/test/unit/city-forward-logger-wiring.test.ts +++ b/services/api/test/unit/city-forward-logger-wiring.test.ts @@ -67,8 +67,8 @@ vi.mock("../../src/services/admin/outbound-mail-service.js", async (importOrigin } }) -vi.mock("../../src/services/report-forward-audit.drizzle.js", () => ({ - makeReportForwardAudit: () => ({ +vi.mock("../../src/services/report-forward-audit-repository.drizzle.js", () => ({ + makeDrizzleReportForwardAuditRepository: () => ({ recordMention: () => Promise.resolve(), markForwarded: () => Promise.resolve(), }), diff --git a/services/api/test/unit/city-forward-logging.test.ts b/services/api/test/unit/city-forward-logging.test.ts index af77598f..4ea68eef 100644 --- a/services/api/test/unit/city-forward-logging.test.ts +++ b/services/api/test/unit/city-forward-logging.test.ts @@ -4,7 +4,7 @@ import { makeCityForwardThrottle, } from "../../src/services/report-city-forward.js" import type { CounterStore } from "../../src/abuse/counter-store.js" -import type { ReportForwardAudit } from "../../src/services/report-forward-audit.drizzle.js" +import type { ReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.drizzle.js" import type { OutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import type { MailThreadRecord } from "../../src/services/admin/mail-repository.drizzle.js" @@ -73,10 +73,10 @@ describe("city forward failures are logged, not swallowed", () => { it("logs a failed audit write without failing the forward", async () => { const { lines, logger } = capture() - const audit: ReportForwardAudit = { + const audit: ReportForwardAuditRepository = { recordMention: () => Promise.reject(new Error("audit down")), markForwarded: () => Promise.resolve(), - } as unknown as ReportForwardAudit + } as unknown as ReportForwardAuditRepository const res = await forwardReportCityMention(mail({}), ctx, "@sf please fix", CREATED, { logger, audit, diff --git a/services/api/test/unit/city-forward-wiring.test.ts b/services/api/test/unit/city-forward-wiring.test.ts index d36b11df..f063f0c9 100644 --- a/services/api/test/unit/city-forward-wiring.test.ts +++ b/services/api/test/unit/city-forward-wiring.test.ts @@ -84,8 +84,8 @@ vi.mock("../../src/services/admin/outbound-mail-service.js", async (importOrigin } }) -vi.mock("../../src/services/report-forward-audit.drizzle.js", () => ({ - makeReportForwardAudit: () => ({ +vi.mock("../../src/services/report-forward-audit-repository.drizzle.js", () => ({ + makeDrizzleReportForwardAuditRepository: () => ({ recordMention: () => Promise.resolve(), markForwarded: () => Promise.resolve(), }), diff --git a/services/api/test/unit/cleanup-remove-member-waitlist.test.ts b/services/api/test/unit/cleanup-remove-member-waitlist.test.ts index bf58f855..4b4df779 100644 --- a/services/api/test/unit/cleanup-remove-member-waitlist.test.ts +++ b/services/api/test/unit/cleanup-remove-member-waitlist.test.ts @@ -4,7 +4,7 @@ import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" import { makeCleanupService } from "../../src/services/cleanup-service.js" -import { WAITLIST_PROMOTE_JOB } from "../../src/services/host/registration-queues.js" +import { WAITLIST_PROMOTE_JOB } from "../../src/lib/queue-names.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { makeFakeSql } from "../helpers/fake-sql.js" import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" diff --git a/services/api/test/unit/cleanup-service.test.ts b/services/api/test/unit/cleanup-service.test.ts index 801ce442..029b5055 100644 --- a/services/api/test/unit/cleanup-service.test.ts +++ b/services/api/test/unit/cleanup-service.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach } from "vitest" import { FakeJobs } from "@civfix/shared/fakes" import { makeCleanupService, - CLEANUP_CANCEL_FANOUT_JOB, ATTENDEES_DEFAULT_LIMIT, MAX_BRING_ITEMS, RESOURCE_REQUEST_PER_HOST_PER_DAY, @@ -12,7 +11,10 @@ import { MEMBERSHIP_FLIPS_PER_EVENT_PER_WINDOW, type CleanupService, } from "../../src/services/cleanup-service.js" -import { CLEANUP_GUEST_UPDATE_FANOUT_JOB } from "../../src/services/guest-rsvp-service.js" +import { + CLEANUP_CANCEL_FANOUT_JOB, + CLEANUP_GUEST_UPDATE_FANOUT_JOB, +} from "../../src/lib/queue-names.js" import { DEFAULT_EVENT_DURATION_MS } from "../../src/services/cleanup-rules.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" diff --git a/services/api/test/unit/cleanup-update-location.test.ts b/services/api/test/unit/cleanup-update-location.test.ts index 1e5fb9fe..2105e694 100644 --- a/services/api/test/unit/cleanup-update-location.test.ts +++ b/services/api/test/unit/cleanup-update-location.test.ts @@ -4,7 +4,7 @@ import { FakeJobs } from "@civfix/shared/fakes" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { makeCleanupService, type CleanupService } from "../../src/services/cleanup-service.js" -import { CLEANUP_GUEST_UPDATE_FANOUT_JOB } from "../../src/services/guest-rsvp-service.js" +import { CLEANUP_GUEST_UPDATE_FANOUT_JOB } from "../../src/lib/queue-names.js" const ORG = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/data-export-correctness.test.ts b/services/api/test/unit/data-export-correctness.test.ts index acfd283f..230faf7b 100644 --- a/services/api/test/unit/data-export-correctness.test.ts +++ b/services/api/test/unit/data-export-correctness.test.ts @@ -11,11 +11,8 @@ import { } from "../../src/services/data-export-service.js" import type { JobHandler } from "@civfix/shared/interfaces" import type { Container } from "../../src/di.js" -import { - DATA_EXPORT_JOB, - registerDataExportJobs, - runDataExport, -} from "../../src/services/data-export-jobs.js" +import { registerDataExportJobs, runDataExport } from "../../src/services/data-export-jobs.js" +import { DATA_EXPORT_JOB } from "../../src/lib/queue-names.js" const FROM = "no-reply@civfix.org" const SUPPORT = "support@civfix.org" diff --git a/services/api/test/unit/data-export-route.test.ts b/services/api/test/unit/data-export-route.test.ts index 0ba6bbd0..def14ec9 100644 --- a/services/api/test/unit/data-export-route.test.ts +++ b/services/api/test/unit/data-export-route.test.ts @@ -7,7 +7,7 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores, type InMemoryUserStore } from "../../src/auth/stores.js" import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" -import { DATA_EXPORT_JOB } from "../../src/services/data-export-jobs.js" +import { DATA_EXPORT_JOB } from "../../src/lib/queue-names.js" let current: FastifyInstance | undefined afterEach(async () => { diff --git a/services/api/test/unit/discovery-jobs.test.ts b/services/api/test/unit/discovery-jobs.test.ts index a377f250..26cd8604 100644 --- a/services/api/test/unit/discovery-jobs.test.ts +++ b/services/api/test/unit/discovery-jobs.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import { FakeJobs } from "@civfix/shared/fakes" import { registerDiscoveryJobs } from "../../src/services/admin/discovery-jobs.js" -import { JURISDICTION_DISCOVERY_JOB } from "../../src/services/jurisdiction-service.js" +import { JURISDICTION_DISCOVERY_JOB } from "../../src/lib/queue-names.js" import type { Container } from "../../src/di.js" import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" diff --git a/services/api/test/unit/host/host-service-correctness.test.ts b/services/api/test/unit/host/host-service-correctness.test.ts index 5888b801..62d68400 100644 --- a/services/api/test/unit/host/host-service-correctness.test.ts +++ b/services/api/test/unit/host/host-service-correctness.test.ts @@ -7,7 +7,7 @@ import { makeRegistrationService } from "../../../src/services/host/registration import { makeTicketTypeService } from "../../../src/services/host/ticket-type-service.js" import { makeQuestionService } from "../../../src/services/host/question-service.js" import { makeWaitlistService } from "../../../src/services/host/waitlist-service.js" -import { WAITLIST_PROMOTE_JOB } from "../../../src/services/host/registration-queues.js" +import { WAITLIST_PROMOTE_JOB } from "../../../src/lib/queue-names.js" import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" import type { SeatDraft } from "../../../src/services/host/registration-repository.types.js" diff --git a/services/api/test/unit/inbound-bounce-redrive.test.ts b/services/api/test/unit/inbound-bounce-redrive.test.ts index 71247a69..76052b26 100644 --- a/services/api/test/unit/inbound-bounce-redrive.test.ts +++ b/services/api/test/unit/inbound-bounce-redrive.test.ts @@ -12,7 +12,7 @@ import type { Container } from "../../src/di.js" import { makeFakeSql } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleMailRepository } from "../../src/services/admin/mail-repository.drizzle.js" -import { JURISDICTION_DISCOVERY_JOB } from "../../src/services/jurisdiction-service.js" +import { JURISDICTION_DISCOVERY_JOB } from "../../src/lib/queue-names.js" const GEOID = "0644000" const FAILED = "clerk@lacity.gov" diff --git a/services/api/test/unit/jurisdiction-service.test.ts b/services/api/test/unit/jurisdiction-service.test.ts index 4be72e76..c7e381cd 100644 --- a/services/api/test/unit/jurisdiction-service.test.ts +++ b/services/api/test/unit/jurisdiction-service.test.ts @@ -5,9 +5,9 @@ import { needsDiscovery, isRoutable, makeJurisdictionService, - JURISDICTION_DISCOVERY_JOB, type JurisdictionHealthRow, } from "../../src/services/jurisdiction-service.js" +import { JURISDICTION_DISCOVERY_JOB } from "../../src/lib/queue-names.js" import { CachedJurisdictionLookup, FakeJurisdictionLookup, diff --git a/services/api/test/unit/media-claim-actor.test.ts b/services/api/test/unit/media-claim-actor.test.ts index 30243613..a79849ea 100644 --- a/services/api/test/unit/media-claim-actor.test.ts +++ b/services/api/test/unit/media-claim-actor.test.ts @@ -13,10 +13,8 @@ import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import type { SavePageArgs } from "../../src/services/host/registration-repository.types.js" -import { - MEDIA_CHECKS_JOB, - makeMediaIntakeService, -} from "../../src/services/media-intake-service.js" +import { makeMediaIntakeService } from "../../src/services/media-intake-service.js" +import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import { anonUploader, userUploader } from "../../src/services/media-uploader.js" import { makeFakeSql, diff --git a/services/api/test/unit/media-finalize-retry.test.ts b/services/api/test/unit/media-finalize-retry.test.ts index fc5955fc..b322a770 100644 --- a/services/api/test/unit/media-finalize-retry.test.ts +++ b/services/api/test/unit/media-finalize-retry.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect } from "vitest" import { FakeStorage, FakeJobs } from "@civfix/shared/fakes" -import { - makeMediaIntakeService, - MEDIA_CHECKS_JOB, -} from "../../src/services/media-intake-service.js" +import { makeMediaIntakeService } from "../../src/services/media-intake-service.js" +import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import { InMemoryMediaRepository } from "../helpers/media.js" const SHA = "a".repeat(64) diff --git a/services/api/test/unit/media-intake-service.test.ts b/services/api/test/unit/media-intake-service.test.ts index 85b6a4d4..f71d4957 100644 --- a/services/api/test/unit/media-intake-service.test.ts +++ b/services/api/test/unit/media-intake-service.test.ts @@ -6,9 +6,9 @@ import { makeMediaIntakeService, precheckUpload, buildR2Key, - MEDIA_CHECKS_JOB, type MediaIntakeService, } from "../../src/services/media-intake-service.js" +import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import { InMemoryMediaRepository } from "../helpers/media.js" const SHA = "a".repeat(64) diff --git a/services/api/test/unit/media-routes.test.ts b/services/api/test/unit/media-routes.test.ts index 1192613a..c4ef86ab 100644 --- a/services/api/test/unit/media-routes.test.ts +++ b/services/api/test/unit/media-routes.test.ts @@ -5,7 +5,7 @@ import { buildServer } from "../../src/server.js" import { buildContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryMediaRepository } from "../helpers/media.js" -import { MEDIA_CHECKS_JOB } from "../../src/services/media-intake-service.js" +import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import type { FakeStorage, FakeJobs } from "@civfix/shared/fakes" const SHA = "b".repeat(64) diff --git a/services/api/test/unit/report-forward-audit.test.ts b/services/api/test/unit/report-forward-audit.test.ts index 9fc7ff2e..d04b76ed 100644 --- a/services/api/test/unit/report-forward-audit.test.ts +++ b/services/api/test/unit/report-forward-audit.test.ts @@ -6,7 +6,7 @@ import { CITY_FORWARD_PER_GEOID_PER_HOUR, } from "../../src/services/report-city-forward.js" import { InMemoryCounterStore, type CounterStore } from "../../src/abuse/counter-store.js" -import type { ReportForwardAudit } from "../../src/services/report-forward-audit.drizzle.js" +import type { ReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.drizzle.js" import type { AppendOutboundInput, OutboundMailService, @@ -79,7 +79,9 @@ function mailer(opts: { fail?: boolean; thread?: MailThreadRecord | null } = {}) } } -function spyAudit(overrides: Partial = {}): ReportForwardAudit & { +function spyAudit( + overrides: Partial = {}, +): ReportForwardAuditRepository & { recordMention: ReturnType markForwarded: ReturnType } { @@ -87,7 +89,7 @@ function spyAudit(overrides: Partial = {}): ReportForwardAud recordMention: vi.fn(() => Promise.resolve()), markForwarded: vi.fn(() => Promise.resolve()), ...overrides, - } as ReportForwardAudit & { + } as ReportForwardAuditRepository & { recordMention: ReturnType markForwarded: ReturnType } diff --git a/services/api/test/unit/report-verification.test.ts b/services/api/test/unit/report-verification.test.ts index ed6e697c..d85e3ad6 100644 --- a/services/api/test/unit/report-verification.test.ts +++ b/services/api/test/unit/report-verification.test.ts @@ -21,7 +21,8 @@ import { type OutboundMailService, } from "../../src/services/admin/outbound-mail-service.js" import { runAutoForwardWith } from "../../src/services/admin/autoforward-jobs.js" -import { makeReportService, REPORT_AUTOFORWARD_JOB } from "../../src/services/report-service.js" +import { makeReportService } from "../../src/services/report-service.js" +import { REPORT_AUTOFORWARD_JOB } from "../../src/lib/queue-names.js" import { InMemoryReportRepository } from "../helpers/reports.js" const NOW = new Date("2026-06-22T00:00:00.000Z") diff --git a/services/media-worker/src/jobs/hold-release-sweep.ts b/services/media-worker/src/jobs/hold-release-sweep.ts index 7d98471f..58d907f4 100644 --- a/services/media-worker/src/jobs/hold-release-sweep.ts +++ b/services/media-worker/src/jobs/hold-release-sweep.ts @@ -22,10 +22,9 @@ import type { AbuseChecks } from "@civfix/shared/interfaces" import { releaseAnonHoldIfReady, type AnonHoldReleaseRepo } from "@civfix/api/anon-hold-release" +import { ANON_HOLD_RELEASE_SWEEP_JOB } from "@civfix/api/queue-names" import { resolveJobObs, type JobObsDeps } from "./obs.js" -const HOLD_RELEASE_SWEEP = "anon.hold.release.sweep" - export interface HoldReleaseSweepDeps extends JobObsDeps { repo: AnonHoldReleaseRepo abuseChecks: AbuseChecks @@ -47,7 +46,7 @@ export async function runHoldReleaseSweep( try { ids = await deps.repo.findHeldAnonReportIds(deps.batchSize) } catch (err) { - report(err, { job: HOLD_RELEASE_SWEEP, phase: "find" }) + report(err, { job: ANON_HOLD_RELEASE_SWEEP_JOB, phase: "find" }) log("anon.hold.release.sweep: find failed", { err: String(err) }) return { scanned: 0, published: 0, errors: 1 } } @@ -65,7 +64,7 @@ export async function runHoldReleaseSweep( if (result.published) published++ } catch (err) { errors++ - report(err, { job: HOLD_RELEASE_SWEEP, phase: "release", reportId }) + report(err, { job: ANON_HOLD_RELEASE_SWEEP_JOB, phase: "release", reportId }) log("anon.hold.release.sweep: report failed", { reportId, err: String(err) }) } } diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index 6f880b7e..986c7174 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -12,7 +12,8 @@ import { type JobLogFn, type JobReportFn, } from "./obs.js" -import { MEDIA_CHECKS_JOB, readEtag } from "@civfix/api/media-repo" +import { readEtag } from "@civfix/api/media-repo" +import { MEDIA_CHECKS_JOB } from "@civfix/api/queue-names" import { SandboxSpawnError } from "../sandbox/exec.js" import { ScratchSetupError } from "../sandbox/tmp.js" import { servedKey, thumbnailKey } from "./media-keys.js" diff --git a/services/media-worker/src/jobs/orphan-sweep.ts b/services/media-worker/src/jobs/orphan-sweep.ts index d3a690cb..cbe2f579 100644 --- a/services/media-worker/src/jobs/orphan-sweep.ts +++ b/services/media-worker/src/jobs/orphan-sweep.ts @@ -7,8 +7,8 @@ import { R2_PUT_TTL_SEC } from "@civfix/api/adapters/storage" import { servedKey, thumbnailKey } from "./media-keys.js" import { mapWithLimit } from "@civfix/api/concurrency" import { MS_PER_SECOND } from "@civfix/api/time" +import { ORPHAN_SWEEP_JOB } from "@civfix/api/queue-names" -const ORPHAN_SWEEP = "orphan.sweep" const ORPHAN_CONCURRENCY = 8 const LEGACY_PROCESSED_SUFFIXES = [".img", ".mp4"] @@ -71,7 +71,7 @@ export async function runOrphanSweep(deps: OrphanSweepDeps): Promise boundMeanwhile++, onError: (err, id) => { errors++ - report(err, { job: ORPHAN_SWEEP, phase: "delete", mediaId: id }) + report(err, { job: ORPHAN_SWEEP_JOB, phase: "delete", mediaId: id }) log("orphan.sweep: row failed", { mediaId: id, err: String(err) }) }, onLeak: (keys, id) => { @@ -81,7 +81,7 @@ export async function runOrphanSweep(deps: OrphanSweepDeps): Promise= LEAK_RETRY_MAX_ATTEMPTS) { report(new Error(`orphan.sweep gave up on a leaked R2 object after ${attempts} attempts`), { - job: ORPHAN_SWEEP, + job: ORPHAN_SWEEP_JOB, phase: "leak-retry", key: row.r2Key, mediaId: row.mediaId, @@ -209,7 +209,7 @@ async function adoptLegacyServedKeys( } return adopted } catch (err) { - report(err, { job: ORPHAN_SWEEP, phase: "adopt-legacy-served-keys" }) + report(err, { job: ORPHAN_SWEEP_JOB, phase: "adopt-legacy-served-keys" }) log("orphan.sweep: legacy served-key adoption failed", { err: String(err) }) return 0 } diff --git a/services/media-worker/src/jobs/partition-maintenance.ts b/services/media-worker/src/jobs/partition-maintenance.ts index f0afaae8..fc115128 100644 --- a/services/media-worker/src/jobs/partition-maintenance.ts +++ b/services/media-worker/src/jobs/partition-maintenance.ts @@ -4,10 +4,9 @@ import { PARTITION_MONTHS_AHEAD, } from "@civfix/api/media-repo" import type { Sql } from "@civfix/api/db" +import { CHAT_PARTITION_JOB } from "@civfix/api/queue-names" import { resolveJobObs, type JobObsDeps } from "./obs.js" -const CHAT_PARTITION_MAINTENANCE = "chat.partition.maintenance" - export interface PartitionMaintenanceDeps extends JobObsDeps { sql: Sql monthsAhead?: number @@ -30,7 +29,7 @@ export async function runPartitionMaintenance( log("chat.partition.maintenance: ensured", { chat, dm }) return { chat, dm } } catch (err) { - report(err, { job: CHAT_PARTITION_MAINTENANCE }) + report(err, { job: CHAT_PARTITION_JOB }) log("chat.partition.maintenance: failed", { err: String(err) }) throw err } diff --git a/services/media-worker/src/jobs/reject-cleanup.ts b/services/media-worker/src/jobs/reject-cleanup.ts index 40ecf883..09af513f 100644 --- a/services/media-worker/src/jobs/reject-cleanup.ts +++ b/services/media-worker/src/jobs/reject-cleanup.ts @@ -1,5 +1,6 @@ import type { Storage } from "@civfix/shared/interfaces" -import { MEDIA_CHECKS_JOB, type MediaWorkerRepo } from "@civfix/api/media-repo" +import type { MediaWorkerRepo } from "@civfix/api/media-repo" +import { MEDIA_CHECKS_JOB } from "@civfix/api/queue-names" import type { JobLogFn, JobReportFn } from "./obs.js" import { servedKey, thumbnailKey } from "./media-keys.js" diff --git a/services/media-worker/src/jobs/retention-sweep.ts b/services/media-worker/src/jobs/retention-sweep.ts index f199167e..d9212666 100644 --- a/services/media-worker/src/jobs/retention-sweep.ts +++ b/services/media-worker/src/jobs/retention-sweep.ts @@ -11,6 +11,7 @@ import { makeDrizzleRetentionRepository } from "@civfix/api/retention-repo" import { drainPages } from "./drain.js" import { resolveJobObs, type JobObsDeps } from "./obs.js" import { MS_PER_DAY, MS_PER_HOUR } from "@civfix/api/time" +import { RETENTION_SWEEP_JOB } from "@civfix/api/queue-names" export interface RetentionSweepDeps extends JobObsDeps { sql: Sql @@ -36,8 +37,6 @@ export interface RetentionSweepResult { errors: number } -const RETENTION_SWEEP = "retention.sweep" - export const RETENTION_GRACE_MS = MS_PER_HOUR const RETENTION_BATCH = 5000 const RETENTION_IDEMPOTENCY_MS = 48 * MS_PER_HOUR @@ -87,7 +86,7 @@ export async function runRetentionSweep(deps: RetentionSweepDeps): Promise onDeleted(rows.length), { pageSize, maxPages }) } catch (err) { result.errors++ - report(err, { job: RETENTION_SWEEP, table }) + report(err, { job: RETENTION_SWEEP_JOB, table }) log(`retention.sweep: ${table} failed`, { err: String(err) }) } } @@ -192,7 +191,7 @@ export async function runInboundEmailRetentionLane( ) } catch (err) { result.errors++ - opts.report(err, { job: RETENTION_SWEEP, table: "inbound_emails" }) + opts.report(err, { job: RETENTION_SWEEP_JOB, table: "inbound_emails" }) opts.log("retention.sweep: inbound_emails failed", { err: String(err) }) } } @@ -211,7 +210,7 @@ async function deleteAttachments( objectsGone = false result.inboundEmailObjectsLeaked += 1 opts.report(err, { - job: RETENTION_SWEEP, + job: RETENTION_SWEEP_JOB, table: "inbound_emails", phase: "attachment", }) diff --git a/services/media-worker/src/jobs/stuck-sweep.ts b/services/media-worker/src/jobs/stuck-sweep.ts index 676345e1..10c84991 100644 --- a/services/media-worker/src/jobs/stuck-sweep.ts +++ b/services/media-worker/src/jobs/stuck-sweep.ts @@ -1,16 +1,14 @@ import type { Jobs, Storage } from "@civfix/shared/interfaces" +import type { MediaChecksJob, MediaWorkerRepo, StuckMediaRow } from "@civfix/api/media-repo" import { MEDIA_CHECKS_JOB, - type MediaChecksJob, - type MediaWorkerRepo, - type StuckMediaRow, -} from "@civfix/api/media-repo" + MEDIA_STUCK_SWEEP_JOB, + MEDIA_UPLOAD_REAP_JOB, +} from "@civfix/api/queue-names" import type { WorkerLimits } from "../config.js" import { resolveJobObs, type JobObsDeps } from "./obs.js" import { deleteRejectedObjects } from "./reject-cleanup.js" -import { MEDIA_UPLOAD_REAP_JOB, uploadReapDelaySec } from "./upload-reap.js" - -const STUCK_SWEEP = "media.stuck.sweep" +import { uploadReapDelaySec } from "./upload-reap.js" export interface StuckSweepDeps extends JobObsDeps { repo: MediaWorkerRepo @@ -35,7 +33,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise Date: Wed, 23 Sep 2026 16:30:18 +0000 Subject: [PATCH 08/20] db schema table files named after their tables --- services/api/src/db/schema/chat.ts | 2 +- services/api/src/db/schema/chat_group_bans.ts | 2 +- .../src/db/schema/{chat-groups.ts => chat_groups.ts} | 0 .../api/src/db/schema/{chat-polls.ts => chat_polls.ts} | 0 ...mplate-settings.ts => forward_template_settings.ts} | 0 services/api/src/db/schema/index.ts | 10 +++++----- ...s-certificates.ts => service_hours_certificates.ts} | 0 .../schema/{volunteer-hours.ts => volunteer_hours.ts} | 0 .../api/src/services/chat-group-repository.drizzle.ts | 2 +- 9 files changed, 8 insertions(+), 8 deletions(-) rename services/api/src/db/schema/{chat-groups.ts => chat_groups.ts} (100%) rename services/api/src/db/schema/{chat-polls.ts => chat_polls.ts} (100%) rename services/api/src/db/schema/{forward-template-settings.ts => forward_template_settings.ts} (100%) rename services/api/src/db/schema/{service-hours-certificates.ts => service_hours_certificates.ts} (100%) rename services/api/src/db/schema/{volunteer-hours.ts => volunteer_hours.ts} (100%) diff --git a/services/api/src/db/schema/chat.ts b/services/api/src/db/schema/chat.ts index 73dfae5d..8a85ae71 100644 --- a/services/api/src/db/schema/chat.ts +++ b/services/api/src/db/schema/chat.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm" import { index, jsonb, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" -import { chatGroups } from "./chat-groups.js" +import { chatGroups } from "./chat_groups.js" import { cleanups } from "./cleanups.js" import { reports } from "./reports.js" import { users } from "./users.js" diff --git a/services/api/src/db/schema/chat_group_bans.ts b/services/api/src/db/schema/chat_group_bans.ts index cfa4a3f0..b352dbeb 100644 --- a/services/api/src/db/schema/chat_group_bans.ts +++ b/services/api/src/db/schema/chat_group_bans.ts @@ -1,5 +1,5 @@ import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" -import { chatGroups } from "./chat-groups.js" +import { chatGroups } from "./chat_groups.js" import { users } from "./users.js" export const chatGroupBans = pgTable( diff --git a/services/api/src/db/schema/chat-groups.ts b/services/api/src/db/schema/chat_groups.ts similarity index 100% rename from services/api/src/db/schema/chat-groups.ts rename to services/api/src/db/schema/chat_groups.ts diff --git a/services/api/src/db/schema/chat-polls.ts b/services/api/src/db/schema/chat_polls.ts similarity index 100% rename from services/api/src/db/schema/chat-polls.ts rename to services/api/src/db/schema/chat_polls.ts diff --git a/services/api/src/db/schema/forward-template-settings.ts b/services/api/src/db/schema/forward_template_settings.ts similarity index 100% rename from services/api/src/db/schema/forward-template-settings.ts rename to services/api/src/db/schema/forward_template_settings.ts diff --git a/services/api/src/db/schema/index.ts b/services/api/src/db/schema/index.ts index 870d3ca5..ed67c09a 100644 --- a/services/api/src/db/schema/index.ts +++ b/services/api/src/db/schema/index.ts @@ -21,9 +21,9 @@ export * from "./cleanup_guests.js" export * from "./guest_otps.js" export * from "./sms_opt_outs.js" export * from "./chat.js" -export * from "./chat-groups.js" +export * from "./chat_groups.js" export * from "./chat_group_bans.js" -export * from "./chat-polls.js" +export * from "./chat_polls.js" export * from "./chat_reactions.js" export * from "./message_mentions.js" export * from "./report_chat_members.js" @@ -43,8 +43,8 @@ export * from "./post_likes.js" export * from "./post_saves.js" export * from "./post_mentions.js" -export * from "./volunteer-hours.js" -export * from "./service-hours-certificates.js" +export * from "./volunteer_hours.js" +export * from "./service_hours_certificates.js" export * from "./notifications.js" export * from "./notification_prefs.js" @@ -62,7 +62,7 @@ export * from "./gov_claims.js" export * from "./user_moderation.js" export * from "./moderation_items.js" export * from "./mail.js" -export * from "./forward-template-settings.js" +export * from "./forward_template_settings.js" export * from "./inbound_emails.js" export * from "./inbound_bounce_attempts.js" export * from "./outreach_state.js" diff --git a/services/api/src/db/schema/service-hours-certificates.ts b/services/api/src/db/schema/service_hours_certificates.ts similarity index 100% rename from services/api/src/db/schema/service-hours-certificates.ts rename to services/api/src/db/schema/service_hours_certificates.ts diff --git a/services/api/src/db/schema/volunteer-hours.ts b/services/api/src/db/schema/volunteer_hours.ts similarity index 100% rename from services/api/src/db/schema/volunteer-hours.ts rename to services/api/src/db/schema/volunteer_hours.ts diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index f02357c1..bf3712fa 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -1,6 +1,6 @@ import type { Sql } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus, PersonDTO } from "@civfix/shared" -import type { ChatGroupKind, ChatGroupVisibility } from "../db/schema/chat-groups.js" +import type { ChatGroupKind, ChatGroupVisibility } from "../db/schema/chat_groups.js" import type { GROUP_MEMBER_ROLE_VALUES } from "../db/schema/types.js" import type { PresignMedia } from "./media-presign.js" import { blockedPairExpr } from "./blocks-sql.js" From bee92744aa592ad1d8b31eb3e1d072bc2956bccb Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:32:40 +0000 Subject: [PATCH 09/20] worker jobs seam module and factories named make* --- services/media-worker/.env.example | 2 +- services/media-worker/src/seams.ts | 14 +++--- .../src/{jobs.ts => worker-jobs.ts} | 4 +- services/media-worker/src/worker.ts | 14 +++--- services/media-worker/test/helpers/pg.ts | 2 +- .../retention-and-phash-pg.test.ts | 12 ++--- .../test/unit/anon-hold-release.test.ts | 12 ++--- .../test/unit/pg-boss-jobs.test.ts | 12 ++--- .../test/unit/seams-abuse-log.test.ts | 4 +- .../test/unit/seams-local-storage.test.ts | 12 ++--- .../media-worker/test/unit/worker.test.ts | 46 +++++++++---------- 11 files changed, 67 insertions(+), 67 deletions(-) rename services/media-worker/src/{jobs.ts => worker-jobs.ts} (98%) diff --git a/services/media-worker/.env.example b/services/media-worker/.env.example index 5516680d..7947db4d 100644 --- a/services/media-worker/.env.example +++ b/services/media-worker/.env.example @@ -77,7 +77,7 @@ MEDIA_JOB_TIMEOUT_MS= # [OPT] per-PHASE wall-clock budget f # the job budget only fires on a genuine tool hang, not on legit slow # processing (a process-phase timeout now RETRIES, it does not reject). # Charged once for the download and once for processing. The graceful - # stop is derived as 2x + 5s (jobs.ts stopGraceMsFor = 185s at the + # stop is derived as 2x + 5s (worker-jobs.ts stopGraceMsFor = 185s at the # default), and the media-worker container's stop_grace_period in the # civfix-infra compose MUST exceed that (>= ~200s). Raising this raises both. MEDIA_MAX_VIDEO_DURATION_SEC= # [OPT] longest accepted video, seconds (default 30) diff --git a/services/media-worker/src/seams.ts b/services/media-worker/src/seams.ts index 4e17e9de..3ebdeeb7 100644 --- a/services/media-worker/src/seams.ts +++ b/services/media-worker/src/seams.ts @@ -44,7 +44,7 @@ const defaultSeamLog: JobLogFn = (line, extra) => console.warn(line, extra ?? {} const DEFAULT_NODE_ENV = "development" const DEFAULT_SERVICE_VERSION = "0.0.0" -export async function buildSeams( +export async function makeSeams( source: NodeJS.ProcessEnv = process.env, options: BuildSeamsOptions = {}, ): Promise { @@ -77,17 +77,17 @@ export async function buildSeams( ? new FakeStorage() : r2Storage(source, () => req(source, "R2_BUCKET")) - const { dbHandle, repo, anonHoldRepo } = buildDbSeams(source) + const { dbHandle, repo, anonHoldRepo } = makeDbSeams(source) const findPhashDuplicate: FindPhashDuplicateFn | undefined = repo?.findPhashDuplicate const abuseChecks: AbuseChecks = fakeAbuse ? new FakeAbuseChecks() - : await buildRealAbuseChecks(source, limits, findPhashDuplicate, log) + : await makeRealAbuseChecks(source, limits, findPhashDuplicate, log) const download = makeDownloader(storage) - const inboundStorage = buildInboundStorage(source, { + const inboundStorage = makeInboundStorage(source, { storage, localStorageDir, usesR2: localStorageDir.length === 0 && !fakeStorage, @@ -138,7 +138,7 @@ function r2Storage(source: NodeJS.ProcessEnv, bucket: () => string): R2Storage { }) } -function buildDbSeams( +function makeDbSeams( source: NodeJS.ProcessEnv, ): Pick { const databaseUrl = (source.DATABASE_URL ?? "").trim() @@ -156,7 +156,7 @@ function buildDbSeams( return { dbHandle: undefined, repo: undefined, anonHoldRepo: undefined } } -function buildInboundStorage( +function makeInboundStorage( source: NodeJS.ProcessEnv, media: { storage: Storage; localStorageDir: string; usesR2: boolean }, ): Storage | undefined { @@ -185,7 +185,7 @@ function req(source: NodeJS.ProcessEnv, key: string): string { return v } -async function buildRealAbuseChecks( +async function makeRealAbuseChecks( source: NodeJS.ProcessEnv, limits: WorkerLimits, findPhashDuplicate: FindPhashDuplicateFn | undefined, diff --git a/services/media-worker/src/jobs.ts b/services/media-worker/src/worker-jobs.ts similarity index 98% rename from services/media-worker/src/jobs.ts rename to services/media-worker/src/worker-jobs.ts index cd043660..bb828d23 100644 --- a/services/media-worker/src/jobs.ts +++ b/services/media-worker/src/worker-jobs.ts @@ -300,9 +300,9 @@ class FakeWorkerJobs extends FakeJobs implements WorkerJobs { } } -export function buildJobs(source: NodeJS.ProcessEnv = process.env): JobsHandle { +export function makeJobs(source: NodeJS.ProcessEnv = process.env): JobsHandle { const fakeJobs = useFakeJobs(source) - // Mirrors buildSeams' USE_FAKE_STORAGE guard: FakeJobs consumes NOTHING from + // Mirrors makeSeams' USE_FAKE_STORAGE guard: FakeJobs consumes NOTHING from // pg-boss, so a worker booted with it in production starts cleanly, reports healthy, and every uploaded // media stays `validating` forever with no error anywhere - the silent no-op the storage guard exists // to prevent, in the one seam that makes the whole process pointless. diff --git a/services/media-worker/src/worker.ts b/services/media-worker/src/worker.ts index 21977405..686b92bd 100644 --- a/services/media-worker/src/worker.ts +++ b/services/media-worker/src/worker.ts @@ -12,13 +12,13 @@ import { } from "@civfix/api/queue-names" import { releaseAnonHoldIfReady, type HeldReportView } from "@civfix/api/anon-hold-release" import { - buildJobs, + makeJobs, stopGraceMsFor, type JobsHandle, type QueueOptions, type WorkerJobs, -} from "./jobs.js" -import { buildSeams, type WorkerSeams } from "./seams.js" +} from "./worker-jobs.js" +import { makeSeams, type WorkerSeams } from "./seams.js" import { CHAT_PARTITION_CRON, HOLD_RELEASE_SWEEP_CRON, @@ -348,11 +348,11 @@ async function runBootPartitionMaintenance(seams: WorkerSeams): Promise { } } -export async function buildWorker( - handle: JobsHandle = buildJobs(), +export async function makeWorker( + handle: JobsHandle = makeJobs(), seams?: WorkerSeams, ): Promise { - const resolvedSeams = seams ?? (await buildSeams()) + const resolvedSeams = seams ?? (await makeSeams()) let started = false async function start(): Promise { @@ -406,7 +406,7 @@ export function makeShutdown( } export async function start(): Promise { - const worker = await buildWorker() + const worker = await makeWorker() await worker.start() console.log("civfix media-worker started") diff --git a/services/media-worker/test/helpers/pg.ts b/services/media-worker/test/helpers/pg.ts index d9efc87f..8d9a5855 100644 --- a/services/media-worker/test/helpers/pg.ts +++ b/services/media-worker/test/helpers/pg.ts @@ -80,7 +80,7 @@ function assertSkipAllowed(reason: string, env: NodeJS.ProcessEnv = process.env) export interface WorkerPgHarness { sql: Sql db: Db - /** The container's connection URI, for code under test that takes a DATABASE_URL (e.g. buildSeams). */ + /** The container's connection URI, for code under test that takes a DATABASE_URL (e.g. makeSeams). */ uri: string teardown(): Promise } diff --git a/services/media-worker/test/integration/retention-and-phash-pg.test.ts b/services/media-worker/test/integration/retention-and-phash-pg.test.ts index e8dbf87e..92bd8983 100644 --- a/services/media-worker/test/integration/retention-and-phash-pg.test.ts +++ b/services/media-worker/test/integration/retention-and-phash-pg.test.ts @@ -12,7 +12,7 @@ * Both are also failure-tolerant in production - the retention sweep counts and continues, the dedupe * error is downgraded to a note - so drift would surface as silently-doing-nothing, not as an incident. * - * The phash lookup is reached through buildSeams(), i.e. the real production wiring, because how seams.ts + * The phash lookup is reached through makeSeams(), i.e. the real production wiring, because how seams.ts * wires it (only when DATABASE_URL is set) is part of what should not break. */ @@ -21,7 +21,7 @@ import { randomUUID } from "node:crypto" import { FakeStorage } from "@civfix/shared/fakes" import { GEOCODE_CACHE_TTL_MS } from "@civfix/api/geocode-cache" import { runRetentionSweep } from "../../src/jobs/retention-sweep.js" -import { buildSeams, type WorkerSeams } from "../../src/seams.js" +import { makeSeams, type WorkerSeams } from "../../src/seams.js" import { withWorkerPg, type WorkerPgHarness } from "../helpers/pg.js" const pg = await withWorkerPg() @@ -43,8 +43,8 @@ beforeAll(async () => { RETURNING id ` userId = user!.id - // The REAL production wiring: a DATABASE_URL is what makes buildSeams construct the phash lookup at all. - seams = await buildSeams({ + // The REAL production wiring: a DATABASE_URL is what makes makeSeams construct the phash lookup at all. + seams = await makeSeams({ NODE_ENV: "test", DATABASE_URL: h.uri, USE_FAKE_STORAGE: "1", @@ -369,13 +369,13 @@ describe.skipIf(!pg)("retention.sweep against the real schema", () => { }) describe.skipIf(!pg)("phash near-duplicate lookup against the real media_assets", () => { - /** The lookup buildSeams wired; asserted present because `undefined` here would skip every case. */ + /** The lookup makeSeams wired; asserted present because `undefined` here would skip every case. */ function lookup( hash: string, opts?: { excludeAssetId?: string; excludeReportId?: string }, ): Promise<{ dup: boolean; ofReportId?: string | null }> { const fn = seams?.findPhashDuplicate - expect(fn, "buildSeams must wire findPhashDuplicate when DATABASE_URL is set").toBeDefined() + expect(fn, "makeSeams must wire findPhashDuplicate when DATABASE_URL is set").toBeDefined() return fn!(hash, opts) } diff --git a/services/media-worker/test/unit/anon-hold-release.test.ts b/services/media-worker/test/unit/anon-hold-release.test.ts index 922a2c6a..96269c34 100644 --- a/services/media-worker/test/unit/anon-hold-release.test.ts +++ b/services/media-worker/test/unit/anon-hold-release.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import type { LatLng } from "@civfix/shared" -import { buildWorker } from "../../src/worker.js" -import { buildJobs } from "../../src/jobs.js" +import { makeWorker } from "../../src/worker.js" +import { makeJobs } from "../../src/worker-jobs.js" import { loadLimits } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" import type { WorkerSeams } from "../../src/seams.js" @@ -114,9 +114,9 @@ describe("media-worker hold-release wiring", () => { const reportId = "anon-report-ready" const holdRepo = new MemHoldRepo(heldReport(reportId), repo) - const handle = buildJobs() + const handle = makeJobs() const seams = makeSeams({ repo, anonHoldRepo: holdRepo, abuse, storage }) - const worker = await buildWorker(handle, seams) + const worker = await makeWorker(handle, seams) await worker.start() const valid = await fx.makeValidPng() @@ -138,9 +138,9 @@ describe("media-worker hold-release wiring", () => { const reportId = "anon-report-nsfw" const holdRepo = new MemHoldRepo(heldReport(reportId), repo) - const handle = buildJobs() + const handle = makeJobs() const seams = makeSeams({ repo, anonHoldRepo: holdRepo, abuse, storage }) - const worker = await buildWorker(handle, seams) + const worker = await makeWorker(handle, seams) await worker.start() const nsfw = await fx.makeNsfwJpeg() diff --git a/services/media-worker/test/unit/pg-boss-jobs.test.ts b/services/media-worker/test/unit/pg-boss-jobs.test.ts index 235ea339..119c3fb0 100644 --- a/services/media-worker/test/unit/pg-boss-jobs.test.ts +++ b/services/media-worker/test/unit/pg-boss-jobs.test.ts @@ -119,7 +119,7 @@ vi.mock("pg-boss", () => { return { default: StubPgBoss } }) -const { PgBossWorkerJobs, buildJobs, stopGraceMsFor } = await import("../../src/jobs.js") +const { PgBossWorkerJobs, makeJobs, stopGraceMsFor } = await import("../../src/worker-jobs.js") /** The stub instance created by the most recent start(). */ function lastBoss(): StubBoss { @@ -423,9 +423,9 @@ describe("PgBossWorkerJobs unsupported name-agnostic complete/fail", () => { }) }) -describe("buildJobs seam selection", () => { +describe("makeJobs seam selection", () => { it("defaults to the fake outside production (the worker boots offline)", () => { - const handle = buildJobs({ NODE_ENV: "test" } as NodeJS.ProcessEnv) + const handle = makeJobs({ NODE_ENV: "test" } as NodeJS.ProcessEnv) expect(handle.jobs).toBeInstanceOf(FakeJobs) }) @@ -435,7 +435,7 @@ describe("buildJobs seam selection", () => { */ it("THROWS when USE_FAKE_JOBS is explicitly on in production", () => { expect(() => - buildJobs({ + makeJobs({ NODE_ENV: "production", USE_FAKE_JOBS: "1", DATABASE_URL: "postgres://stub/civfix", @@ -444,13 +444,13 @@ describe("buildJobs seam selection", () => { }) it("THROWS when the real seam is selected without a DATABASE_URL", () => { - expect(() => buildJobs({ NODE_ENV: "test", USE_FAKE_JOBS: "0" } as NodeJS.ProcessEnv)).toThrow( + expect(() => makeJobs({ NODE_ENV: "test", USE_FAKE_JOBS: "0" } as NodeJS.ProcessEnv)).toThrow( /DATABASE_URL is required when USE_FAKE_JOBS is off/, ) }) it("builds the real handle in production and derives its stop grace from MEDIA_JOB_TIMEOUT_MS", async () => { - const handle = buildJobs({ + const handle = makeJobs({ NODE_ENV: "production", DATABASE_URL: "postgres://stub/civfix", MEDIA_JOB_TIMEOUT_MS: "10000", diff --git a/services/media-worker/test/unit/seams-abuse-log.test.ts b/services/media-worker/test/unit/seams-abuse-log.test.ts index 0b748a96..e7324e0b 100644 --- a/services/media-worker/test/unit/seams-abuse-log.test.ts +++ b/services/media-worker/test/unit/seams-abuse-log.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { buildSeams } from "../../src/seams.js" +import { makeSeams } from "../../src/seams.js" afterEach(() => { vi.restoreAllMocks() @@ -9,7 +9,7 @@ describe("media-worker abuse-checks seam logging", () => { it("routes the real abuse checks' log lines to the worker's injected logger, not console", async () => { const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) const lines: { line: string; extra?: Record }[] = [] - const seams = await buildSeams( + const seams = await makeSeams( { NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "0" }, { log: (line, extra) => lines.push({ line, ...(extra ? { extra } : {}) }) }, ) diff --git a/services/media-worker/test/unit/seams-local-storage.test.ts b/services/media-worker/test/unit/seams-local-storage.test.ts index 96298b8e..8d324dd4 100644 --- a/services/media-worker/test/unit/seams-local-storage.test.ts +++ b/services/media-worker/test/unit/seams-local-storage.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { FakeStorage } from "@civfix/shared/fakes" import { LocalDiskStorage } from "@civfix/api/adapters/storage-local" -import { buildSeams } from "../../src/seams.js" +import { makeSeams } from "../../src/seams.js" const PUBLIC_API_URL = "http://localhost:8080" const SIGNING_KEY = "worker-test-local-storage-signing-key" @@ -42,7 +42,7 @@ afterEach(async () => { describe("media-worker local-disk storage seam", () => { it("selects the local-disk adapter over the in-memory fake", async () => { - const seams = await buildSeams(source()) + const seams = await makeSeams(source()) try { expect(seams.storage).toBeInstanceOf(LocalDiskStorage) } finally { @@ -51,7 +51,7 @@ describe("media-worker local-disk storage seam", () => { }) it("falls back to the fake when no local directory is configured", async () => { - const seams = await buildSeams({ NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "1" }) + const seams = await makeSeams({ NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "1" }) try { expect(seams.storage).toBeInstanceOf(FakeStorage) } finally { @@ -65,7 +65,7 @@ describe("media-worker local-disk storage seam", () => { contentType: "image/jpeg", }) - const seams = await buildSeams(source()) + const seams = await makeSeams(source()) try { const bytes = await seams.storage.getObject("uploads/2026/07/cross-process") expect(Buffer.from(bytes ?? new Uint8Array()).toString()).toBe("shared bytes") @@ -87,7 +87,7 @@ describe("media-worker local-disk storage seam", () => { it("mints a presigned GET the API can verify, so download.ts keeps working", async () => { const api = makeApiMediaStore() - const seams = await buildSeams(source()) + const seams = await makeSeams(source()) try { const url = new URL(await seams.storage.presignGet("uploads/2026/07/downloadable", 120)) expect(url.origin).toBe(PUBLIC_API_URL) @@ -110,7 +110,7 @@ describe("media-worker local-disk storage seam", () => { }) it("refuses to build the local-disk seam in production", async () => { - await expect(buildSeams(source({ NODE_ENV: "production" }))).rejects.toThrow( + await expect(makeSeams(source({ NODE_ENV: "production" }))).rejects.toThrow( /DEVELOPMENT ONLY|production/i, ) }) diff --git a/services/media-worker/test/unit/worker.test.ts b/services/media-worker/test/unit/worker.test.ts index 2e612f95..5b56f270 100644 --- a/services/media-worker/test/unit/worker.test.ts +++ b/services/media-worker/test/unit/worker.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { buildWorker } from "../../src/worker.js" +import { makeWorker } from "../../src/worker.js" import { ANON_HOLD_RELEASE_JOB, ANON_HOLD_RELEASE_SWEEP_JOB, @@ -10,8 +10,8 @@ import { ORPHAN_SWEEP_JOB, RETENTION_SWEEP_JOB, } from "@civfix/api/queue-names" -import { buildJobs, type ScheduleOptions } from "../../src/jobs.js" -import { buildSeams, type WorkerSeams } from "../../src/seams.js" +import { makeJobs, type ScheduleOptions } from "../../src/worker-jobs.js" +import { makeSeams, type WorkerSeams } from "../../src/seams.js" import { CHAT_PARTITION_CRON, HOLD_RELEASE_SWEEP_CRON, @@ -28,19 +28,19 @@ import * as fx from "../fixtures/make.js" describe("media-worker wiring", () => { it("builds a worker over the fake Jobs seam", async () => { - const handle = buildJobs() + const handle = makeJobs() expect(handle.jobs).toBeInstanceOf(FakeJobs) - const seams = await buildSeams() - const worker = await buildWorker(handle, seams) + const seams = await makeSeams() + const worker = await makeWorker(handle, seams) expect(worker.jobs).toBe(handle.jobs) await seams.close() }) it("start() registers the media.checks handler + both cron schedules, stop() resolves", async () => { - const handle = buildJobs() + const handle = makeJobs() const fake = handle.jobs as unknown as FakeJobs - const seams = await buildSeams() - const worker = await buildWorker(handle, seams) + const seams = await makeSeams() + const worker = await makeWorker(handle, seams) await expect(worker.start()).resolves.toBeUndefined() @@ -61,7 +61,7 @@ describe("media-worker wiring", () => { }) it("registers media.checks with a bounded retry policy so infra throws retry (not dead-letter)", async () => { - const handle = buildJobs() + const handle = makeJobs() const calls: { name: string options?: { retryLimit?: number; retryBackoff?: boolean; policy?: string } @@ -71,8 +71,8 @@ describe("media-worker wiring", () => { calls.push({ name, ...(options ? { options } : {}) }) return original(name, options) } - const seams = await buildSeams() - const worker = await buildWorker(handle, seams) + const seams = await makeSeams() + const worker = await makeWorker(handle, seams) await worker.start() const mediaQueue = calls.find((c) => c.name === MEDIA_CHECKS_JOB) @@ -92,7 +92,7 @@ describe("media-worker wiring", () => { }) it("creates a queue for EVERY job it registers a handler on (pg-boss v10 requires it first)", async () => { - const handle = buildJobs() + const handle = makeJobs() const queues: string[] = [] const worked = new Set() const originalCreate = handle.jobs.createQueue.bind(handle.jobs) @@ -110,8 +110,8 @@ describe("media-worker wiring", () => { worked.add(name) return originalPlainWork(name, handler) } - const seams = await buildSeams() - const worker = await buildWorker(handle, seams) + const seams = await makeSeams() + const worker = await makeWorker(handle, seams) await worker.start() const expected = [ @@ -131,15 +131,15 @@ describe("media-worker wiring", () => { }) it("schedules ALL FIVE crons with their expressions and single-flight expire/singleton options", async () => { - const handle = buildJobs() + const handle = makeJobs() const scheduled: { name: string; cron: string; data: unknown; options?: ScheduleOptions }[] = [] const original = handle.jobs.schedule.bind(handle.jobs) handle.jobs.schedule = (name, cron, data, options) => { scheduled.push({ name, cron, data, ...(options ? { options } : {}) }) return original(name, cron, data, options) } - const seams = await buildSeams() - const worker = await buildWorker(handle, seams) + const seams = await makeSeams() + const worker = await makeWorker(handle, seams) await worker.start() const opts = { expireInSeconds: 1500, singletonKey: "" } @@ -208,7 +208,7 @@ describe("F25: post-success hold-release hook is gated on anon+held report state async function runMediaJobWith( anonHoldRepo: AnonHoldReleaseRepo, ): Promise<{ fake: FakeJobs; repo: InMemoryWorkerRepo }> { - const handle = buildJobs() + const handle = makeJobs() const fake = handle.jobs as unknown as FakeJobs const storage = new FakeStorage() const repo = new InMemoryWorkerRepo() @@ -238,7 +238,7 @@ describe("F25: post-success hold-release hook is gated on anon+held report state report: () => {}, close: () => Promise.resolve(), } - const worker = await buildWorker(handle, seams) + const worker = await makeWorker(handle, seams) await worker.start() await handle.jobs.enqueue(MEDIA_CHECKS_JOB, { @@ -299,16 +299,16 @@ describe("F25: post-success hold-release hook is gated on anon+held report state }) }) -describe("buildSeams production storage guard", () => { +describe("makeSeams production storage guard", () => { it("THROWS when USE_FAKE_STORAGE is on in production (would silently lose media, issue #39)", async () => { await expect( - buildSeams({ NODE_ENV: "production", USE_FAKE_STORAGE: "1" } as NodeJS.ProcessEnv), + makeSeams({ NODE_ENV: "production", USE_FAKE_STORAGE: "1" } as NodeJS.ProcessEnv), ).rejects.toThrow(/USE_FAKE_STORAGE must be 0 in production/) }) it("fires on fake storage even when USE_FAKE_ABUSE_NSFW is off (abuse seam is not what is guarded)", async () => { await expect( - buildSeams({ + makeSeams({ NODE_ENV: "production", USE_FAKE_STORAGE: "1", USE_FAKE_ABUSE_NSFW: "0", From 4c36b8feb35a7f9c88c2e20920e2570e2148da26 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:37:18 +0000 Subject: [PATCH 10/20] api factories named make*, data builders keep build* --- docs/erasure-behavior.md | 2 +- services/api/src/auth/auth-services.ts | 8 ++-- services/api/src/di.ts | 4 +- services/api/src/routes/admin/_audit-read.ts | 2 +- services/api/src/routes/admin/_route-utils.ts | 2 +- .../src/routes/admin/report-chat.routes.ts | 4 +- .../api/src/routes/chat-gateway-wiring.ts | 28 ++++++------- services/api/src/routes/chat-powers-wiring.ts | 4 +- services/api/src/server.ts | 18 ++++----- .../api/test/helpers/auth-harness.test.ts | 10 ++--- services/api/test/helpers/auth.ts | 20 +++++----- .../test/integration/chat-channels-pg.test.ts | 14 +++---- .../test/integration/chat-groups-pg.test.ts | 12 +++--- .../integration/chat-groups-ws-pg.test.ts | 14 +++---- .../api/test/integration/chat-pins-pg.test.ts | 12 +++--- .../test/integration/chat-polls-pg.test.ts | 14 +++---- .../test/integration/cleanups-chat-pg.test.ts | 18 ++++----- services/api/test/integration/map-pg.test.ts | 8 ++-- .../api/test/integration/media-pg.test.ts | 8 ++-- .../test/integration/messages-edit-pg.test.ts | 22 +++++----- .../test/unit/adapter-logger-wiring.test.ts | 4 +- .../api/test/unit/admin-auth-guard.test.ts | 8 ++-- .../unit/admin-auth-routes-security.test.ts | 8 ++-- .../api/test/unit/admin-guard-cache.test.ts | 8 ++-- services/api/test/unit/admin-inbox.test.ts | 8 ++-- .../api/test/unit/admin-routes-http.test.ts | 8 ++-- services/api/test/unit/anon-routes.test.ts | 14 +++---- .../test/unit/auth-context-security.test.ts | 10 ++--- .../api/test/unit/auth-route-mounting.test.ts | 14 +++---- .../api/test/unit/certificate-routes.test.ts | 12 +++--- services/api/test/unit/chat-routes.test.ts | 12 +++--- .../test/unit/claim-routes-security.test.ts | 4 +- .../test/unit/cleanup-slots-routes.test.ts | 12 +++--- .../api/test/unit/cleanups-routes.test.ts | 12 +++--- .../unit/conversation-hides-routes.test.ts | 8 ++-- .../unit/conversation-mutes-routes.test.ts | 10 ++--- .../conversations-routes-security.test.ts | 8 ++-- .../api/test/unit/data-export-route.test.ts | 8 ++-- .../test/unit/delete-account-route.test.ts | 16 ++++---- .../test/unit/delete-message-routes.test.ts | 8 ++-- services/api/test/unit/di-close.test.ts | 12 +++--- services/api/test/unit/di.test.ts | 26 ++++++------ .../api/test/unit/dm-edit-mentions.test.ts | 8 ++-- services/api/test/unit/dm-edit-slur.test.ts | 8 ++-- .../api/test/unit/env-strict-parsing.test.ts | 4 +- .../api/test/unit/errors-http-mapper.test.ts | 2 +- .../api/test/unit/exposed-5xx-copy.test.ts | 6 +-- services/api/test/unit/geo-routes.test.ts | 18 ++++----- .../api/test/unit/guest-rsvp-routes.test.ts | 8 ++-- services/api/test/unit/health.test.ts | 24 +++++------ .../api/test/unit/home-turf-routes.test.ts | 20 +++++----- .../api/test/unit/host/host-routes.test.ts | 12 +++--- .../host/insights-invalidation-wiring.test.ts | 4 +- .../test/unit/host/org-team-routes.test.ts | 12 +++--- .../test/unit/keyset-cursor-forged.test.ts | 8 ++-- .../test/unit/local-storage-routes.test.ts | 12 +++--- .../api/test/unit/map-routes-security.test.ts | 8 ++-- services/api/test/unit/map-routes.test.ts | 40 +++++++++---------- services/api/test/unit/media-routes.test.ts | 20 +++++----- .../api/test/unit/notification-routes.test.ts | 12 +++--- services/api/test/unit/posts-routes.test.ts | 14 +++---- .../test/unit/report-chat-membership.test.ts | 8 ++-- services/api/test/unit/report-chat.test.ts | 8 ++-- .../test/unit/report-content-routes.test.ts | 8 ++-- services/api/test/unit/report-routes.test.ts | 10 ++--- .../test/unit/request-text-hardening.test.ts | 8 ++-- services/api/test/unit/route-coverage.test.ts | 12 +++--- .../api/test/unit/server-timeouts.test.ts | 6 +-- services/api/test/unit/shutdown-drain.test.ts | 22 +++++----- services/api/test/unit/social-routes.test.ts | 8 ++-- .../api/test/unit/thread-read-routes.test.ts | 8 ++-- services/api/test/unit/trust-proxy.test.ts | 4 +- services/api/test/unit/users-routes.test.ts | 8 ++-- services/api/test/unit/version-gate.test.ts | 6 +-- .../test/unit/volunteer-hours-service.test.ts | 12 +++--- .../test/unit/web-base-url-fallback.test.ts | 8 ++-- 76 files changed, 415 insertions(+), 415 deletions(-) diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index 85c8f963..d5dc4872 100644 --- a/docs/erasure-behavior.md +++ b/docs/erasure-behavior.md @@ -215,7 +215,7 @@ tombstone (`softDeleteAndAnonymize`, `services/api/src/auth/pg-stores.ts`): `total_hours`, `entry_count`, the period bounds and `document_sha256`. 3. **Best-effort deletes the R2 object** for each row, after the commit. Same primitive the holder's own `revoke()` uses. Wired in production through - `buildAuthServicesFromContainer`; if the object store is not wired the row + `makeAuthServicesFromContainer`; if the object store is not wired the row scrub still happens and the skipped object is logged. Already-revoked rows are included in the scrub: their PII is no less PII, and diff --git a/services/api/src/auth/auth-services.ts b/services/api/src/auth/auth-services.ts index 541659c9..568df25f 100644 --- a/services/api/src/auth/auth-services.ts +++ b/services/api/src/auth/auth-services.ts @@ -41,7 +41,7 @@ function enabledProvidersFromConfig(config: OAuthConfig): OAuthProvider[] { return providers } -export interface BuildAuthServicesOptions { +export interface MakeAuthServicesOptions { stores: AuthStores cache: CacheClient mailer: Mailer @@ -54,7 +54,7 @@ export interface BuildAuthServicesOptions { audit?: OtpAuditSink } -export function buildAuthServices(opts: BuildAuthServicesOptions): AuthServices { +export function makeAuthServices(opts: MakeAuthServicesOptions): AuthServices { const now = opts.now const sessions = new SessionService({ store: opts.stores.sessions, @@ -91,7 +91,7 @@ export function buildAuthServices(opts: BuildAuthServicesOptions): AuthServices } } -export function buildAuthServicesFromContainer( +export function makeAuthServicesFromContainer( container: Container, opts: { logger?: OtpLogger & SessionLogger } = {}, ): AuthServices { @@ -103,7 +103,7 @@ export function buildAuthServicesFromContainer( }) const cache = new RedisCacheClient(container.getRedis()) const reviewerConfig = reviewerOtpConfigFromEnv(container.env) - return buildAuthServices({ + return makeAuthServices({ stores, cache, mailer: container.mailer, diff --git a/services/api/src/di.ts b/services/api/src/di.ts index ba6b2c20..1c83a23e 100644 --- a/services/api/src/di.ts +++ b/services/api/src/di.ts @@ -166,7 +166,7 @@ function lazy(create: () => T): Lazy { } } -// Adapters are built before buildServer hands over its logger, so they get a forwarder that resolves +// Adapters are built before makeServer hands over its logger, so they get a forwarder that resolves // the server logger at call time instead of capturing a console fallback at construction. function forwardingLogger(resolve: () => NotificationLogger | undefined): AdapterLogger { const forward = (level: "warn" | "error", obj: unknown, msg: string | undefined): void => { @@ -369,7 +369,7 @@ async function closeIfClosable(seam: unknown): Promise { if (typeof closable.close === "function") await closable.close() } -export function buildContainer(env: Env): Container { +export function makeContainer(env: Env): Container { let serverLogger: NotificationLogger | undefined const currentLogger = (): NotificationLogger | undefined => serverLogger const adapterLogger = forwardingLogger(currentLogger) diff --git a/services/api/src/routes/admin/_audit-read.ts b/services/api/src/routes/admin/_audit-read.ts index 4a0ee8b5..e6ef0c45 100644 --- a/services/api/src/routes/admin/_audit-read.ts +++ b/services/api/src/routes/admin/_audit-read.ts @@ -37,7 +37,7 @@ export interface ReadAuditInput { * break nothing. * * Read off `request.server`, so it covers every read-audited route and is inherited through the - * encapsulated admin scope's prototype chain (a test may install it after buildServer). + * encapsulated admin scope's prototype chain (a test may install it after makeServer). */ export interface AdminReadAuditOverrides { sink(input: WriteAuditInput): Promise diff --git a/services/api/src/routes/admin/_route-utils.ts b/services/api/src/routes/admin/_route-utils.ts index cea553d3..3e49364f 100644 --- a/services/api/src/routes/admin/_route-utils.ts +++ b/services/api/src/routes/admin/_route-utils.ts @@ -65,7 +65,7 @@ export function sendOk(reply: FastifyReply): void { /** * The override slot is re-read on every call rather than captured, so a test that installs it after - * buildServer still wins (it is reached through the encapsulated admin scope's prototype chain). + * makeServer still wins (it is reached through the encapsulated admin scope's prototype chain). */ export function overridableService( app: FastifyInstance, diff --git a/services/api/src/routes/admin/report-chat.routes.ts b/services/api/src/routes/admin/report-chat.routes.ts index 892f1b10..c1f8e30d 100644 --- a/services/api/src/routes/admin/report-chat.routes.ts +++ b/services/api/src/routes/admin/report-chat.routes.ts @@ -73,13 +73,13 @@ export async function registerAdminReportChatRoutes( historySource: historySourceFrom(() => overrides.chatRepo), send: overrides.send, }), - () => (containerService ??= buildContainerService()), + () => (containerService ??= makeContainerService()), ) let containerService: AdminReportChatService | undefined let chatRepo: ChatRepository | undefined - function buildContainerService(): AdminReportChatService { + function makeContainerService(): AdminReportChatService { const sql = container.getDb().sql const getChatRepo = (): ChatRepository => (chatRepo ??= makeDrizzleChatRepository(sql, makePrivateMediaPresigner(container.storage))) diff --git a/services/api/src/routes/chat-gateway-wiring.ts b/services/api/src/routes/chat-gateway-wiring.ts index 9b79442f..7a2869e7 100644 --- a/services/api/src/routes/chat-gateway-wiring.ts +++ b/services/api/src/routes/chat-gateway-wiring.ts @@ -263,7 +263,7 @@ interface WiringContext { useFakeChat: boolean } -function buildPresence({ container, overrides, useFakeChat }: WiringContext): ChatPresence { +function makePresence({ container, overrides, useFakeChat }: WiringContext): ChatPresence { return ( overrides?.presence ?? (useFakeChat ? new InMemoryChatPresence() : new RedisChatPresence(container.getRedis())) @@ -284,7 +284,7 @@ interface ChatRepos { getReportRepo(): DiscussionRepository } -function buildChatRepos({ container, overrides, useFakeChat }: WiringContext): ChatRepos { +function makeChatRepos({ container, overrides, useFakeChat }: WiringContext): ChatRepos { let cleanupRepo: CleanupRepository | undefined const getCleanupRepo = (): CleanupRepository => (cleanupRepo ??= makeDrizzleCleanupRepository(container.getDb().sql)) @@ -326,7 +326,7 @@ function buildChatRepos({ container, overrides, useFakeChat }: WiringContext): C } } -function buildGroupChat(repos: ChatRepos): GatewayGroupChat | undefined { +function makeGroupChat(repos: ChatRepos): GatewayGroupChat | undefined { if (!repos.groupWired) return undefined const groups = (): ChatGroupRepository => repos.getGroupsRepo()! return { @@ -371,7 +371,7 @@ interface ChatNotifications { > } -function buildChatNotifications({ +function makeChatNotifications({ app, container, overrides, @@ -439,7 +439,7 @@ function makeThreadRecipientsOf( } } -function buildBellDeps( +function makeBellDeps( { useFakeChat }: WiringContext, repos: ChatRepos, notifications: ChatNotifications, @@ -565,7 +565,7 @@ interface RoomMemberNotifiers { onGroupMessage: OnGroupMessage | undefined } -function buildRoomMemberNotifiers( +function makeRoomMemberNotifiers( { app }: WiringContext, repos: ChatRepos, notifications: ChatNotifications, @@ -640,7 +640,7 @@ function makeOnReportMessage( } } -function buildGatewayReportChat( +function makeWiredGatewayReportChat( { overrides, useFakeChat }: WiringContext, repos: ChatRepos, clearBell: ClearBell, @@ -663,14 +663,14 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha } const { readState, markRoomRead } = conversationReadSeam(app, container) - const presence = buildPresence(ctx) - const repos = buildChatRepos(ctx) + const presence = makePresence(ctx) + const repos = makeChatRepos(ctx) const { isMember, dmRepo, isBlockedEitherWay } = repos - const groupChat = buildGroupChat(repos) + const groupChat = makeGroupChat(repos) const listGroupMembers = makeSharedGroupMemberList(repos.getGroupsRepo) const dmPeerOf = makeDmPeerOf(dmRepo) - const notifications = buildChatNotifications(ctx) + const notifications = makeChatNotifications(ctx) const { notificationService, isMutedFor } = notifications const clearBell = makeClearBell(() => notificationService, app.log) @@ -682,7 +682,7 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha const advanceCleanupWatermark = makeCleanupWatermark(ctx, readState) const threadRecipientsOf = makeThreadRecipientsOf(ctx, repos, dmPeerOf, listGroupMembers) - const bellDeps = buildBellDeps(ctx, repos, notifications, presence) + const bellDeps = makeBellDeps(ctx, repos, notifications, presence) const chatMentions: GatewayChatMentions | undefined = ctx.overrides?.chatMentions ?? (bellDeps @@ -697,7 +697,7 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha const fanoutHandoff = makeRoomFanoutHandoff(ctx) const chatWithResilience = withSendResilience(ctx, repos, makeLazySendDedupe(ctx)) - const { notifyReportChatMembers, onGroupMessage } = buildRoomMemberNotifiers( + const { notifyReportChatMembers, onGroupMessage } = makeRoomMemberNotifiers( ctx, repos, notifications, @@ -709,7 +709,7 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha applyWsUpgradeRateLimit(app) - const reportChat = buildGatewayReportChat(ctx, repos, clearBell) + const reportChat = makeWiredGatewayReportChat(ctx, repos, clearBell) const wsTicketCache = app.authServices?.cache registerChatGateway(app, { diff --git a/services/api/src/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index f763beb7..2b9ced0b 100644 --- a/services/api/src/routes/chat-powers-wiring.ts +++ b/services/api/src/routes/chat-powers-wiring.ts @@ -70,12 +70,12 @@ const resolvers = new WeakMap() export function wireChatPowers(app: FastifyInstance, container: Container): ResolveChatPowers { const cached = resolvers.get(app) if (cached) return cached - const resolver = buildChatPowers(app, container) + const resolver = makeChatPowers(app, container) resolvers.set(app, resolver) return resolver } -function buildChatPowers(app: FastifyInstance, container: Container): ResolveChatPowers { +function makeChatPowers(app: FastifyInstance, container: Container): ResolveChatPowers { const overrides = app.chatOverrides if (overrides?.chatPowers) return overrides.chatPowers diff --git a/services/api/src/server.ts b/services/api/src/server.ts index 35bfbd28..c56d1471 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -1,6 +1,6 @@ import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify" import { loadEnv, type Env } from "./env.js" -import { assertRedisReachable, buildContainer, type Container } from "./di.js" +import { assertRedisReachable, makeContainer, type Container } from "./di.js" import { makeErrorHandler, makeNotFoundHandler } from "./errors/http-mapper.js" import { initErrorReporting } from "./errors/glitchtip.js" import { LOG_REDACTION_CENSOR, redactLogObject } from "./errors/log-redaction.js" @@ -13,7 +13,7 @@ import { registerRateLimit } from "./plugins/rate-limit.js" import { registerVersionGate } from "./versioning/version-gate.js" import { registerAuthContext } from "./auth/context.js" import { registerAccountStatusGuard } from "./auth/account-status.js" -import { buildAuthServicesFromContainer, type AuthServices } from "./auth/auth-services.js" +import { makeAuthServicesFromContainer, type AuthServices } from "./auth/auth-services.js" import type { MediaRepository } from "./services/media-intake-service.js" import type { ReportServiceOverrides } from "./routes/reports.routes.js" import type { AnonServiceOverride } from "./routes/anon.routes.js" @@ -69,7 +69,7 @@ declare module "fastify" { } } -export interface BuildServerOptions { +export interface MakeServerOptions { env?: Env container?: Container authServices?: AuthServices @@ -130,7 +130,7 @@ const OVERRIDE_KEYS = [ "conversationRoutesOverrides", "moderationOverrides", "contentSubjectGate", -] as const satisfies readonly (keyof BuildServerOptions)[] +] as const satisfies readonly (keyof MakeServerOptions)[] // Header and error-envelope paths only. Sensitive keys inside logged objects are censored at any depth by // redactLogObject; wildcard paths here only ever reached one nesting level. @@ -175,9 +175,9 @@ export function loggerOptions(env: Env): LoggerOptions { } } -export async function buildServer(opts: BuildServerOptions = {}): Promise { +export async function makeServer(opts: MakeServerOptions = {}): Promise { const env = opts.env ?? opts.container?.env ?? loadEnv() - const container = opts.container ?? buildContainer(env) + const container = opts.container ?? makeContainer(env) const app = Fastify({ genReqId, @@ -241,14 +241,14 @@ export async function buildServer(opts: BuildServerOptions = {}): Promise { release: SERVICE_VERSION, }) - const app = await buildServer({ env }) + const app = await makeServer({ env }) await startBackgroundJobs(app, env) const shutdown = makeShutdown(app, { diff --git a/services/api/test/helpers/auth-harness.test.ts b/services/api/test/helpers/auth-harness.test.ts index 15d92c2d..3153ab78 100644 --- a/services/api/test/helpers/auth-harness.test.ts +++ b/services/api/test/helpers/auth-harness.test.ts @@ -1,6 +1,6 @@ /** * Tests for the shared offline auth harness itself (test/helpers/auth.ts): the OTP `signIn` primitive and - * `makeAuthHarness`'s pass-through of buildServer overrides. + * `makeAuthHarness`'s pass-through of makeServer overrides. * * ~10 unit suites each hand-rolled this sign-in and its server boot. Before those adopt the shared * versions, the shared versions need their own coverage: that the token really authenticates, that a @@ -13,7 +13,7 @@ import type { FastifyInstance } from "fastify" import type { FakeChatService } from "@civfix/shared/fakes" import { bearer, makeAuthHarness, signIn, type AuthHarness } from "./auth.js" import { InMemoryNotificationRepository } from "./notifications.js" -import { buildContainer } from "../../src/di.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" let open: FastifyInstance | undefined @@ -100,13 +100,13 @@ describe("signIn (shared OTP sign-in)", () => { }) }) -describe("makeAuthHarness (buildServer override pass-through)", () => { +describe("makeAuthHarness (makeServer override pass-through)", () => { it("passes route-service overrides to the app (the injected repo serves the request)", async () => { const repo = new InMemoryNotificationRepository() const h = await harness({ server: { notificationOverrides: { repo } } }) const { token, userId } = await h.signIn("notif-harness@example.com") - // Seeded straight into the injected repo: if the override had not reached buildServer, the route + // Seeded straight into the injected repo: if the override had not reached makeServer, the route // would be reading the (absent) database instead. await repo.insertNotification({ userId, @@ -138,7 +138,7 @@ describe("makeAuthHarness (buildServer override pass-through)", () => { }) it("uses a caller-supplied container rather than building its own", async () => { - const container = buildContainer(loadEnv({ NODE_ENV: "test" })) + const container = makeContainer(loadEnv({ NODE_ENV: "test" })) const h = await harness({ server: { container } }) expect(h.container).toBe(container) }) diff --git a/services/api/test/helpers/auth.ts b/services/api/test/helpers/auth.ts index 7c013a40..32c94c86 100644 --- a/services/api/test/helpers/auth.ts +++ b/services/api/test/helpers/auth.ts @@ -1,7 +1,7 @@ /** * Offline auth test harness. * - * Builds a real Fastify app (via buildServer) but injects an in-memory auth bundle: in-memory stores + * Builds a real Fastify app (via makeServer) but injects an in-memory auth bundle: in-memory stores * + in-memory cache + FakeMailer + a stub JWKS verifier. This exercises the full request path * (routing, validation, transport selection, CSRF, the context hook, the session service) with NO * database and NO Redis, so the whole auth flow runs GREEN locally without Docker. @@ -10,12 +10,12 @@ import type { FastifyInstance } from "fastify" import type { SessionResponse, UserDTO } from "@civfix/shared" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer, type BuildServerOptions } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer, type MakeServerOptions } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv, type Env } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import type { JwksVerifier, VerifiedIdToken, VerifyParams } from "../../src/auth/jwks.js" import type { OAuthConfig } from "../../src/auth/oauth.js" @@ -136,12 +136,12 @@ export interface MakeAuthHarnessOptions { */ env?: Record /** - * buildServer options passed straight through: the route-service overrides (`cleanupOverrides`, + * makeServer options passed straight through: the route-service overrides (`cleanupOverrides`, * `notificationOverrides`, `reportOverrides`, ...) plus an optional pre-built `container` when the test * needs a handle on a seam (e.g. `container.chatService as FakeChatService`). `env` and `authServices` * are owned by the harness and cannot be set here. */ - server?: Omit + server?: Omit } /** @@ -198,7 +198,7 @@ export async function makeAuthHarness(opts: MakeAuthHarnessOptions = {}): Promis const mailer = new FakeMailer() const verifier = new StubJwksVerifier() - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache, mailer, @@ -213,10 +213,10 @@ export async function makeAuthHarness(opts: MakeAuthHarnessOptions = {}): Promis // ALWAYS an explicit source (see HARNESS_ENV_SOURCE): loadEnv(undefined) would read process.env, and a // developer's DATABASE_URL / REDIS_URL / R2 credentials must never reach an offline app. const env = loadEnv(envSource) - // Built explicitly (rather than left to buildServer) so the caller can reach the container's seams: + // Built explicitly (rather than left to makeServer) so the caller can reach the container's seams: // container.chatService / container.pushSender are the fakes several suites assert against. - const container = opts.server?.container ?? buildContainer(env) - const app = await buildServer({ ...opts.server, env, container, authServices: services }) + const container = opts.server?.container ?? makeContainer(env) + const app = await makeServer({ ...opts.server, env, container, authServices: services }) return { app, diff --git a/services/api/test/integration/chat-channels-pg.test.ts b/services/api/test/integration/chat-channels-pg.test.ts index 0cfd9271..bcd2ddb3 100644 --- a/services/api/test/integration/chat-channels-pg.test.ts +++ b/services/api/test/integration/chat-channels-pg.test.ts @@ -7,7 +7,7 @@ * authed user (self-serve join + WS read-only presence join + pre-join history reads); 'private' * stays member-only. * - * HTTP (buildServer + chatOverrides on real repos): + * HTTP (makeServer + chatOverrides on real repos): * - POST /groups/:id/join: public 200 (myRole 'member', memberCount bumps) + idempotent re-join * (no dupe row, count stable); private 403 not_public; * - GET /groups/:id/messages: a non-member reads a PUBLIC channel's history 200, a PRIVATE one 403; @@ -32,12 +32,12 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" @@ -125,7 +125,7 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr beforeAll(async () => { const env = loadEnv({ NODE_ENV: "test" }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -142,8 +142,8 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr reportChat: makeReportChatRepository(h.sql), groups: makeChatGroupRepository(h.sql), } - container = buildContainer(env) - app = await buildServer({ env, container, authServices, chatOverrides: overrides }) + container = makeContainer(env) + app = await makeServer({ env, container, authServices, chatOverrides: overrides }) }) afterAll(async () => { diff --git a/services/api/test/integration/chat-groups-pg.test.ts b/services/api/test/integration/chat-groups-pg.test.ts index eff6db6d..409e265f 100644 --- a/services/api/test/integration/chat-groups-pg.test.ts +++ b/services/api/test/integration/chat-groups-pg.test.ts @@ -30,12 +30,12 @@ import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedMediaAsset } from "../helpers/media-pg.js" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" @@ -69,7 +69,7 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { h = pg as PgHarness const env = loadEnv({ NODE_ENV: "test" }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -98,8 +98,8 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { groupRoleOf: (groupId, userId) => groups.roleOf(groupId, userId), }), } - container = buildContainer(env) - app = await buildServer({ + container = makeContainer(env) + app = await makeServer({ env, container, authServices, diff --git a/services/api/test/integration/chat-groups-ws-pg.test.ts b/services/api/test/integration/chat-groups-ws-pg.test.ts index a4f93c87..a7d74596 100644 --- a/services/api/test/integration/chat-groups-ws-pg.test.ts +++ b/services/api/test/integration/chat-groups-ws-pg.test.ts @@ -13,7 +13,7 @@ * - @mention of a group MEMBER resolves + records the chat_message_mentions row; a non-member * handle resolves to nothing (no row): the chat-mention-resolver group scope end-to-end. * - * HTTP (buildServer + chatOverrides on real repos, FakeChatService watcher for broadcast frames): + * HTTP (makeServer + chatOverrides on real repos, FakeChatService watcher for broadcast frames): * - PATCH /messages roomKind:"group" edits the sender's message (200 + message_update broadcast); * a NON-member 403s (the chat-edit-service group lane); * - POST /messages/reactions toggles in a group room (200, refreshed summary, legacy @@ -29,12 +29,12 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" @@ -410,7 +410,7 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () beforeAll(async () => { const env = loadEnv({ NODE_ENV: "test" }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -428,8 +428,8 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () reportChat: makeReportChatRepository(h.sql), groups: makeChatGroupRepository(h.sql), } - container = buildContainer(env) - app = await buildServer({ env, container, authServices, chatOverrides: overrides }) + container = makeContainer(env) + app = await makeServer({ env, container, authServices, chatOverrides: overrides }) }) afterAll(async () => { diff --git a/services/api/test/integration/chat-pins-pg.test.ts b/services/api/test/integration/chat-pins-pg.test.ts index 415502a0..f35b7cfd 100644 --- a/services/api/test/integration/chat-pins-pg.test.ts +++ b/services/api/test/integration/chat-pins-pg.test.ts @@ -28,12 +28,12 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { withPg, type PgHarness } from "../helpers/pg.js" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" import { roomKeyFor } from "../../src/ws/gateway.js" @@ -71,7 +71,7 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { NODE_ENV: "test", ADMIN_EMAILS: [PIN_OPERATOR_EMAIL, DELETE_OPERATOR_EMAIL].join(","), }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -100,8 +100,8 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { groupRoleOf: (groupId, userId) => makeChatGroupRepository(h.sql).roleOf(groupId, userId), }), } - container = buildContainer(env) - app = await buildServer({ + container = makeContainer(env) + app = await makeServer({ env, container, authServices, diff --git a/services/api/test/integration/chat-polls-pg.test.ts b/services/api/test/integration/chat-polls-pg.test.ts index aca0dcab..51a9b5af 100644 --- a/services/api/test/integration/chat-polls-pg.test.ts +++ b/services/api/test/integration/chat-polls-pg.test.ts @@ -3,7 +3,7 @@ * live PostGIS container (via withPg). * * A poll is a chat_messages row (kind='poll', body=question) plus the chat_polls trio. The three REST - * routes ride the unified /messages plugin (buildServer + chatOverrides on real repos; the container's + * routes ride the unified /messages plugin (makeServer + chatOverrides on real repos; the container's * FakeChatService captures broadcasts via joinRoom'd MockConnections): * * CREATE (POST /messages/poll): @@ -32,12 +32,12 @@ import { WsServerMessageSchema } from "@civfix/shared" import { FakeMailer, FakePushSender } from "@civfix/shared/fakes" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" @@ -66,7 +66,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration beforeAll(async () => { h = pg as PgHarness const env = loadEnv({ NODE_ENV: "test" }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -98,8 +98,8 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration chatPolls: makeChatPollRepository(h.sql), chatPowers, } - container = buildContainer(env) - app = await buildServer({ env, container, authServices, chatOverrides: overrides }) + container = makeContainer(env) + app = await makeServer({ env, container, authServices, chatOverrides: overrides }) }) afterAll(async () => { diff --git a/services/api/test/integration/cleanups-chat-pg.test.ts b/services/api/test/integration/cleanups-chat-pg.test.ts index efa9dc2d..17774d53 100644 --- a/services/api/test/integration/cleanups-chat-pg.test.ts +++ b/services/api/test/integration/cleanups-chat-pg.test.ts @@ -18,12 +18,12 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { withPg, type PgHarness } from "../helpers/pg.js" import { signupSeat } from "../helpers/cleanups.js" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" import { makeDrizzleChatRepository } from "../../src/services/chat-repository.drizzle.js" @@ -254,10 +254,10 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { it("GET /cleanups/:id/messages is membership-gated against real Postgres (200 member, 403 non-member)", async () => { // Real DB-backed cleanup routes (no cleanupOverrides) + an in-memory auth bundle for sessions. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) - const container = buildContainer(env) + const container = makeContainer(env) const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer: container.mailer as never, @@ -265,7 +265,7 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { verifier: new StubJwksVerifier(), now: () => Date.now(), }) - const app: FastifyInstance = await buildServer({ env, container, authServices }) + const app: FastifyInstance = await makeServer({ env, container, authServices }) try { // Create a cleanup owned by an organizer who is also a real session user. @@ -325,10 +325,10 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { // Full server against the real DB (same wiring as the membership-gated route test above) so the // POST /cleanups/:id/messages/:messageId/reactions path, shared-schema parse included, runs for real. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) - const container = buildContainer(env) + const container = makeContainer(env) const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer: container.mailer as never, @@ -336,7 +336,7 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { verifier: new StubJwksVerifier(), now: () => Date.now(), }) - const app: FastifyInstance = await buildServer({ env, container, authServices }) + const app: FastifyInstance = await makeServer({ env, container, authServices }) try { const organizerId = await newUser("Org Reaction") diff --git a/services/api/test/integration/map-pg.test.ts b/services/api/test/integration/map-pg.test.ts index c18b3d59..445bd671 100644 --- a/services/api/test/integration/map-pg.test.ts +++ b/services/api/test/integration/map-pg.test.ts @@ -5,9 +5,9 @@ import { FakeGeocoder, FakeJobs } from "@civfix/shared/fakes" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { clientQuery } from "../helpers/query.js" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" -import { buildContainer } from "../../src/di.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { makeJurisdictionService } from "../../src/services/jurisdiction-service.js" import { @@ -33,8 +33,8 @@ describe.skipIf(!pg)("map routes (integration)", () => { h = pg as PgHarness // Every other seam defaults to its fake outside production. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) - const container = buildContainer(env) - app = await buildServer({ env, container }) + const container = makeContainer(env) + app = await makeServer({ env, container }) }) afterAll(async () => { diff --git a/services/api/test/integration/media-pg.test.ts b/services/api/test/integration/media-pg.test.ts index a818b16b..91154d5a 100644 --- a/services/api/test/integration/media-pg.test.ts +++ b/services/api/test/integration/media-pg.test.ts @@ -2,8 +2,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" import { withPg, type PgHarness } from "../helpers/pg.js" import { publishMediaAsReady } from "../helpers/media-pg.js" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import type { FakeStorage, FakeJobs } from "@civfix/shared/fakes" @@ -22,8 +22,8 @@ describe.skipIf(!pg)("media routes (integration)", () => { h = pg as PgHarness // No injected repo, so the routes use the Drizzle-backed MediaRepository. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) - const container = buildContainer(env) - app = await buildServer({ env, container }) + const container = makeContainer(env) + app = await makeServer({ env, container }) storage = container.storage as unknown as FakeStorage jobs = container.jobs as unknown as FakeJobs }) diff --git a/services/api/test/integration/messages-edit-pg.test.ts b/services/api/test/integration/messages-edit-pg.test.ts index 3eeb6a18..b4d37a5a 100644 --- a/services/api/test/integration/messages-edit-pg.test.ts +++ b/services/api/test/integration/messages-edit-pg.test.ts @@ -5,12 +5,12 @@ import type { FastifyInstance } from "fastify" import type { WsServerMessage } from "@civfix/shared" import { FakeMailer } from "@civfix/shared/fakes" import { withPg, type PgHarness } from "../helpers/pg.js" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryChatRepository, @@ -342,7 +342,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer: new FakeMailer(), @@ -357,7 +357,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { chatRepo: new InMemoryChatRepository(), blocksRepo: makeDrizzleBlocksRepository(h.sql), } - const app: FastifyInstance = await buildServer({ env, authServices, chatOverrides: overrides }) + const app: FastifyInstance = await makeServer({ env, authServices, chatOverrides: overrides }) try { const aliceId = await newUser("DM Edit Alice") @@ -404,7 +404,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { beforeAll(async () => { const env = loadEnv({ NODE_ENV: "test" }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -421,8 +421,8 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { blocksRepo: makeDrizzleBlocksRepository(h.sql), reportChat: makeReportChatRepository(h.sql), } - container = buildContainer(env) - app = await buildServer({ env, container, authServices, chatOverrides: overrides }) + container = makeContainer(env) + app = await makeServer({ env, container, authServices, chatOverrides: overrides }) }) afterAll(async () => { @@ -574,7 +574,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { beforeAll(async () => { const env = loadEnv({ NODE_ENV: "test" }) - authServices = buildAuthServices({ + authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -605,9 +605,9 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { groupRoleOf: (groupId, userId) => groups.roleOf(groupId, userId), }), } - app = await buildServer({ + app = await makeServer({ env, - container: buildContainer(env), + container: makeContainer(env), authServices, chatOverrides: overrides, discussionOverrides: { repo: makeDrizzleDiscussionRepository(h.sql) }, diff --git a/services/api/test/unit/adapter-logger-wiring.test.ts b/services/api/test/unit/adapter-logger-wiring.test.ts index d7433627..ecad17d3 100644 --- a/services/api/test/unit/adapter-logger-wiring.test.ts +++ b/services/api/test/unit/adapter-logger-wiring.test.ts @@ -28,7 +28,7 @@ vi.mock("nodemailer", () => ({ const { PgBossJobs } = await import("../../src/adapters/jobs.pgboss.js") const { OciMailer } = await import("../../src/adapters/mailer.oci.js") -const { buildContainer } = await import("../../src/di.js") +const { makeContainer } = await import("../../src/di.js") const { loadEnv } = await import("../../src/env.js") function recordingLogger() { @@ -82,7 +82,7 @@ describe("adapter logging goes through the injected logger", () => { it("wires the server logger into abuse checks and the push sender built by the container", async () => { const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) - const container = buildContainer( + const container = makeContainer( loadEnv({ NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "0", diff --git a/services/api/test/unit/admin-auth-guard.test.ts b/services/api/test/unit/admin-auth-guard.test.ts index f84a00a9..3127dd0f 100644 --- a/services/api/test/unit/admin-auth-guard.test.ts +++ b/services/api/test/unit/admin-auth-guard.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { WriteAuditInput } from "../../src/services/admin/audit.js" import type { AccessIdentity, VerifyAccessJwt } from "../../src/auth/cf-access.js" @@ -40,7 +40,7 @@ async function makeHarness(opts: { verifyAccessJwt?: VerifyAccessJwt } = {}): Pr const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache, mailer, @@ -52,7 +52,7 @@ async function makeHarness(opts: { verifyAccessJwt?: VerifyAccessJwt } = {}): Pr NODE_ENV: "test", ADMIN_EMAILS: `${ALLOWED}, OTHER@civfix.org, ${ALLOWED_RESERVED_LOCAL}`, }) - const app = await buildServer({ env, authServices: services }) + const app = await makeServer({ env, authServices: services }) const audits: WriteAuditInput[] = [] // The offline harness has no DB to write audits through, and no real CF JWKS for the exchange. app.adminAuthOverrides = { diff --git a/services/api/test/unit/admin-auth-routes-security.test.ts b/services/api/test/unit/admin-auth-routes-security.test.ts index ae4b314e..de8ef234 100644 --- a/services/api/test/unit/admin-auth-routes-security.test.ts +++ b/services/api/test/unit/admin-auth-routes-security.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores, type AccountStatus } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { WriteAuditInput } from "../../src/services/admin/audit.js" @@ -28,7 +28,7 @@ afterEach(async () => { async function harness(opts: { accessConfigured?: boolean } = {}) { const stores = makeInMemoryStores() - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -37,7 +37,7 @@ async function harness(opts: { accessConfigured?: boolean } = {}) { now: () => Date.now(), }) const env = loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: ALLOWED }) - app = await buildServer({ env, authServices: services }) + app = await makeServer({ env, authServices: services }) const audits: WriteAuditInput[] = [] app.adminAuthOverrides = { auditSink: (input) => { diff --git a/services/api/test/unit/admin-guard-cache.test.ts b/services/api/test/unit/admin-guard-cache.test.ts index 951d70f7..5214ca06 100644 --- a/services/api/test/unit/admin-guard-cache.test.ts +++ b/services/api/test/unit/admin-guard-cache.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient, type CacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import type { UserRecord, UserStore } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { OPERATOR_ALLOWLIST_TTL_SECONDS } from "../../src/auth/admin-guard.js" import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" @@ -142,7 +142,7 @@ async function makeHarness(): Promise { const inner = makeInMemoryStores() const users = new SpyUserStore(inner.users) const cache = new SpyCache(new InMemoryCacheClient(() => clock.ms)) - const services = buildAuthServices({ + const services = makeAuthServices({ stores: { ...inner, users }, cache, mailer: new FakeMailer(), @@ -150,7 +150,7 @@ async function makeHarness(): Promise { now: () => clock.ms, }) const env = loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: ALLOWED }) - const app = await buildServer({ + const app = await makeServer({ env, authServices: services, moderationOverrides: { repo: new InMemoryModerationRepository() }, diff --git a/services/api/test/unit/admin-inbox.test.ts b/services/api/test/unit/admin-inbox.test.ts index ff74997a..97371104 100644 --- a/services/api/test/unit/admin-inbox.test.ts +++ b/services/api/test/unit/admin-inbox.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer, FakeStorage } from "@civfix/shared/fakes" import type { MailAttachment } from "@civfix/shared" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" import { MEDIA_GET_URL_TTL_SEC } from "../../src/services/media-intake-service.js" @@ -39,7 +39,7 @@ interface Harness { async function makeHarness(): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache, mailer: new FakeMailer(), @@ -48,7 +48,7 @@ async function makeHarness(): Promise { now: () => Date.now(), }) const env = loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: OPERATOR }) - const app = await buildServer({ env, authServices: services }) + const app = await makeServer({ env, authServices: services }) const repo = new InMemoryInboundRepository() const storage = new RecordingStorage() app.adminInboxOverrides = { repo, storage } diff --git a/services/api/test/unit/admin-routes-http.test.ts b/services/api/test/unit/admin-routes-http.test.ts index 64f242ec..7f63c2cf 100644 --- a/services/api/test/unit/admin-routes-http.test.ts +++ b/services/api/test/unit/admin-routes-http.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeJobs, FakeMailer, FakeStorage } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { WriteAuditInput } from "../../src/services/admin/audit.js" import { InMemoryHomeRepository } from "../../src/services/admin/home-repository.memory.js" @@ -192,7 +192,7 @@ function makeFakes(): Fakes { async function makeHarness(): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache, mailer: new FakeMailer(), @@ -201,7 +201,7 @@ async function makeHarness(): Promise { now: () => Date.now(), }) const env = loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: OPERATOR }) - const app = await buildServer({ env, authServices: services }) + const app = await makeServer({ env, authServices: services }) const fakes = makeFakes() const mailer = new FakeMailer() const outboundMail = makeOutboundMailService({ diff --git a/services/api/test/unit/anon-routes.test.ts b/services/api/test/unit/anon-routes.test.ts index be775e5f..224ad6f3 100644 --- a/services/api/test/unit/anon-routes.test.ts +++ b/services/api/test/unit/anon-routes.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, afterEach } from "vitest" import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { FakeAbuseChecks, FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeContainer, type Container } from "../../src/di.js" import { signAnonToken } from "../../src/abuse/anon-token.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" import { makeAnonService } from "../../src/services/anon-service.js" @@ -48,7 +48,7 @@ async function makeHarness(): Promise { const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() const verifier = new StubJwksVerifier() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -107,7 +107,7 @@ async function makeHarness(): Promise { presignMedia: (r2Key) => Promise.resolve({ url: `memory://${r2Key}` }), } - const app = await buildServer({ + const app = await makeServer({ env, authServices, reportOverrides, @@ -517,10 +517,10 @@ describe("F131: the PRODUCTION anon service raises abuse flags and logs", () => const env = loadEnv({ NODE_ENV: "test" }) const db = makeFakeSql() const container = { - ...buildContainer(env), + ...makeContainer(env), getDb: () => ({ sql: db.sql }), } as unknown as Container - const app = await buildServer({ env, container }) + const app = await makeServer({ env, container }) const logs: { line: string; extra: Record }[] = [] app.log.info = ((extra: Record, line: string) => { logs.push({ line, extra }) diff --git a/services/api/test/unit/auth-context-security.test.ts b/services/api/test/unit/auth-context-security.test.ts index f3f2c9ad..e754317c 100644 --- a/services/api/test/unit/auth-context-security.test.ts +++ b/services/api/test/unit/auth-context-security.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { signAnonToken } from "../../src/abuse/anon-token.js" import { InMemoryMediaRepository } from "../helpers/media.js" @@ -19,7 +19,7 @@ async function uploadWithCookie(cookie: string | null): Promise { const env = loadEnv({ NODE_ENV: "test" }) const subjects: string[] = [] const container = { - ...buildContainer(env), + ...makeContainer(env), env: { ...env, REDIS_URL: "redis://cache:6379" }, getByteMeter: () => ({ add: (subject: string, bytes: number) => { @@ -27,8 +27,8 @@ async function uploadWithCookie(cookie: string | null): Promise { return Promise.resolve(bytes) }, }), - } as unknown as ReturnType - app = await buildServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) + } as unknown as ReturnType + app = await makeServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) const res = await app.inject({ method: "POST", url: "/v1/media/upload", diff --git a/services/api/test/unit/auth-route-mounting.test.ts b/services/api/test/unit/auth-route-mounting.test.ts index 17309726..b31646c2 100644 --- a/services/api/test/unit/auth-route-mounting.test.ts +++ b/services/api/test/unit/auth-route-mounting.test.ts @@ -8,17 +8,17 @@ * The deploy fix forces NODE_ENV=production; these tests lock the invariant it relies on: the auth and * admin routes mount IFF an auth bundle is present, and the DATABASE_URL/REDIS_URL pair drives that. * - * Infra-free: the "present" case asserts only that buildServer DECORATED the bundle, before any + * Infra-free: the "present" case asserts only that makeServer DECORATED the bundle, before any * ready()/inject(), so the lazily-connecting DB/Redis handles never open a socket. */ import { describe, it, expect } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" /** The not-found handler's message prefix; a domain 404 (AppError) never starts with this. */ @@ -26,7 +26,7 @@ const routeMissingPrefix = (method: string): string => `Route ${method} ` describe("auth/admin route mounting is gated on the auth bundle", () => { it("does NOT mount /auth/* or /admin/* with no DATABASE_URL/REDIS_URL (reproduces the reported 404)", async () => { - const app = await buildServer({ env: loadEnv({ NODE_ENV: "test" }) }) + const app = await makeServer({ env: loadEnv({ NODE_ENV: "test" }) }) try { expect(app.hasDecorator("authServices")).toBe(false) @@ -55,7 +55,7 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { it("builds the auth bundle when DATABASE_URL + REDIS_URL are present (the gate opens)", async () => { // ioredis is lazyConnect and postgres connects on first query, so without ready()/inject() the bogus // URLs below are never dialed. - const app = await buildServer({ + const app = await makeServer({ env: loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://civfix:civfix@127.0.0.1:5432/civfix", @@ -72,7 +72,7 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { it("serves GET /admin/auth/session (200, unauthenticated) when an auth bundle is present", async () => { // The anonymous path answers without any DB/Redis call; it is what the SPA bootstrap reads before // falling back to the Access exchange. - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer: new FakeMailer(), @@ -80,7 +80,7 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { verifier: new StubJwksVerifier(), now: () => Date.now(), }) - const app = await buildServer({ env: loadEnv({ NODE_ENV: "test" }), authServices }) + const app = await makeServer({ env: loadEnv({ NODE_ENV: "test" }), authServices }) try { const res = await app.inject({ method: "GET", url: "/v1/admin/auth/session" }) expect(res.statusCode).toBe(200) diff --git a/services/api/test/unit/certificate-routes.test.ts b/services/api/test/unit/certificate-routes.test.ts index d9a88f33..4d9e6a71 100644 --- a/services/api/test/unit/certificate-routes.test.ts +++ b/services/api/test/unit/certificate-routes.test.ts @@ -18,12 +18,12 @@ import { randomUUID } from "node:crypto" import { CERTIFICATE_CODE_RE, formatCertificateCode } from "@civfix/shared" import { FakeMailer, FakeStorage } from "@civfix/shared/fakes" import type { StorageHead, StoragePutMeta } from "@civfix/shared/interfaces" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" @@ -101,7 +101,7 @@ async function makeHarness(): Promise { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache: new InMemoryCacheClient(() => Date.now()), mailer, @@ -116,8 +116,8 @@ async function makeHarness(): Promise { const objects = new FakeStorage() const storage = new RecordingStorage(objects) - const container = buildContainer(env) - const app = await buildServer({ + const container = makeContainer(env) + const app = await makeServer({ env, container, authServices, diff --git a/services/api/test/unit/chat-routes.test.ts b/services/api/test/unit/chat-routes.test.ts index 7f0a27c4..a8ac2ae7 100644 --- a/services/api/test/unit/chat-routes.test.ts +++ b/services/api/test/unit/chat-routes.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance, FastifyRequest } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { SessionService } from "../../src/auth/session-service.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" @@ -35,7 +35,7 @@ async function makeThreadsHarness(seed: (repo: InMemoryThreadsRepository) => voi const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -51,7 +51,7 @@ async function makeThreadsHarness(seed: (repo: InMemoryThreadsRepository) => voi threadsRepo, } - const app = await buildServer({ env, authServices, chatOverrides }) + const app = await makeServer({ env, authServices, chatOverrides }) current = app return { app, mailer } } @@ -101,7 +101,7 @@ describe("GET /threads", () => { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -114,7 +114,7 @@ describe("GET /threads", () => { isMember: () => Promise.resolve(true), threadsRepo, } - const app = await buildServer({ env, authServices, chatOverrides }) + const app = await makeServer({ env, authServices, chatOverrides }) current = app const { token, userId } = await signIn(app, mailer, "member@example.com") diff --git a/services/api/test/unit/claim-routes-security.test.ts b/services/api/test/unit/claim-routes-security.test.ts index 3f74b281..1404bbb9 100644 --- a/services/api/test/unit/claim-routes-security.test.ts +++ b/services/api/test/unit/claim-routes-security.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import type { ClaimService } from "../../src/services/claim-service.js" @@ -25,7 +25,7 @@ async function serverWithNudges(): Promise<{ app: FastifyInstance; nudged: strin }, claimReport: () => Promise.reject(new Error("unused")), } - app = await buildServer({ + app = await makeServer({ env: loadEnv({ NODE_ENV: "test", WEB_ORIGINS: WEB_ORIGIN }), claimOverride: { service }, }) diff --git a/services/api/test/unit/cleanup-slots-routes.test.ts b/services/api/test/unit/cleanup-slots-routes.test.ts index 7259b6fa..897e3c31 100644 --- a/services/api/test/unit/cleanup-slots-routes.test.ts +++ b/services/api/test/unit/cleanup-slots-routes.test.ts @@ -19,12 +19,12 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { endpoints, versionedPath } from "@civfix/shared/client" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import type { CleanupServiceOverrides } from "../../src/routes/cleanups.routes.js" @@ -43,7 +43,7 @@ async function makeHarness(): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -54,8 +54,8 @@ async function makeHarness(): Promise { const repo = new InMemoryCleanupRepository() const cleanupOverrides: CleanupServiceOverrides = { repo } - const container = buildContainer(env) - const app = await buildServer({ env, container, authServices, cleanupOverrides }) + const container = makeContainer(env) + const app = await makeServer({ env, container, authServices, cleanupOverrides }) const email = "host@example.com" await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) diff --git a/services/api/test/unit/cleanups-routes.test.ts b/services/api/test/unit/cleanups-routes.test.ts index deff0d9b..ac81982c 100644 --- a/services/api/test/unit/cleanups-routes.test.ts +++ b/services/api/test/unit/cleanups-routes.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import type { FakeChatService } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { clientQuery } from "../helpers/query.js" @@ -34,7 +34,7 @@ async function makeHarness(seed?: (repo: InMemoryCleanupRepository) => void): Pr const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() const verifier = new StubJwksVerifier() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -47,10 +47,10 @@ async function makeHarness(seed?: (repo: InMemoryCleanupRepository) => void): Pr if (seed) seed(repo) const cleanupOverrides: CleanupServiceOverrides = { repo } - const container = buildContainer(env) + const container = makeContainer(env) const chat = container.chatService as FakeChatService - const app = await buildServer({ env, container, authServices, cleanupOverrides }) + const app = await makeServer({ env, container, authServices, cleanupOverrides }) const email = "organizer@example.com" const { token, userId } = await signIn(app, mailer, email) diff --git a/services/api/test/unit/conversation-hides-routes.test.ts b/services/api/test/unit/conversation-hides-routes.test.ts index 8e22f68a..630a2da7 100644 --- a/services/api/test/unit/conversation-hides-routes.test.ts +++ b/services/api/test/unit/conversation-hides-routes.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach, vi } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { ConversationRoutesOverrides } from "../../src/routes/conversations.routes.js" import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" @@ -42,7 +42,7 @@ async function makeHarness(participates?: Participates): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -58,7 +58,7 @@ async function makeHarness(participates?: Participates): Promise { participates: participates ?? (() => Promise.resolve(true)), } - const app = await buildServer({ env, authServices, conversationRoutesOverrides }) + const app = await makeServer({ env, authServices, conversationRoutesOverrides }) const email = "hider@example.com" await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) diff --git a/services/api/test/unit/conversation-mutes-routes.test.ts b/services/api/test/unit/conversation-mutes-routes.test.ts index 1aa9f448..25fe7bf4 100644 --- a/services/api/test/unit/conversation-mutes-routes.test.ts +++ b/services/api/test/unit/conversation-mutes-routes.test.ts @@ -1,18 +1,18 @@ import { describe, it, expect, afterEach, vi } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { ConversationRoutesOverrides } from "../../src/routes/conversations.routes.js" import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" /** * Route-level tests for PUT /conversations/mute, run with NO database: a fake - * ConversationMutesRepository is injected via buildServer(opts.conversationRoutesOverrides), and a full + * ConversationMutesRepository is injected via makeServer(opts.conversationRoutesOverrides), and a full * in-memory auth bundle mints a real bearer session. Bearer transport is CSRF-exempt (see auth/csrf.ts), * so this state-changing PUT needs only the Authorization header. */ @@ -47,7 +47,7 @@ async function makeHarness(participates?: Participates): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -62,7 +62,7 @@ async function makeHarness(participates?: Participates): Promise { participates: participates ?? (() => Promise.resolve(true)), } - const app = await buildServer({ env, authServices, conversationRoutesOverrides }) + const app = await makeServer({ env, authServices, conversationRoutesOverrides }) const email = "muter@example.com" await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) diff --git a/services/api/test/unit/conversations-routes-security.test.ts b/services/api/test/unit/conversations-routes-security.test.ts index b9da9cac..7c732be5 100644 --- a/services/api/test/unit/conversations-routes-security.test.ts +++ b/services/api/test/unit/conversations-routes-security.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach, vi } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { ConversationRoutesOverrides } from "../../src/routes/conversations.routes.js" @@ -30,7 +30,7 @@ afterEach(async () => { async function harnessWithoutGate(): Promise { const env = loadEnv({ NODE_ENV: "test" }) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer, @@ -51,7 +51,7 @@ async function harnessWithoutGate(): Promise { markRoomRead, } as unknown as ConversationRoutesOverrides - const app = await buildServer({ + const app = await makeServer({ env, authServices, conversationRoutesOverrides: overridesMissingGate, diff --git a/services/api/test/unit/data-export-route.test.ts b/services/api/test/unit/data-export-route.test.ts index def14ec9..2e4e22d9 100644 --- a/services/api/test/unit/data-export-route.test.ts +++ b/services/api/test/unit/data-export-route.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer, type FakeJobs } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores, type InMemoryUserStore } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { DATA_EXPORT_JOB } from "../../src/lib/queue-names.js" @@ -26,7 +26,7 @@ async function harness(): Promise<{ const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache, mailer, @@ -34,7 +34,7 @@ async function harness(): Promise<{ verifier: new StubJwksVerifier(), now: () => Date.now(), }) - const app = await buildServer({ env, authServices: services }) + const app = await makeServer({ env, authServices: services }) current = app return { app, mailer, services } } diff --git a/services/api/test/unit/delete-account-route.test.ts b/services/api/test/unit/delete-account-route.test.ts index d4ded1de..aaa92291 100644 --- a/services/api/test/unit/delete-account-route.test.ts +++ b/services/api/test/unit/delete-account-route.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeContainer, type Container } from "../../src/di.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores, type InMemoryUserStore } from "../../src/auth/stores.js" -import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" @@ -23,7 +23,7 @@ async function harness(): Promise<{ app: FastifyInstance; mailer: FakeMailer }> const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -31,7 +31,7 @@ async function harness(): Promise<{ app: FastifyInstance; mailer: FakeMailer }> verifier: new StubJwksVerifier(), now: () => Date.now(), }) - const app = await buildServer({ env, authServices }) + const app = await makeServer({ env, authServices }) current = app return { app, mailer } } @@ -47,7 +47,7 @@ async function cleanupHarness(): Promise<{ const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const services = buildAuthServices({ + const services = makeAuthServices({ stores, cache, mailer, @@ -62,10 +62,10 @@ async function cleanupHarness(): Promise<{ } const fake = makeFakeSql([{ match: /INSERT INTO audit_log/i, rows: [{ id: "audit-1" }] }]) const container = { - ...buildContainer(env), + ...makeContainer(env), getDb: () => ({ sql: fake.sql }), } as unknown as Container - const app = await buildServer({ env, container, authServices: services }) + const app = await makeServer({ env, container, authServices: services }) current = app return { app, diff --git a/services/api/test/unit/delete-message-routes.test.ts b/services/api/test/unit/delete-message-routes.test.ts index daa08eb6..ad9704d2 100644 --- a/services/api/test/unit/delete-message-routes.test.ts +++ b/services/api/test/unit/delete-message-routes.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryBlocksRepository, @@ -43,7 +43,7 @@ async function harness(): Promise<{ const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -63,7 +63,7 @@ async function harness(): Promise<{ chatRepo, blocksRepo: blocks, } - const app = await buildServer({ env, authServices, chatOverrides: overrides }) + const app = await makeServer({ env, authServices, chatOverrides: overrides }) current = app return { app, mailer, dmRepo, chatRepo } } diff --git a/services/api/test/unit/di-close.test.ts b/services/api/test/unit/di-close.test.ts index ea72ddfc..658b8bec 100644 --- a/services/api/test/unit/di-close.test.ts +++ b/services/api/test/unit/di-close.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest" -import { buildContainer } from "../../src/di.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" describe("DI container after close()", () => { it("refuses to reopen a database pool or hand out repositories bound to the closed one", async () => { - const container = buildContainer( + const container = makeContainer( loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost:5432/civfix" }), ) container.getVolunteerHoursRepo() @@ -20,7 +20,7 @@ describe("DI container after close()", () => { }) it("refuses to reopen Redis after close()", async () => { - const container = buildContainer( + const container = makeContainer( loadEnv({ NODE_ENV: "test", REDIS_URL: "redis://localhost:6379" }), ) @@ -31,7 +31,7 @@ describe("DI container after close()", () => { }) it("lets a job still in flight during the graceful jobs stop reach the database", async () => { - const container = buildContainer( + const container = makeContainer( loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost:5432/civfix" }), ) const poolBeforeStop = container.getDb() @@ -53,7 +53,7 @@ describe("DI container after close()", () => { }) it("closes a pool a draining job opened, instead of leaking it", async () => { - const container = buildContainer( + const container = makeContainer( loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgres://u:p@localhost:5432/civfix" }), ) Object.assign(container.jobs, { @@ -69,7 +69,7 @@ describe("DI container after close()", () => { }) it("tolerates a second close()", async () => { - const container = buildContainer(loadEnv({ NODE_ENV: "test" })) + const container = makeContainer(loadEnv({ NODE_ENV: "test" })) await container.close() await expect(container.close()).resolves.toBeUndefined() }) diff --git a/services/api/test/unit/di.test.ts b/services/api/test/unit/di.test.ts index a53304bb..d1f5de4c 100644 --- a/services/api/test/unit/di.test.ts +++ b/services/api/test/unit/di.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { buildContainer } from "../../src/di.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { FakeStorage, @@ -22,7 +22,7 @@ import { describe("DI container", () => { it("selects all fakes in dev/test mode and leaves db/redis uncreated", () => { - const c = buildContainer(loadEnv({ NODE_ENV: "test" })) + const c = makeContainer(loadEnv({ NODE_ENV: "test" })) expect(c.storage).toBeInstanceOf(FakeStorage) expect(c.mailer).toBeInstanceOf(FakeMailer) expect(c.jobs).toBeInstanceOf(FakeJobs) @@ -45,7 +45,7 @@ describe("DI container", () => { R2_SECRET_ACCESS_KEY: "c", R2_BUCKET: "d", }) - const c = buildContainer(env) + const c = makeContainer(env) expect(c.storage).toBeInstanceOf(R2Storage) expect(c.mailer).toBeInstanceOf(FakeMailer) }) @@ -57,7 +57,7 @@ describe("DI container", () => { DATABASE_URL: "postgres://u:p@localhost:5432/civfix", REDIS_URL: "redis://localhost:6379", }) - const c = buildContainer(env) + const c = makeContainer(env) expect(c.chatService).toBeInstanceOf(WsChatService) expect(c.dbHandle).toBeDefined() expect(c.redis).toBeDefined() @@ -69,24 +69,24 @@ describe("DI container", () => { USE_FAKE_JOBS: "0", DATABASE_URL: "postgres://u:p@localhost:5432/civfix", }) - const c = buildContainer(env) + const c = makeContainer(env) expect(c.jobs).toBeInstanceOf(PgBossJobs) }) it("close() is safe to call when nothing was created", async () => { - const c = buildContainer(loadEnv({ NODE_ENV: "test" })) + const c = makeContainer(loadEnv({ NODE_ENV: "test" })) await expect(c.close()).resolves.toBeUndefined() }) it("selects in-memory blocks/DM repos only when there is no database (F030)", () => { - const c = buildContainer(loadEnv({ NODE_ENV: "test" })) + const c = makeContainer(loadEnv({ NODE_ENV: "test" })) expect(c.getBlocksRepo()).toBeInstanceOf(InMemoryBlocksRepository) expect(c.getDmRepo()).toBeInstanceOf(InMemoryDmRepository) expect(c.dbHandle).toBeUndefined() }) it("selects Drizzle blocks/DM repos when a database is configured, even with USE_FAKE_CHAT on (F030)", () => { - const c = buildContainer( + const c = makeContainer( loadEnv({ NODE_ENV: "test", USE_FAKE_CHAT: "1", @@ -103,7 +103,7 @@ describe("DI container", () => { const configOf = (checks: unknown): { turnstileHostnames?: readonly string[] } => (checks as { config: { turnstileHostnames?: readonly string[] } }).config - const wired = buildContainer( + const wired = makeContainer( loadEnv({ NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "0", @@ -113,7 +113,7 @@ describe("DI container", () => { ) expect(configOf(wired.abuseChecks).turnstileHostnames).toEqual(["civfix.org", "www.civfix.org"]) - const unwired = buildContainer( + const unwired = makeContainer( loadEnv({ NODE_ENV: "test", USE_FAKE_ABUSE_NSFW: "0", CF_TURNSTILE_SECRET: "ts-secret" }), ) expect(configOf(unwired.abuseChecks).turnstileHostnames).toBeUndefined() @@ -138,7 +138,7 @@ describe("DI container: inbound-mail storage is never public", () => { R2_PUBLIC_BASE: "https://cdn.civfix.org", R2_INBOUND_BUCKET: "civfix-inbound", }) - const c = buildContainer(env) + const c = makeContainer(env) const media = (c.storage as unknown as WithConfig).config const inbound = (c.inboundStorage as unknown as WithConfig).config expect(inbound.bucket).toBe("civfix-inbound") @@ -149,11 +149,11 @@ describe("DI container: inbound-mail storage is never public", () => { it("throws rather than falling back to the media bucket when a public base is set", () => { const env = { ...loadEnv(realStorageEnv), R2_PUBLIC_BASE: "https://cdn.civfix.org" } - expect(() => buildContainer(env)).toThrow(/R2_INBOUND_BUCKET is required/) + expect(() => makeContainer(env)).toThrow(/R2_INBOUND_BUCKET is required/) }) it("still shares the media bucket when nothing is publicly addressable", () => { - const c = buildContainer(loadEnv(realStorageEnv)) + const c = makeContainer(loadEnv(realStorageEnv)) expect((c.inboundStorage as unknown as WithConfig).config.bucket).toBe("civfix-media") }) }) diff --git a/services/api/test/unit/dm-edit-mentions.test.ts b/services/api/test/unit/dm-edit-mentions.test.ts index b3222720..a36ffc16 100644 --- a/services/api/test/unit/dm-edit-mentions.test.ts +++ b/services/api/test/unit/dm-edit-mentions.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import type { UserMentionDTO } from "@civfix/shared" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryBlocksRepository, @@ -35,7 +35,7 @@ async function harness(chatMentions: ChatGatewayOverrides["chatMentions"]): Prom const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -54,7 +54,7 @@ async function harness(chatMentions: ChatGatewayOverrides["chatMentions"]): Prom blocksRepo: blocks, chatMentions, } - const app = await buildServer({ env, authServices, chatOverrides: overrides }) + const app = await makeServer({ env, authServices, chatOverrides: overrides }) current = app return { app, mailer, dmRepo } } diff --git a/services/api/test/unit/dm-edit-slur.test.ts b/services/api/test/unit/dm-edit-slur.test.ts index 4e827a95..af0b8c38 100644 --- a/services/api/test/unit/dm-edit-slur.test.ts +++ b/services/api/test/unit/dm-edit-slur.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryBlocksRepository, @@ -39,7 +39,7 @@ async function harness(): Promise<{ const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -57,7 +57,7 @@ async function harness(): Promise<{ chatRepo: new InMemoryChatRepository(), blocksRepo: blocks, } - const app = await buildServer({ env, authServices, chatOverrides: overrides }) + const app = await makeServer({ env, authServices, chatOverrides: overrides }) current = app return { app, mailer, dmRepo } } diff --git a/services/api/test/unit/env-strict-parsing.test.ts b/services/api/test/unit/env-strict-parsing.test.ts index 2fb54180..5767d42f 100644 --- a/services/api/test/unit/env-strict-parsing.test.ts +++ b/services/api/test/unit/env-strict-parsing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { loadEnv } from "../../src/env.js" -import { buildContainer } from "../../src/di.js" +import { makeContainer } from "../../src/di.js" function validProdEnv(): NodeJS.ProcessEnv { return { @@ -71,7 +71,7 @@ describe("loadEnv: APNS_PRODUCTION", () => { describe("push config: APNs gateway default", () => { it("targets the production gateway when APNS_PRODUCTION is unset", () => { - const container = buildContainer( + const container = makeContainer( loadEnv({ NODE_ENV: "test", USE_FAKE_PUSH: "0", diff --git a/services/api/test/unit/errors-http-mapper.test.ts b/services/api/test/unit/errors-http-mapper.test.ts index 652093f4..6967882d 100644 --- a/services/api/test/unit/errors-http-mapper.test.ts +++ b/services/api/test/unit/errors-http-mapper.test.ts @@ -1,5 +1,5 @@ /** - * The canonical error handler, driven through a real Fastify instance exactly as buildServer wires it: + * The canonical error handler, driven through a real Fastify instance exactly as makeServer wires it: * * - ZodError is matched STRUCTURALLY (name + issues, NOT instanceof), so one thrown across the * dual-zod-realm boundary does not render as a 500; diff --git a/services/api/test/unit/exposed-5xx-copy.test.ts b/services/api/test/unit/exposed-5xx-copy.test.ts index f1c4dd18..98ae9ea5 100644 --- a/services/api/test/unit/exposed-5xx-copy.test.ts +++ b/services/api/test/unit/exposed-5xx-copy.test.ts @@ -2,11 +2,11 @@ import Fastify, { type FastifyInstance } from "fastify" import { afterEach, describe, expect, it, vi } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" import { registerUnsubscribeRoutes } from "../../src/routes/host/unsubscribe.routes.js" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { mintUnsubscribeToken, unsubscribeExpiryFrom, @@ -34,7 +34,7 @@ afterEach(async () => { describe("5xx copy written for the people who see it survives production masking", () => { it("tells a home-turf coach the form is temporarily unavailable", async () => { const env = loadEnv({ NODE_ENV: "test", HOME_TURF_NOTIFY_TO: "home-turf@civfix.test" }) - app = await buildServer({ env, container: buildContainer(env) }) + app = await makeServer({ env, container: makeContainer(env) }) const res = await app.inject({ method: "POST", diff --git a/services/api/test/unit/geo-routes.test.ts b/services/api/test/unit/geo-routes.test.ts index fe4ddaf3..a7c2d2b5 100644 --- a/services/api/test/unit/geo-routes.test.ts +++ b/services/api/test/unit/geo-routes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" let app: FastifyInstance | undefined @@ -17,7 +17,7 @@ const TRUSTED_EDGE_HEADERS = { "x-forwarded-for": "203.0.113.7" } describe("GET /geo/approximate", () => { it("answers with the configured home region when no CF geo headers are present", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate" }) expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ @@ -36,14 +36,14 @@ describe("GET /geo/approximate", () => { HOME_REGION_LNG: "-87.6298", HOME_REGION_RADIUS_KM: "25", }) - app = await buildServer({ env, container: buildContainer(env) }) + app = await makeServer({ env, container: makeContainer(env) }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate" }) expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ lat: 41.8781, lng: -87.6298, radiusKm: 25, source: "region" }) }) it("uses the Cloudflare IP fix when the request came through the trusted proxy path", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate", @@ -58,7 +58,7 @@ describe("GET /geo/approximate", () => { }) it("tightens the radius when Cloudflare resolved a city", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate", @@ -73,7 +73,7 @@ describe("GET /geo/approximate", () => { }) it("IGNORES CF geo headers supplied by an untrusted client and answers with the region", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate", @@ -94,7 +94,7 @@ describe("GET /geo/approximate", () => { }) it("falls back to the region when a trusted-edge header is malformed", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate", @@ -109,7 +109,7 @@ describe("GET /geo/approximate", () => { }) it("422s an unrecognized query parameter (the request contract is strict)", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/geo/approximate?foo=1" }) expect(res.statusCode).toBe(422) expect(res.json().code).toBe("VALIDATION") diff --git a/services/api/test/unit/guest-rsvp-routes.test.ts b/services/api/test/unit/guest-rsvp-routes.test.ts index 1f4dafff..a0d57134 100644 --- a/services/api/test/unit/guest-rsvp-routes.test.ts +++ b/services/api/test/unit/guest-rsvp-routes.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, beforeAll, afterAll } from "vitest" import type { FastifyInstance } from "fastify" import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" import { GUEST_OTP_ERROR_FIELD, GuestOtpErrorReason } from "@civfix/shared" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" @@ -17,9 +17,9 @@ beforeAll(async () => { const env = loadEnv({ NODE_ENV: "test" }) repo.seedEvent({ id: EVENT_ID, title: "Beach cleanup" }) repo.memberCounts.set(EVENT_ID, 2) - app = await buildServer({ + app = await makeServer({ env, - container: buildContainer(env), + container: makeContainer(env), guestRsvpOverrides: { repo, requireGuestContact: () => Promise.resolve(), diff --git a/services/api/test/unit/health.test.ts b/services/api/test/unit/health.test.ts index d0408929..ab52670a 100644 --- a/services/api/test/unit/health.test.ts +++ b/services/api/test/unit/health.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import type { RedisClient } from "../../src/adapters/redis.js" import { loadEnv } from "../../src/env.js" @@ -16,7 +16,7 @@ describe("health routes", () => { }) it("GET /healthz returns 200 { ok: true }", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/healthz" }) expect(res.statusCode).toBe(200) const body = res.json() @@ -27,7 +27,7 @@ describe("health routes", () => { }) it("L19: /readyz is rate limited (no longer on the limiter allowlist), /healthz is not", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const ready = await app.inject({ method: "GET", url: "/readyz" }) expect(ready.headers["x-ratelimit-limit"]).toBeDefined() expect(ready.headers["cache-control"]).toBe("no-store") @@ -38,11 +38,11 @@ describe("health routes", () => { it("L19: /readyz memoizes its verdict so a flood costs one backend probe", async () => { let pings = 0 const env = loadEnv() - const container = buildContainer(env) + const container = makeContainer(env) const stub = { ping: async () => (pings++, "PONG") } as unknown as RedisClient Object.defineProperty(container, "redis", { get: () => stub }) Object.defineProperty(container, "getRedis", { value: () => stub }) - app = await buildServer({ env, container }) + app = await makeServer({ env, container }) for (let i = 0; i < 5; i++) { const res = await app.inject({ method: "GET", url: "/readyz" }) @@ -52,7 +52,7 @@ describe("health routes", () => { }) it("echoes x-request-id header", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/healthz", @@ -62,7 +62,7 @@ describe("health routes", () => { }) it("GET /readyz returns 200 with skipped checks in all-fakes mode", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/readyz" }) expect(res.statusCode).toBe(200) const body = res.json() @@ -80,7 +80,7 @@ describe("health routes", () => { USE_FAKE_JOBS: "1", USE_FAKE_USER_CHANNEL: "1", }) - const container = buildContainer(env) + const container = makeContainer(env) expect(container.usesRealDb).toBe(true) expect(container.usesRealRedis).toBe(false) Object.defineProperty(container, "getDb", { @@ -88,7 +88,7 @@ describe("health routes", () => { sql: () => Promise.reject(new Error("db down")), }), }) - app = await buildServer({ env, container }) + app = await makeServer({ env, container }) const res = await app.inject({ method: "GET", url: "/readyz" }) expect(res.statusCode).toBe(503) const body = res.json() @@ -97,7 +97,7 @@ describe("health routes", () => { }) it("unknown route returns the 404 error envelope", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/does-not-exist" }) expect(res.statusCode).toBe(404) const body = res.json() @@ -106,7 +106,7 @@ describe("health routes", () => { }) it("P2-6: sets a lock-down CSP and keeps nosniff on responses", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/healthz" }) const csp = res.headers["content-security-policy"] expect(typeof csp).toBe("string") diff --git a/services/api/test/unit/home-turf-routes.test.ts b/services/api/test/unit/home-turf-routes.test.ts index 60e3d00b..03021ea8 100644 --- a/services/api/test/unit/home-turf-routes.test.ts +++ b/services/api/test/unit/home-turf-routes.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import type { FakeMailer, FakeAbuseChecks } from "@civfix/shared/fakes" import type { OutboundEmail } from "@civfix/shared/interfaces" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { @@ -17,7 +17,7 @@ import { interface Harness { app: FastifyInstance mailer: FakeMailer - container: ReturnType + container: ReturnType } let current: Harness | undefined @@ -33,8 +33,8 @@ const NOTIFY_TO = "home-turf@civfix.test" async function makeHarness(over: Record = {}): Promise { const env = loadEnv({ NODE_ENV: "test", HOME_TURF_NOTIFY_TO: NOTIFY_TO, ...over }) - const container = buildContainer(env) - const app = await buildServer({ + const container = makeContainer(env) + const app = await makeServer({ env, container, homeTurfOverrides: { counters: new InMemoryCounterStore(() => 0) }, @@ -376,8 +376,8 @@ describe("claimHomeTurfConfirmation (M8)", () => { describe("home-turf abuse caps FAIL CLOSED (M8)", () => { it("refuses to send when no counter store is available (empty REDIS_URL, no override)", async () => { const env = loadEnv({ NODE_ENV: "test", HOME_TURF_NOTIFY_TO: NOTIFY_TO }) - const container = buildContainer(env) - const app = await buildServer({ env, container }) + const container = makeContainer(env) + const app = await makeServer({ env, container }) current = { app, mailer: container.mailer as FakeMailer, container } const res = await app.inject({ @@ -394,7 +394,7 @@ describe("home-turf abuse caps FAIL CLOSED (M8)", () => { const env = loadEnv({ NODE_ENV: "test", HOME_TURF_NOTIFY_TO: NOTIFY_TO }) const counted: string[] = [] const container = { - ...buildContainer(env), + ...makeContainer(env), env: { ...env, REDIS_URL: "redis://cache:6379" }, getCounterStore: () => ({ incr: (key: string) => { @@ -402,8 +402,8 @@ describe("home-turf abuse caps FAIL CLOSED (M8)", () => { return Promise.resolve(1) }, }), - } as unknown as ReturnType - const app = await buildServer({ env, container }) + } as unknown as ReturnType + const app = await makeServer({ env, container }) current = { app, mailer: container.mailer as FakeMailer, container } const res = await app.inject({ diff --git a/services/api/test/unit/host/host-routes.test.ts b/services/api/test/unit/host/host-routes.test.ts index 84226ca5..f2658858 100644 --- a/services/api/test/unit/host/host-routes.test.ts +++ b/services/api/test/unit/host/host-routes.test.ts @@ -3,13 +3,13 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { endpoints, versionedPath } from "@civfix/shared/client" -import { buildServer } from "../../../src/server.js" -import { buildContainer } from "../../../src/di.js" +import { makeServer } from "../../../src/server.js" +import { makeContainer } from "../../../src/di.js" import { loadEnv } from "../../../src/env.js" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { InMemoryCacheClient } from "../../../src/auth/cache.js" import { makeInMemoryStores } from "../../../src/auth/stores.js" -import { buildAuthServices } from "../../../src/auth/auth-services.js" +import { makeAuthServices } from "../../../src/auth/auth-services.js" import { StubJwksVerifier } from "../../helpers/auth.js" import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" import { @@ -39,7 +39,7 @@ async function makeHarness(guards: HostGuards = OPEN_HOST_GUARDS): Promise Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -52,9 +52,9 @@ async function makeHarness(guards: HostGuards = OPEN_HOST_GUARDS): Promise tokens.hashFor(seatId) repo.seedEvent({ cleanupId: EVENT }) - const container = buildContainer(env) + const container = makeContainer(env) const counters = new InMemoryCounterStore(() => Date.now()) - const app = await buildServer({ + const app = await makeServer({ env, container, authServices, diff --git a/services/api/test/unit/host/insights-invalidation-wiring.test.ts b/services/api/test/unit/host/insights-invalidation-wiring.test.ts index dbc3c97e..d876211f 100644 --- a/services/api/test/unit/host/insights-invalidation-wiring.test.ts +++ b/services/api/test/unit/host/insights-invalidation-wiring.test.ts @@ -1,7 +1,7 @@ import { TEST_TICKET_SIGNER } from "../../helpers/ticket-signer.js" import { randomUUID } from "node:crypto" import { beforeEach, describe, expect, it } from "vitest" -import { buildContainer } from "../../../src/di.js" +import { makeContainer } from "../../../src/di.js" import { loadEnv } from "../../../src/env.js" import { InMemoryCacheClient } from "../../../src/auth/cache.js" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" @@ -66,7 +66,7 @@ interface Harness { function build(): Harness { const env = loadEnv({ NODE_ENV: "test" }) - const container = buildContainer(env) + const container = makeContainer(env) const repo = new InMemoryHostRegistrationRepository() repo.tokenHashResolver = (seatId) => tokens.hashFor(seatId) repo.seedEvent({ cleanupId: EVENT, scheduledAt: STARTS_AT }) diff --git a/services/api/test/unit/host/org-team-routes.test.ts b/services/api/test/unit/host/org-team-routes.test.ts index fc866f2f..2d48e24e 100644 --- a/services/api/test/unit/host/org-team-routes.test.ts +++ b/services/api/test/unit/host/org-team-routes.test.ts @@ -4,13 +4,13 @@ import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import { AppError } from "@civfix/shared" import { NO_HOST_STANDING, can } from "@civfix/shared/host" -import { buildServer } from "../../../src/server.js" -import { buildContainer } from "../../../src/di.js" +import { makeServer } from "../../../src/server.js" +import { makeContainer } from "../../../src/di.js" import { loadEnv } from "../../../src/env.js" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { InMemoryCacheClient } from "../../../src/auth/cache.js" import { makeInMemoryStores } from "../../../src/auth/stores.js" -import { buildAuthServices } from "../../../src/auth/auth-services.js" +import { makeAuthServices } from "../../../src/auth/auth-services.js" import { StubJwksVerifier } from "../../helpers/auth.js" import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" import { fakeCleanupReader } from "../../helpers/host-team.js" @@ -43,7 +43,7 @@ async function makeHarness(): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -55,8 +55,8 @@ async function makeHarness(): Promise { const orgs = new InMemoryOrganizationRepository() const team = new InMemoryHostTeamRepository() - const container = buildContainer(env) - const app = await buildServer({ + const container = makeContainer(env) + const app = await makeServer({ env, container, authServices, diff --git a/services/api/test/unit/keyset-cursor-forged.test.ts b/services/api/test/unit/keyset-cursor-forged.test.ts index d3089794..568ad0ae 100644 --- a/services/api/test/unit/keyset-cursor-forged.test.ts +++ b/services/api/test/unit/keyset-cursor-forged.test.ts @@ -7,8 +7,8 @@ import { afterEach, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" @@ -172,10 +172,10 @@ describe("GET /v1/reports/search with a forged cursor", () => { ]) const env = loadEnv({ NODE_ENV: "test" }) const container = { - ...buildContainer(env), + ...makeContainer(env), getDb: () => ({ sql: db.sql }), } as unknown as Container - app = await buildServer({ env, container }) + app = await makeServer({ env, container }) const res = await app.inject({ method: "GET", diff --git a/services/api/test/unit/local-storage-routes.test.ts b/services/api/test/unit/local-storage-routes.test.ts index 4e878c57..168fa882 100644 --- a/services/api/test/unit/local-storage-routes.test.ts +++ b/services/api/test/unit/local-storage-routes.test.ts @@ -5,8 +5,8 @@ import { Readable } from "node:stream" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { FastifyInstance } from "fastify" import { MAX_VIDEO_BYTES } from "@civfix/shared" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { LocalDiskStorage } from "../../src/adapters/storage.local.js" import { InMemoryMediaRepository } from "../helpers/media.js" @@ -35,8 +35,8 @@ function envSource(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { async function makeHarness(): Promise { const env = loadEnv(envSource()) - const container = buildContainer(env) - const app = await buildServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) + const container = makeContainer(env) + const app = await makeServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) const harness: Harness = { app, storage: container.storage as LocalDiskStorage, @@ -92,8 +92,8 @@ describe("local-disk storage driver selection", () => { it("does not mount the local-storage routes when the driver is off", async () => { const env = loadEnv({ NODE_ENV: "test" }) - const container = buildContainer(env) - const app = await buildServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) + const container = makeContainer(env) + const app = await makeServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) current = { app, storage: container.storage as LocalDiskStorage, diff --git a/services/api/test/unit/map-routes-security.test.ts b/services/api/test/unit/map-routes-security.test.ts index 6b92de78..a7832258 100644 --- a/services/api/test/unit/map-routes-security.test.ts +++ b/services/api/test/unit/map-routes-security.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" @@ -23,10 +23,10 @@ async function boot(): Promise { { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, ]) const container = { - ...buildContainer(env), + ...makeContainer(env), getDb: () => ({ sql: db.sql }), } as unknown as Container - app = await buildServer({ env, container }) + app = await makeServer({ env, container }) return db } diff --git a/services/api/test/unit/map-routes.test.ts b/services/api/test/unit/map-routes.test.ts index e46a91ae..d8ee2856 100644 --- a/services/api/test/unit/map-routes.test.ts +++ b/services/api/test/unit/map-routes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach, vi } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import type { Container } from "../../src/di.js" import type { ReverseGeocode, ReverseResult } from "../../src/adapters/reverse-geocode.chain.js" @@ -29,7 +29,7 @@ describe("GET /map/tileinfo", () => { // optional TILES_RASTER_URL. minZoom/maxZoom/bounds remain env-tunable. it("advertises the default OpenStreetMap/CARTO Voyager raster basemap when unconfigured", async () => { // The vitest env sets no TILES_* vars, so this exercises the built-in defaults. - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/v1/map/tileinfo" }) expect(res.statusCode).toBe(200) const body = res.json() @@ -52,8 +52,8 @@ describe("GET /map/tileinfo", () => { TILES_MAX_ZOOM: "17", TILES_BOUNDS: "-130,20,-60,55", }) - const container = buildContainer(env) - app = await buildServer({ env, container }) + const container = makeContainer(env) + app = await makeServer({ env, container }) const res = await app.inject({ method: "GET", url: "/v1/map/tileinfo" }) expect(res.statusCode).toBe(200) @@ -69,7 +69,7 @@ describe("GET /map/tileinfo", () => { describe("POST /map/reverse-label", () => { it("returns the FakeGeocoder label for a point", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/reverse-label", @@ -81,7 +81,7 @@ describe("POST /map/reverse-label", () => { }) it("rejects a malformed body with the 422 validation envelope", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/reverse-label", @@ -106,12 +106,12 @@ function withStreetChain(container: Container, result: ReverseResult | null): Co describe("POST /map/resolve-address", () => { it("returns the chain's rung plus the locality hint", async () => { const env = loadEnv({ NODE_ENV: "test" }) - const container = withStreetChain(buildContainer(env), { + const container = withStreetChain(makeContainer(env), { line: "123 Imperial Hwy, Inglewood, CA", precision: "street", provider: "photon", }) - app = await buildServer({ env, container }) + app = await makeServer({ env, container }) const res = await app.inject({ method: "POST", @@ -129,8 +129,8 @@ describe("POST /map/resolve-address", () => { it("falls back to locality on a chain miss, and says so", async () => { const env = loadEnv({ NODE_ENV: "test" }) - const container = withStreetChain(buildContainer(env), null) - app = await buildServer({ env, container }) + const container = withStreetChain(makeContainer(env), null) + app = await makeServer({ env, container }) const res = await app.inject({ method: "POST", @@ -148,12 +148,12 @@ describe("POST /map/resolve-address", () => { it("returns a landmark line RAW, leaving the 'Near ' prefix to the client's locale", async () => { const env = loadEnv({ NODE_ENV: "test" }) - const container = withStreetChain(buildContainer(env), { + const container = withStreetChain(makeContainer(env), { line: "Vista Hermosa Park, Los Angeles, CA", precision: "landmark", provider: "photon", }) - app = await buildServer({ env, container }) + app = await makeServer({ env, container }) const res = await app.inject({ method: "POST", @@ -166,7 +166,7 @@ describe("POST /map/resolve-address", () => { }) it("rejects a malformed body with the 422 validation envelope", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/resolve-address", @@ -182,7 +182,7 @@ describe("POST /map/jurisdictions/:geoid/suggest-contact (validation, no DB)", ( // with no Postgres wired. The 404 (unknown geoid) and 201 (audit written) paths need the DB and are // covered by the Docker-gated integration test. it("422s when neither an email nor a form URL is provided", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/jurisdictions/0644000/suggest-contact", @@ -193,7 +193,7 @@ describe("POST /map/jurisdictions/:geoid/suggest-contact (validation, no DB)", ( }) it("422s a malformed contact email", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/jurisdictions/0644000/suggest-contact", @@ -208,7 +208,7 @@ describe("GET /map/cleanups bbox validation (P2)", () => { // The bbox ordering check runs at query-parse time, BEFORE any DB access, so an inverted bbox is a // clean 422 even with no database wired (the valid-bbox happy path needs Postgres -> integration test). it("422s an inverted bbox (west >= east) without a silent empty result", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const bbox = JSON.stringify({ west: -118.2, south: 34.0, east: -118.5, north: 34.2 }) const res = await app.inject({ method: "GET", @@ -219,7 +219,7 @@ describe("GET /map/cleanups bbox validation (P2)", () => { }) it("422s a degenerate bbox (south >= north)", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const bbox = JSON.stringify({ west: -118.5, south: 34.2, east: -118.2, north: 34.0 }) const res = await app.inject({ method: "GET", @@ -256,7 +256,7 @@ describe("POST /map/suggest", () => { it("F162: forwards the caller's language to the suggest provider", async () => { const urls: string[] = [] captureFetch(urls) - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/suggest", @@ -271,7 +271,7 @@ describe("POST /map/suggest", () => { it('keeps the provider\'s "en" default when the caller sends no language', async () => { const urls: string[] = [] captureFetch(urls) - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "POST", url: "/v1/map/suggest", diff --git a/services/api/test/unit/media-routes.test.ts b/services/api/test/unit/media-routes.test.ts index c4ef86ab..86c221f2 100644 --- a/services/api/test/unit/media-routes.test.ts +++ b/services/api/test/unit/media-routes.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { MAX_IMAGE_BYTES } from "@civfix/shared" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryMediaRepository } from "../helpers/media.js" import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" @@ -21,9 +21,9 @@ let current: Harness | undefined async function makeHarness(): Promise { const env = loadEnv({ NODE_ENV: "test" }) - const container = buildContainer(env) + const container = makeContainer(env) const repo = new InMemoryMediaRepository() - const app = await buildServer({ env, container, mediaRepo: repo }) + const app = await makeServer({ env, container, mediaRepo: repo }) const h: Harness = { app, repo, @@ -104,7 +104,7 @@ describe("media byte quota wiring", () => { const env = loadEnv({ NODE_ENV: "test" }) const charges: { subject: string; bytes: number }[] = [] const container = { - ...buildContainer(env), + ...makeContainer(env), env: { ...env, REDIS_URL: "redis://cache:6379" }, getByteMeter: () => ({ add: (subject: string, bytes: number) => { @@ -112,9 +112,9 @@ describe("media byte quota wiring", () => { return Promise.resolve(bytes) }, }), - } as unknown as ReturnType + } as unknown as ReturnType const repo = new InMemoryMediaRepository() - const app = await buildServer({ env, container, mediaRepo: repo }) + const app = await makeServer({ env, container, mediaRepo: repo }) current = { app, repo, @@ -136,7 +136,7 @@ describe("media byte quota wiring", () => { const env = loadEnv({ NODE_ENV: "test" }) const charges: { subject: string; bytes: number }[] = [] const container = { - ...buildContainer(env), + ...makeContainer(env), env: { ...env, REDIS_URL: "redis://cache:6379" }, getByteMeter: () => ({ add: (subject: string, bytes: number) => { @@ -144,9 +144,9 @@ describe("media byte quota wiring", () => { return Promise.resolve(bytes) }, }), - } as unknown as ReturnType + } as unknown as ReturnType const repo = new InMemoryMediaRepository() - const app = await buildServer({ env, container, mediaRepo: repo }) + const app = await makeServer({ env, container, mediaRepo: repo }) current = { app, repo, diff --git a/services/api/test/unit/notification-routes.test.ts b/services/api/test/unit/notification-routes.test.ts index ed15c2d9..b9da9e18 100644 --- a/services/api/test/unit/notification-routes.test.ts +++ b/services/api/test/unit/notification-routes.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import type { FakePushSender } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryNotificationRepository } from "../helpers/notifications.js" import { @@ -41,7 +41,7 @@ async function makeHarness( const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() const verifier = new StubJwksVerifier() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -54,10 +54,10 @@ async function makeHarness( if (seed) seed(repo) const notificationOverrides: NotificationServiceOverrides = { repo } - const container = buildContainer(env) + const container = makeContainer(env) const push = container.pushSender as FakePushSender - const app = await buildServer({ env, container, authServices, notificationOverrides }) + const app = await makeServer({ env, container, authServices, notificationOverrides }) const { token, userId } = await signIn(app, mailer, "notif@example.com") diff --git a/services/api/test/unit/posts-routes.test.ts b/services/api/test/unit/posts-routes.test.ts index 5c0c2332..6976aebd 100644 --- a/services/api/test/unit/posts-routes.test.ts +++ b/services/api/test/unit/posts-routes.test.ts @@ -8,7 +8,7 @@ * * SEAM: posts.routes.ts reads `container.getPostService()` and has no `app.*Overrides` hook, so the * container getter IS the injection point: the harness swaps that one method on the container it passes - * to buildServer. `sql` is a throwing stub: `resolveMentionTargets` short-circuits on an empty + * to makeServer. `sql` is a throwing stub: `resolveMentionTargets` short-circuits on an empty * `mentionedUserIds`, so every path here provably needs no database. */ @@ -17,12 +17,12 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import type { PersonDTO, PostDTO } from "@civfix/shared" -import { buildServer } from "../../src/server.js" -import { buildContainer, type Container } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer, type Container } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { Sql } from "../../src/db/client.js" import { paginate, parseTimeCursor } from "../../src/db/cursor-helpers.js" @@ -450,7 +450,7 @@ async function makeHarness(): Promise { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache: new InMemoryCacheClient(() => Date.now()), mailer, @@ -468,11 +468,11 @@ async function makeHarness(): Promise { Promise.resolve(blocked.has(`${a}:${b}`) || blocked.has(`${b}:${a}`)), }) - const container = buildContainer(env) + const container = makeContainer(env) // The only seam posts.routes.ts offers (see the file header). Object.assign(container, { getPostService: () => service } satisfies Partial) - const app = await buildServer({ env, container, authServices }) + const app = await makeServer({ env, container, authServices }) /** Run the OTP request/verify pair for `email` on the given transport. */ async function verify(email: string, name: string, client: "mobile" | "web") { diff --git a/services/api/test/unit/report-chat-membership.test.ts b/services/api/test/unit/report-chat-membership.test.ts index 3b7bc5ec..dcb93b0f 100644 --- a/services/api/test/unit/report-chat-membership.test.ts +++ b/services/api/test/unit/report-chat-membership.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, afterEach, vi } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import type { ChatMessageDTO } from "@civfix/shared" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryChatRepository, InMemoryThreadsRepository } from "../helpers/chat.js" import { InMemoryDiscussionRepository } from "../helpers/discussion.js" @@ -68,7 +68,7 @@ async function makeHarness( const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -90,7 +90,7 @@ async function makeHarness( ...(opts.isMember !== undefined ? { isMember: opts.isMember } : {}), }) - const app = await buildServer({ + const app = await makeServer({ env, authServices, chatOverrides: { diff --git a/services/api/test/unit/report-chat.test.ts b/services/api/test/unit/report-chat.test.ts index 8f8ebee3..235d43d8 100644 --- a/services/api/test/unit/report-chat.test.ts +++ b/services/api/test/unit/report-chat.test.ts @@ -25,12 +25,12 @@ import type { } from "../../src/services/admin/outbound-mail-service.js" import type { MailThreadRecord } from "../../src/services/admin/mail-repository.drizzle.js" import type { ReportJurisdictionView } from "../../src/services/discussion-types.js" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryBlocksRepository, @@ -594,7 +594,7 @@ describe("GET /reports/:id/messages visibility gate", () => { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer: new FakeMailer(), @@ -616,7 +616,7 @@ describe("GET /reports/:id/messages visibility gate", () => { reporterUserId: null, }) const blocks = new InMemoryBlocksRepository() - const built = await buildServer({ + const built = await makeServer({ env, authServices, chatOverrides: { diff --git a/services/api/test/unit/report-content-routes.test.ts b/services/api/test/unit/report-content-routes.test.ts index 69d64d59..f8694e5c 100644 --- a/services/api/test/unit/report-content-routes.test.ts +++ b/services/api/test/unit/report-content-routes.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" import type { ContentSubjectGate } from "../../src/services/content-report-subject.js" @@ -37,7 +37,7 @@ async function harness(gate?: ContentSubjectGate): Promise<{ const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -46,7 +46,7 @@ async function harness(gate?: ContentSubjectGate): Promise<{ now: () => Date.now(), }) const repo = new InMemoryModerationRepository() - const app = await buildServer({ + const app = await makeServer({ env, authServices, moderationOverrides: { repo }, diff --git a/services/api/test/unit/report-routes.test.ts b/services/api/test/unit/report-routes.test.ts index 9bd6d649..3e80a2f9 100644 --- a/services/api/test/unit/report-routes.test.ts +++ b/services/api/test/unit/report-routes.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach, vi } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryReportRepository } from "../helpers/reports.js" import { clientQuery } from "../helpers/query.js" @@ -13,7 +13,7 @@ import type { ReportServiceOverrides } from "../../src/routes/reports.routes.js" /** * Route-level tests for the report plugin, run with NO database: an in-memory ReportRepository (+ fake - * jurisdiction/presign) is injected via buildServer(opts.reportOverrides), and a full in-memory auth + * jurisdiction/presign) is injected via makeServer(opts.reportOverrides), and a full in-memory auth * bundle is injected so the [auth] routes get a real bearer session. Exercised through the real Fastify * app via app.inject. The Drizzle/PostGIS transaction path is covered by the Docker-gated integration * test instead. @@ -46,7 +46,7 @@ async function makeHarness( const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() const verifier = new StubJwksVerifier() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -74,7 +74,7 @@ async function makeHarness( ), } - const app = await buildServer({ env, authServices, reportOverrides }) + const app = await makeServer({ env, authServices, reportOverrides }) // Sign in through the real OTP flow (mobile transport -> bearer token in the body). const email = "reporter@example.com" diff --git a/services/api/test/unit/request-text-hardening.test.ts b/services/api/test/unit/request-text-hardening.test.ts index 1302486e..56a77b7a 100644 --- a/services/api/test/unit/request-text-hardening.test.ts +++ b/services/api/test/unit/request-text-hardening.test.ts @@ -13,11 +13,11 @@ import { } from "../helpers/chat.js" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryBlocksRepository, @@ -346,7 +346,7 @@ describe("PATCH /dm/:threadId/messages/:messageId over HTTP (CVX-002b)", () => { }> { const env = loadEnv({ NODE_ENV: "test" }) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), mailer, @@ -364,7 +364,7 @@ describe("PATCH /dm/:threadId/messages/:messageId over HTTP (CVX-002b)", () => { chatRepo: new InMemoryChatRepository(), blocksRepo: blocks, } - app = await buildServer({ env, authServices, chatOverrides }) + app = await makeServer({ env, authServices, chatOverrides }) await app.inject({ method: "POST", diff --git a/services/api/test/unit/route-coverage.test.ts b/services/api/test/unit/route-coverage.test.ts index 2ccd5218..9cad18a4 100644 --- a/services/api/test/unit/route-coverage.test.ts +++ b/services/api/test/unit/route-coverage.test.ts @@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto" import type { FastifyInstance, InjectOptions } from "fastify" import { FakeAbuseChecks, FakeMailer } from "@civfix/shared/fakes" import { endpoints, versionedPath, type EndpointDef } from "@civfix/shared/client" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { makeAnonService } from "../../src/services/anon-service.js" @@ -52,7 +52,7 @@ async function buildFullFakeServer(): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -128,9 +128,9 @@ async function buildFullFakeServer(): Promise { hostRepo.seedEvent({ cleanupId: PARAM_VALUE }) hostRepo.seedTicketType({ cleanupId: PARAM_VALUE, capacity: 10, maxPartySize: 4 }) - const container = buildContainer(env) + const container = makeContainer(env) - return buildServer({ + return makeServer({ env, container, authServices, diff --git a/services/api/test/unit/server-timeouts.test.ts b/services/api/test/unit/server-timeouts.test.ts index 08d67175..cc8c9394 100644 --- a/services/api/test/unit/server-timeouts.test.ts +++ b/services/api/test/unit/server-timeouts.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { makeDb } from "../../src/db/client.js" import { REQUEST_TIMEOUT_MS, SHUTDOWN_CLOSE_WAIT_MS } from "../../src/lifecycle.js" @@ -18,7 +18,7 @@ type WithConnection = { options: { connection?: Record } } describe("server timeout ordering (F029)", () => { it("keeps the socket timeout strictly above the request budget and the DB statement timeout", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const socketTimeout = app.server.timeout const requestTimeout = app.server.requestTimeout @@ -36,7 +36,7 @@ describe("server timeout ordering (F029)", () => { }) it("never force-closes connections, and bounds the shutdown close wait by the request budget", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) // Fastify's default ('idle') calls closeAllConnections() on close, destroying sockets with a // request STILL IN FLIGHT - the exact dropped request the SIGTERM drain exists to prevent. expect(app.initialConfig.forceCloseConnections).toBe(false) diff --git a/services/api/test/unit/shutdown-drain.test.ts b/services/api/test/unit/shutdown-drain.test.ts index 74b407fb..0170f385 100644 --- a/services/api/test/unit/shutdown-drain.test.ts +++ b/services/api/test/unit/shutdown-drain.test.ts @@ -18,7 +18,7 @@ import { describe, it, expect, afterEach, vi } from "vitest" import http from "node:http" import type { AddressInfo } from "node:net" import type { FastifyInstance } from "fastify" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { makeShutdown, @@ -83,7 +83,7 @@ describe("shutdown drain", () => { it("flips /healthz to 503 + the drain header, keeps serving for the drain window, then closes", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const gate = deferred() app.get("/__drain-probe", async () => { await gate.promise @@ -136,7 +136,7 @@ describe("shutdown drain", () => { it("clamps a drain window above the loader ceiling instead of trusting it", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) let closes = 0 app.addHook("onClose", async () => { closes += 1 @@ -161,7 +161,7 @@ describe("shutdown drain", () => { it("closes immediately when the drain window is 0, and is idempotent across repeated signals", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) let closes = 0 app.addHook("onClose", async () => { closes += 1 @@ -184,7 +184,7 @@ describe("shutdown drain", () => { }) it("a second signal DURING the drain neither closes early nor exits twice", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) let closes = 0 app.addHook("onClose", async () => { closes += 1 @@ -212,7 +212,7 @@ describe("shutdown drain", () => { }) it("a fresh server instance is not draining (the flag is per-instance, not module-global)", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const res = await app.inject({ method: "GET", url: "/healthz" }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ ok: true }) @@ -221,7 +221,7 @@ describe("shutdown drain", () => { }) it("exits 1 when the container teardown outlives its watchdog instead of hanging", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) await app.ready() const exits: number[] = [] @@ -239,7 +239,7 @@ describe("shutdown drain", () => { }) it("exits 1 when the container teardown rejects", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) await app.ready() const exits: number[] = [] @@ -256,7 +256,7 @@ describe("shutdown drain", () => { }) it("proceeds to teardown when the server close cannot settle, instead of waiting on it forever", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) await app.ready() const realClose = app.close.bind(app) ;(app as unknown as { close: () => Promise }).close = () => @@ -313,7 +313,7 @@ describe("shutdown drain over a real socket", () => { } it("an in-flight request completes across app.close() instead of dying with a socket error", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const entered = deferred() app.get("/__slow", async () => { entered.resolve() @@ -345,7 +345,7 @@ describe("shutdown drain over a real socket", () => { }) it("a parked keep-alive connection does not stretch the close", async () => { - app = await buildServer({ env: loadEnv() }) + app = await makeServer({ env: loadEnv() }) const port = await listen() agent = new http.Agent({ keepAlive: true }) diff --git a/services/api/test/unit/social-routes.test.ts b/services/api/test/unit/social-routes.test.ts index b923ee30..1dd4b5e0 100644 --- a/services/api/test/unit/social-routes.test.ts +++ b/services/api/test/unit/social-routes.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemorySocialRepository, makeCleanupRecord } from "../helpers/social.js" import type { SocialServiceOverrides } from "../../src/routes/social.routes.js" @@ -37,7 +37,7 @@ async function makeHarness(seed?: (repo: InMemorySocialRepository) => void): Pro const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() const verifier = new StubJwksVerifier() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -51,7 +51,7 @@ async function makeHarness(seed?: (repo: InMemorySocialRepository) => void): Pro const notifier = new SpyNotifier() const socialOverrides: SocialServiceOverrides = { repo, notifier } - const app = await buildServer({ env, authServices, socialOverrides }) + const app = await makeServer({ env, authServices, socialOverrides }) const { token, userId } = await signIn(app, mailer, "viewer@example.com") repo.seedUser({ id: userId, displayName: "Viewer", handle: "viewer" }) diff --git a/services/api/test/unit/thread-read-routes.test.ts b/services/api/test/unit/thread-read-routes.test.ts index 7eab7588..81383b0f 100644 --- a/services/api/test/unit/thread-read-routes.test.ts +++ b/services/api/test/unit/thread-read-routes.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, afterEach, vi } from "vitest" import { readFileSync } from "node:fs" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" import { @@ -73,7 +73,7 @@ async function makeHarness(participates = true): Promise { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -103,7 +103,7 @@ async function makeHarness(participates = true): Promise { participates: gate, } - const app = await buildServer({ env, authServices, chatOverrides, conversationRoutesOverrides }) + const app = await makeServer({ env, authServices, chatOverrides, conversationRoutesOverrides }) current = app const email = "reader@example.com" diff --git a/services/api/test/unit/trust-proxy.test.ts b/services/api/test/unit/trust-proxy.test.ts index 8aa744d1..15a83e40 100644 --- a/services/api/test/unit/trust-proxy.test.ts +++ b/services/api/test/unit/trust-proxy.test.ts @@ -3,7 +3,7 @@ import Fastify from "fastify" import type { FastifyInstance } from "fastify" import { parseTrustProxy, DEFAULT_TRUSTED_PROXY_CIDRS } from "../../src/plugins/trust-proxy.js" import { loadEnv } from "../../src/env.js" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { normalizeIp } from "../../src/abuse/ip-rate-limit.js" import type { AnonReportRequest } from "@civfix/shared" import type { @@ -126,7 +126,7 @@ describe("anon submit abuse key is the real client IP (not a spoofed XFF)", () = }, } - app = await buildServer({ + app = await makeServer({ env: loadEnv({ NODE_ENV: "test" }), anonOverride: { service: captureService }, }) diff --git a/services/api/test/unit/users-routes.test.ts b/services/api/test/unit/users-routes.test.ts index cff32820..a1b6fce5 100644 --- a/services/api/test/unit/users-routes.test.ts +++ b/services/api/test/unit/users-routes.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" import type { PersonDTO } from "@civfix/shared" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryBlocksRepository, @@ -49,7 +49,7 @@ async function makeHarness(): Promise { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache: new InMemoryCacheClient(() => Date.now()), mailer, @@ -67,7 +67,7 @@ async function makeHarness(): Promise { chatRepo: new InMemoryChatRepository(), blocksRepo: blocks, } - const app = await buildServer({ env, authServices, chatOverrides }) + const app = await makeServer({ env, authServices, chatOverrides }) async function verify(email: string, name: string, client: "mobile" | "web") { const requested = await app.inject({ diff --git a/services/api/test/unit/version-gate.test.ts b/services/api/test/unit/version-gate.test.ts index 6e2b7c18..4befc528 100644 --- a/services/api/test/unit/version-gate.test.ts +++ b/services/api/test/unit/version-gate.test.ts @@ -6,7 +6,7 @@ * versions and all unversioned/system paths through untouched. * * Rather than stand up the full DI bundle, this builds a MINIMAL, self-contained Fastify instance wired - * exactly like `buildServer` around the gate: the same request-id generator (so the error envelope's + * exactly like `makeServer` around the gate: the same request-id generator (so the error envelope's * `requestId` is populated) and the same canonical error handler (`makeErrorHandler`) that renders an * AppError into `{ code, message, requestId }` with the AppError's HTTP status. On top of that it * registers the gate plus four trivial probe routes: @@ -35,7 +35,7 @@ import { } from "../../src/versioning/policy.js" /** - * A minimal Fastify instance wired around the version gate the same way `buildServer` is: the gate's + * A minimal Fastify instance wired around the version gate the same way `makeServer` is: the gate's * `onRequest` hook runs ahead of route matching, the canonical error handler renders AppError → wire * envelope, and a handful of probe routes stand in for the real surface. */ @@ -45,7 +45,7 @@ async function buildGateProbeServer(): Promise { app.setErrorHandler(makeErrorHandler()) app.setNotFoundHandler(makeNotFoundHandler()) - // Registered before routes, exactly as buildServer does, so it fires at onRequest ahead of handlers. + // Registered before routes, exactly as makeServer does, so it fires at onRequest ahead of handlers. await registerVersionGate(app) // A SERVED current version: reaching this 200 proves the gate let it through. diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index e7cdb74d..e917bb17 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -11,12 +11,12 @@ import { import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" import type { InsightsInvalidator } from "../../src/services/host/host-analytics-cache.js" import type { NotificationService } from "../../src/services/notification-service.js" -import { buildServer } from "../../src/server.js" -import { buildContainer } from "../../src/di.js" +import { makeServer } from "../../src/server.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" -import { buildAuthServices } from "../../src/auth/auth-services.js" +import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" const HOST = "11111111-1111-1111-1111-111111111111" @@ -986,7 +986,7 @@ describe("volunteer hours routes: leaderboard T1/T4 tripwires", () => { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const mailer = new FakeMailer() - const authServices = buildAuthServices({ + const authServices = makeAuthServices({ stores, cache, mailer, @@ -996,9 +996,9 @@ describe("volunteer hours routes: leaderboard T1/T4 tripwires", () => { }) const repo = new InMemoryVolunteerHoursRepository() repo.seedJurisdiction(GEOID_A, "San Francisco") - const built = await buildServer({ + const built = await makeServer({ env, - container: buildContainer(env), + container: makeContainer(env), authServices, volunteerOverrides: { repo }, }) diff --git a/services/api/test/unit/web-base-url-fallback.test.ts b/services/api/test/unit/web-base-url-fallback.test.ts index 0cb7affe..1d2d2d89 100644 --- a/services/api/test/unit/web-base-url-fallback.test.ts +++ b/services/api/test/unit/web-base-url-fallback.test.ts @@ -4,9 +4,9 @@ import type { HostCapability } from "@civfix/shared" import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" -import { buildContainer } from "../../src/di.js" +import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" -import { buildServer } from "../../src/server.js" +import { makeServer } from "../../src/server.js" import type { HostStandingResolution } from "../../src/services/host/host-standing-repository.drizzle.js" import { InMemoryHostTeamRepository } from "../../src/services/host/host-team-repository.memory.js" import { @@ -101,9 +101,9 @@ describe("links mailed from a runtime with no web origin configured", () => { const mailer = new FakeMailer() const repo = new InMemoryGuestRsvpRepository() repo.seedEvent({ id: EVENT, title: "Beach cleanup" }) - const app = await buildServer({ + const app = await makeServer({ env, - container: buildContainer(env), + container: makeContainer(env), guestRsvpOverrides: { repo, requireGuestContact: () => Promise.resolve(), From 010a91ffecff237791097c5cfa25ea16f1b3ffbc Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:42:02 +0000 Subject: [PATCH 11/20] worker-facing persistence files and types named -repository --- README.md | 2 +- docs/out-of-band-indexes.md | 2 +- scripts/check-dynamic-sql.mjs | 2 +- services/api/package.json | 4 +-- .../src/db/schema/media_reap_tombstones.ts | 2 +- ...> anon-hold-release-repository.drizzle.ts} | 8 +++-- .../api/src/services/anon-hold-release.ts | 4 +-- .../src/services/anon-repository.drizzle.ts | 2 +- ....ts => media-worker-repository.drizzle.ts} | 4 +-- services/api/test/helpers/anon.ts | 4 +-- ...est.ts => chat-threads-repository.test.ts} | 0 .../integration/anon-hold-release-pg.test.ts | 8 ++--- .../anon-hold-release-sweep-pg.test.ts | 4 +-- services/api/test/integration/anon-pg.test.ts | 6 ++-- .../api/test/integration/media-pg.test.ts | 2 +- .../integration/media-upload-etag-pg.test.ts | 4 +-- .../held-media-moderation-reporter.test.ts | 8 ++--- .../unit/media-binding-drop-guard.test.ts | 2 +- .../test/unit/media-orphan-bindings.test.ts | 2 +- .../test/unit/media-public-served-key.test.ts | 3 +- .../api/test/unit/media-upload-etag.test.ts | 4 +-- .../src/jobs/hold-release-sweep.ts | 7 +++-- .../media-worker/src/jobs/media-checks.ts | 8 +++-- .../media-worker/src/jobs/orphan-sweep.ts | 6 ++-- .../media-worker/src/jobs/reject-cleanup.ts | 4 +-- services/media-worker/src/jobs/stuck-sweep.ts | 4 +-- services/media-worker/src/jobs/upload-reap.ts | 4 +-- services/media-worker/src/seams.ts | 17 ++++++----- ...s => in-memory-media-worker-repository.ts} | 4 +-- .../test/integration/media-checks-pg.test.ts | 10 +++---- .../served-key-and-orphan-pg.test.ts | 11 ++++--- .../test/unit/anon-hold-release.test.ts | 22 +++++++------- .../test/unit/maintenance.test.ts | 30 +++++++++---------- .../test/unit/media-checks.test.ts | 20 ++++++++----- .../test/unit/orphan-race.test.ts | 16 +++++----- .../unit/scratch-infra-classification.test.ts | 5 ++-- .../media-worker/test/unit/served-key.test.ts | 8 ++--- .../unit/spawn-failure-classification.test.ts | 7 +++-- .../test/unit/stuck-sweep-etag.test.ts | 10 +++---- .../test/unit/upload-reap.test.ts | 6 ++-- .../test/unit/video-policy.test.ts | 8 ++--- .../media-worker/test/unit/worker.test.ts | 12 ++++---- 42 files changed, 160 insertions(+), 136 deletions(-) rename services/api/src/services/{anon-hold-release-repo.drizzle.ts => anon-hold-release-repository.drizzle.ts} (95%) rename services/api/src/services/{media-worker-repo.ts => media-worker-repository.drizzle.ts} (99%) rename services/api/test/helpers/{chat-threads-repo.test.ts => chat-threads-repository.test.ts} (100%) rename services/media-worker/test/helpers/{in-memory-repo.ts => in-memory-media-worker-repository.ts} (98%) diff --git a/README.md b/README.md index c5ed0c51..64eebfee 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ postgres-js client, the R2 adapter, or the GlitchTip reporter. It depends on `@c `services/api/package.json`: - `@civfix/api/db` - the schema barrel + `makeDb` + `Db`/`Sql` types. -- `@civfix/api/media-repo` - the richer media-worker persistence seam (`MediaWorkerRepo`: +- `@civfix/api/media-repo` - the richer media-worker persistence seam (`MediaWorkerRepository`: find / applyResult / insertAbuseFlag / findOrphans / deleteById), its Drizzle impl, and `ensureNextMonthChatPartition` (so partition bounds/naming have one source shared with the migrations). diff --git a/docs/out-of-band-indexes.md b/docs/out-of-band-indexes.md index a32bafaa..818b477e 100644 --- a/docs/out-of-band-indexes.md +++ b/docs/out-of-band-indexes.md @@ -172,7 +172,7 @@ through the exported `explainFeedCandidates` and requires ### `media_assets_orphan_sweep_idx` (migration 0098, audit H13) Back the hourly orphan sweep's candidate scan (`findOrphans` /`deleteOrphan` in -`services/api/src/services/media-worker-repo.ts`), which looks for media rows +`services/api/src/services/media-worker-repository.drizzle.ts`), which looks for media rows with no binding older than the orphan TTL. Without it every run sequentially scans `media_assets`. diff --git a/scripts/check-dynamic-sql.mjs b/scripts/check-dynamic-sql.mjs index c0fca972..0df231d0 100644 --- a/scripts/check-dynamic-sql.mjs +++ b/scripts/check-dynamic-sql.mjs @@ -9,7 +9,7 @@ const ALLOW_DYNAMIC_SQL = new Set([ "services/api/src/db/migrate.ts:unsafe", "services/api/src/db/backfill-keyset.ts:unsafe", "services/api/src/db/sql/jurisdiction.ts:unsafe", - "services/api/src/services/media-worker-repo.ts:unsafe", + "services/api/src/services/media-worker-repository.drizzle.ts:unsafe", ]) const NOT_SQL = new Set(["services/media-worker/src/sandbox/phash.ts:raw"]) diff --git a/services/api/package.json b/services/api/package.json index 4c827f8a..a5ae4558 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -10,12 +10,12 @@ ".": "./dist/server.js", "./server": "./dist/server.js", "./db": "./src/db/worker-db.ts", - "./media-repo": "./src/services/media-worker-repo.ts", + "./media-repo": "./src/services/media-worker-repository.drizzle.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", + "./anon-hold-repo": "./src/services/anon-hold-release-repository.drizzle.ts", "./adapters/storage": "./src/adapters/storage.r2.ts", "./adapters/storage-local": "./src/adapters/storage.local.ts", "./adapters/abuse-checks": "./src/adapters/abuse-checks.ts", diff --git a/services/api/src/db/schema/media_reap_tombstones.ts b/services/api/src/db/schema/media_reap_tombstones.ts index ec55895a..fe6ef51a 100644 --- a/services/api/src/db/schema/media_reap_tombstones.ts +++ b/services/api/src/db/schema/media_reap_tombstones.ts @@ -1,7 +1,7 @@ /** * Storage keys whose physical delete failed after the orphan sweep had already deleted their * media_assets row. The sweep deletes the row before the objects on purpose (a TOCTOU fix, see - * MediaWorkerRepo.findOrphans), so without a tombstone a failed delete would strand the object with + * MediaWorkerRepository.findOrphans), so without a tombstone a failed delete would strand the object with * nothing left to rediscover it. `attempts` caps the retries, and a row at the cap is deliberately kept * as the operator-visible record of a permanent leak. */ diff --git a/services/api/src/services/anon-hold-release-repo.drizzle.ts b/services/api/src/services/anon-hold-release-repository.drizzle.ts similarity index 95% rename from services/api/src/services/anon-hold-release-repo.drizzle.ts rename to services/api/src/services/anon-hold-release-repository.drizzle.ts index f5efc9c1..05fa3f30 100644 --- a/services/api/src/services/anon-hold-release-repo.drizzle.ts +++ b/services/api/src/services/anon-hold-release-repository.drizzle.ts @@ -1,5 +1,9 @@ import type { Queryable, Sql } from "../db/client.js" -import type { AnonHoldReleaseRepo, HeldReportView, ReleaseMediaView } from "./anon-hold-release.js" +import type { + AnonHoldReleaseRepository, + HeldReportView, + ReleaseMediaView, +} from "./anon-hold-release.js" const RELEASE_TIMELINE_NOTE = "Released after automated review" @@ -24,7 +28,7 @@ async function countOpenFlags( return rows[0]?.n ?? 0 } -export function makeDrizzleAnonHoldReleaseRepo(sql: Sql): AnonHoldReleaseRepo { +export function makeDrizzleAnonHoldReleaseRepository(sql: Sql): AnonHoldReleaseRepository { return { async findReport(reportId: string): Promise { const rows = await sql< diff --git a/services/api/src/services/anon-hold-release.ts b/services/api/src/services/anon-hold-release.ts index 7e0a2d25..4e0ec2d4 100644 --- a/services/api/src/services/anon-hold-release.ts +++ b/services/api/src/services/anon-hold-release.ts @@ -18,7 +18,7 @@ export interface ReleaseMediaView { exifGeo?: LatLng | null } -export interface AnonHoldReleaseRepo { +export interface AnonHoldReleaseRepository { findReport(reportId: string): Promise findMedia(reportId: string): Promise countOpenAbuseFlags(reportId: string, mediaIds: string[]): Promise @@ -27,7 +27,7 @@ export interface AnonHoldReleaseRepo { } export interface AnonHoldReleaseDeps { - repo: AnonHoldReleaseRepo + repo: AnonHoldReleaseRepository abuseChecks: AbuseChecks now?: () => Date log?: (line: string, extra?: Record) => void diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 089dc316..61559a50 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -1,6 +1,6 @@ /** * Postgres implementations of the anonymous-reporting seams: AnonReportRepository (held create, status, - * the anon_tokens store), AnonHoldReleaseRepo (the worker's release gate) and ClaimRepository + * the anon_tokens store), AnonHoldReleaseRepository (the worker's release gate) and ClaimRepository * (claim-by-code linking and the nudge lookup). * * Raw postgres-js rather than the Drizzle query builder, because every report row carries PostGIS geometry diff --git a/services/api/src/services/media-worker-repo.ts b/services/api/src/services/media-worker-repository.drizzle.ts similarity index 99% rename from services/api/src/services/media-worker-repo.ts rename to services/api/src/services/media-worker-repository.drizzle.ts index 6af47259..91f38ae7 100644 --- a/services/api/src/services/media-worker-repo.ts +++ b/services/api/src/services/media-worker-repository.drizzle.ts @@ -81,7 +81,7 @@ export interface LegacyServedKeyAdoption { remaining: number } -export interface MediaWorkerRepo { +export interface MediaWorkerRepository { findById(id: string): Promise findByUploadId(uploadId: string): Promise applyResult(id: string, patch: MediaResultPatch): Promise @@ -127,7 +127,7 @@ function toAsset(row: typeof mediaAssets.$inferSelect): MediaWorkerAsset { } } -export function makeDrizzleMediaWorkerRepo(db: Db, tag: Sql): MediaWorkerRepo { +export function makeDrizzleMediaWorkerRepository(db: Db, tag: Sql): MediaWorkerRepository { return { async findById(id: string): Promise { const rows = await db.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1) diff --git a/services/api/test/helpers/anon.ts b/services/api/test/helpers/anon.ts index e4f8801e..5528ba8f 100644 --- a/services/api/test/helpers/anon.ts +++ b/services/api/test/helpers/anon.ts @@ -29,7 +29,7 @@ import type { CreateAnonReportTxResult, } from "../../src/services/anon-service.js" import type { - AnonHoldReleaseRepo, + AnonHoldReleaseRepository, HeldReportView, ReleaseMediaView, } from "../../src/services/anon-hold-release.js" @@ -295,7 +295,7 @@ export class InMemoryAnonStore { } } - holdReleaseRepo(): AnonHoldReleaseRepo { + holdReleaseRepo(): AnonHoldReleaseRepository { // Arrow-function properties so `this` is the store instance (no this-aliasing). return { findReport: (reportId: string): Promise => { diff --git a/services/api/test/helpers/chat-threads-repo.test.ts b/services/api/test/helpers/chat-threads-repository.test.ts similarity index 100% rename from services/api/test/helpers/chat-threads-repo.test.ts rename to services/api/test/helpers/chat-threads-repository.test.ts diff --git a/services/api/test/integration/anon-hold-release-pg.test.ts b/services/api/test/integration/anon-hold-release-pg.test.ts index 8c8eabd0..19aaa294 100644 --- a/services/api/test/integration/anon-hold-release-pg.test.ts +++ b/services/api/test/integration/anon-hold-release-pg.test.ts @@ -14,8 +14,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" -import { makeDrizzleAnonHoldReleaseRepo } from "../../src/services/anon-hold-release-repo.drizzle.js" -import type { AnonHoldReleaseRepo } from "../../src/services/anon-hold-release.js" +import { makeDrizzleAnonHoldReleaseRepository } from "../../src/services/anon-hold-release-repository.drizzle.js" +import type { AnonHoldReleaseRepository } from "../../src/services/anon-hold-release.js" const pg = await withPg() @@ -23,11 +23,11 @@ const PUBLISHED_AT = new Date("2026-08-01T00:00:00.000Z") describe.skipIf(!pg)("F132: releasing a held anon report closes its moderation item", () => { let h: PgHarness - let repo: AnonHoldReleaseRepo + let repo: AnonHoldReleaseRepository beforeAll(() => { h = pg as PgHarness - repo = makeDrizzleAnonHoldReleaseRepo(h.sql) + repo = makeDrizzleAnonHoldReleaseRepository(h.sql) }) beforeEach(async () => { diff --git a/services/api/test/integration/anon-hold-release-sweep-pg.test.ts b/services/api/test/integration/anon-hold-release-sweep-pg.test.ts index 5b8715d8..00c2d1f2 100644 --- a/services/api/test/integration/anon-hold-release-sweep-pg.test.ts +++ b/services/api/test/integration/anon-hold-release-sweep-pg.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" -import { makeDrizzleAnonHoldReleaseRepo } from "../../src/services/anon-hold-release-repo.drizzle.js" +import { makeDrizzleAnonHoldReleaseRepository } from "../../src/services/anon-hold-release-repository.drizzle.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() @@ -38,7 +38,7 @@ describe.skipIf(!pg)("anon hold-release candidates (integration)", () => { const stuckB = await seedHeld(170, "stuckB") const fresh = await seedHeld(5, "fresh") - const repo = makeDrizzleAnonHoldReleaseRepo(h.sql) + const repo = makeDrizzleAnonHoldReleaseRepository(h.sql) const first = await repo.findHeldAnonReportIds(2) expect([...first].sort()).toEqual([stuckA, stuckB].sort()) diff --git a/services/api/test/integration/anon-pg.test.ts b/services/api/test/integration/anon-pg.test.ts index acdf64e3..45acb177 100644 --- a/services/api/test/integration/anon-pg.test.ts +++ b/services/api/test/integration/anon-pg.test.ts @@ -31,7 +31,7 @@ import { makeDrizzleAnonReportRepository, makeDrizzleClaimRepository, } from "../../src/services/anon-repository.drizzle.js" -import { makeDrizzleAnonHoldReleaseRepo } from "../../src/services/anon-hold-release-repo.drizzle.js" +import { makeDrizzleAnonHoldReleaseRepository } from "../../src/services/anon-hold-release-repository.drizzle.js" import { releaseAnonHoldIfReady } from "../../src/services/anon-hold-release.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" import { makeReportService, type ReportService } from "../../src/services/report-service.js" @@ -204,7 +204,7 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = const { response } = await anon.submitAnonReport(req(), { ip: "203.0.113.6", cfGeo: {} }) await attachMedia(response.reportId, "ready") - const holdRepo = makeDrizzleAnonHoldReleaseRepo(h.sql) + const holdRepo = makeDrizzleAnonHoldReleaseRepository(h.sql) const result = await releaseAnonHoldIfReady(response.reportId, { repo: holdRepo, abuseChecks: new FakeAbuseChecks(), @@ -249,7 +249,7 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = const { response } = await anon.submitAnonReport(req(), { ip: "203.0.113.7", cfGeo: {} }) await attachMedia(response.reportId, "rejected") const result = await releaseAnonHoldIfReady(response.reportId, { - repo: makeDrizzleAnonHoldReleaseRepo(h.sql), + repo: makeDrizzleAnonHoldReleaseRepository(h.sql), abuseChecks: new FakeAbuseChecks(), }) expect(result.outcome).toBe("media_blocked") diff --git a/services/api/test/integration/media-pg.test.ts b/services/api/test/integration/media-pg.test.ts index 91154d5a..707efd9d 100644 --- a/services/api/test/integration/media-pg.test.ts +++ b/services/api/test/integration/media-pg.test.ts @@ -52,7 +52,7 @@ describe.skipIf(!pg)("media routes (integration)", () => { // Intake keys the object by the server-minted uploadId (uploads/YYYY/MM/), NOT the // client-claimed sha256: trusting the client's hash as the physical path would let a caller // collide/overwrite another object. The worker later promotes verified bytes to the content- - // addressed uploads/YYYY/MM/ key for dedup (see media-worker-repo). + // addressed uploads/YYYY/MM/ key for dedup (see media-worker-repository.drizzle.ts). expect(rows[0]!.r2_key).toMatch(new RegExp(`^uploads/\\d{4}/\\d{2}/${uploadId}$`)) expect(Number(rows[0]!.byte_size)).toBe(1024) }) diff --git a/services/api/test/integration/media-upload-etag-pg.test.ts b/services/api/test/integration/media-upload-etag-pg.test.ts index bcacaa71..eb1064c0 100644 --- a/services/api/test/integration/media-upload-etag-pg.test.ts +++ b/services/api/test/integration/media-upload-etag-pg.test.ts @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleMediaRepository } from "../../src/services/media-repository.drizzle.js" -import { makeDrizzleMediaWorkerRepo } from "../../src/services/media-worker-repo.js" +import { makeDrizzleMediaWorkerRepository } from "../../src/services/media-worker-repository.drizzle.js" const pg = await withPg() @@ -83,7 +83,7 @@ describe.skipIf(!pg)("media_assets.upload_etag (integration)", () => { WHERE id IN (${withEtag.id}, ${legacy.id}) ` - const worker = makeDrizzleMediaWorkerRepo(h.db, h.sql) + const worker = makeDrizzleMediaWorkerRepository(h.db, h.sql) const picked = await worker.findStuckValidating(new Date(Date.now() - HOUR_MS), 100) const byId = new Map(picked.map((row) => [row.id, row])) diff --git a/services/api/test/unit/held-media-moderation-reporter.test.ts b/services/api/test/unit/held-media-moderation-reporter.test.ts index c061d6a9..c30c9631 100644 --- a/services/api/test/unit/held-media-moderation-reporter.test.ts +++ b/services/api/test/unit/held-media-moderation-reporter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { drizzle } from "drizzle-orm/postgres-js" import type { Db, Sql } from "../../src/db/client.js" -import { makeDrizzleMediaWorkerRepo } from "../../src/services/media-worker-repo.js" +import { makeDrizzleMediaWorkerRepository } from "../../src/services/media-worker-repository.drizzle.js" const REPORT_ID = "22222222-2222-2222-2222-222222222222" const REPORTER_ID = "33333333-3333-3333-3333-333333333333" @@ -35,7 +35,7 @@ function insertedMeta(calls: Call[]): Record { describe("held-media moderation item names its reporter", () => { it("shows a signed-in reporter's display name and links their account", async () => { const { db, calls } = stubDb(["trash", "Pile of bags", "Los Angeles", "Dana R", REPORTER_ID]) - const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + const repo = makeDrizzleMediaWorkerRepository(db, {} as Sql) await repo.enqueueHeldModerationItem!({ reportId: REPORT_ID, @@ -47,7 +47,7 @@ describe("held-media moderation item names its reporter", () => { it("keeps 'Anonymous' for a report with no reporter account", async () => { const { db, calls } = stubDb(["trash", null, null, null, null]) - const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + const repo = makeDrizzleMediaWorkerRepository(db, {} as Sql) await repo.enqueueHeldModerationItem!({ reportId: REPORT_ID, @@ -70,7 +70,7 @@ describe("held media folding into an open moderation item", () => { }, } const db = drizzle(client as never) as unknown as Db - const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + const repo = makeDrizzleMediaWorkerRepository(db, {} as Sql) await repo.enqueueHeldModerationItem!({ reportId: REPORT_ID, diff --git a/services/api/test/unit/media-binding-drop-guard.test.ts b/services/api/test/unit/media-binding-drop-guard.test.ts index c005f143..24cde599 100644 --- a/services/api/test/unit/media-binding-drop-guard.test.ts +++ b/services/api/test/unit/media-binding-drop-guard.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest" const HERE = dirname(fileURLToPath(import.meta.url)) const DRIZZLE_DIR = join(HERE, "..", "..", "drizzle") -const WORKER_REPO = join(HERE, "..", "..", "src", "services", "media-worker-repo.ts") +const WORKER_REPO = join(HERE, "..", "..", "src", "services", "media-worker-repository.drizzle.ts") function orphanBindingColumns(): string[] { const src = readFileSync(WORKER_REPO, "utf8") diff --git a/services/api/test/unit/media-orphan-bindings.test.ts b/services/api/test/unit/media-orphan-bindings.test.ts index 0848689d..ddee3049 100644 --- a/services/api/test/unit/media-orphan-bindings.test.ts +++ b/services/api/test/unit/media-orphan-bindings.test.ts @@ -5,7 +5,7 @@ import { mediaBoundElsewhere, mediaBoundToCleanup, } from "../../src/services/media-bindings.js" -import { orphanPredicate } from "../../src/services/media-worker-repo.js" +import { orphanPredicate } from "../../src/services/media-worker-repository.drizzle.js" const sql = postgres("postgres://user:pass@127.0.0.1:1/unused", { max: 1 }) diff --git a/services/api/test/unit/media-public-served-key.test.ts b/services/api/test/unit/media-public-served-key.test.ts index 7307651b..21786d0e 100644 --- a/services/api/test/unit/media-public-served-key.test.ts +++ b/services/api/test/unit/media-public-served-key.test.ts @@ -26,7 +26,8 @@ const RAW_UPLOAD_KEY_READERS: Record = { "services/media-served-key.ts": "defines the uploader, moderation and public key expressions", "services/media-claim-repository.drizzle.ts": "the avatar claim answers only the claimant, and binds the served copy", - "services/media-worker-repo.ts": "the media worker reads the original to re-encode it", + "services/media-worker-repository.drizzle.ts": + "the media worker reads the original to re-encode it", "services/certificate-repository.drizzle.ts": "service_hours_certificates.r2_key is a generated certificate, not an upload", "services/report-sql.ts": diff --git a/services/api/test/unit/media-upload-etag.test.ts b/services/api/test/unit/media-upload-etag.test.ts index efcc6dcf..0c635cda 100644 --- a/services/api/test/unit/media-upload-etag.test.ts +++ b/services/api/test/unit/media-upload-etag.test.ts @@ -4,7 +4,7 @@ import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" import type { Db, Sql } from "../../src/db/client.js" import { makeMediaIntakeService } from "../../src/services/media-intake-service.js" import { makeDrizzleMediaRepository } from "../../src/services/media-repository.drizzle.js" -import { makeDrizzleMediaWorkerRepo } from "../../src/services/media-worker-repo.js" +import { makeDrizzleMediaWorkerRepository } from "../../src/services/media-worker-repository.drizzle.js" import { InMemoryMediaRepository } from "../helpers/media.js" const SHA = "b".repeat(64) @@ -94,7 +94,7 @@ describe("the stuck sweep reads the stored upload etag", () => { ? [[MEDIA_ID]] : [[MEDIA_ID, UPLOAD_ID, "uploads/2026/09/x", null, null, "image", 2, NORMALIZED_ETAG]], ) - const repo = makeDrizzleMediaWorkerRepo(db, {} as Sql) + const repo = makeDrizzleMediaWorkerRepository(db, {} as Sql) const rows = await repo.findStuckValidating(new Date(), 10) diff --git a/services/media-worker/src/jobs/hold-release-sweep.ts b/services/media-worker/src/jobs/hold-release-sweep.ts index 58d907f4..653c6b30 100644 --- a/services/media-worker/src/jobs/hold-release-sweep.ts +++ b/services/media-worker/src/jobs/hold-release-sweep.ts @@ -21,12 +21,15 @@ */ import type { AbuseChecks } from "@civfix/shared/interfaces" -import { releaseAnonHoldIfReady, type AnonHoldReleaseRepo } from "@civfix/api/anon-hold-release" +import { + releaseAnonHoldIfReady, + type AnonHoldReleaseRepository, +} from "@civfix/api/anon-hold-release" import { ANON_HOLD_RELEASE_SWEEP_JOB } from "@civfix/api/queue-names" import { resolveJobObs, type JobObsDeps } from "./obs.js" export interface HoldReleaseSweepDeps extends JobObsDeps { - repo: AnonHoldReleaseRepo + repo: AnonHoldReleaseRepository abuseChecks: AbuseChecks batchSize: number } diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index 986c7174..647a817b 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -1,6 +1,10 @@ import type { MediaKind, MediaStatus } from "@civfix/shared" import type { AbuseChecks, Storage, StorageHead } from "@civfix/shared/interfaces" -import type { MediaResultPatch, MediaWorkerAsset, MediaWorkerRepo } from "@civfix/api/media-repo" +import type { + MediaResultPatch, + MediaWorkerAsset, + MediaWorkerRepository, +} from "@civfix/api/media-repo" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" import { settleWithin } from "@civfix/api/timeout" import type { WorkerLimits } from "../config.js" @@ -50,7 +54,7 @@ function withJobTimeout(p: Promise, ms: number, onTimeout?: () => void): P } export interface MediaChecksDeps extends JobObsDeps { - repo: MediaWorkerRepo + repo: MediaWorkerRepository storage: Storage abuseChecks: AbuseChecks limits: WorkerLimits diff --git a/services/media-worker/src/jobs/orphan-sweep.ts b/services/media-worker/src/jobs/orphan-sweep.ts index cbe2f579..681aa4cf 100644 --- a/services/media-worker/src/jobs/orphan-sweep.ts +++ b/services/media-worker/src/jobs/orphan-sweep.ts @@ -1,5 +1,5 @@ import type { Storage } from "@civfix/shared/interfaces" -import type { LeakedObjectRow, MediaWorkerRepo, OrphanRow } from "@civfix/api/media-repo" +import type { LeakedObjectRow, MediaWorkerRepository, OrphanRow } from "@civfix/api/media-repo" import type { WorkerLimits } from "../config.js" import { drainPages } from "./drain.js" import { resolveJobObs, type JobObsDeps, type JobLogFn, type JobReportFn } from "./obs.js" @@ -17,7 +17,7 @@ export const LEAK_RETRY_MAX_ATTEMPTS = 5 const LEAK_RETRY_LIMIT = 200 export interface OrphanSweepDeps extends JobObsDeps { - repo: MediaWorkerRepo + repo: MediaWorkerRepository storage: Storage limits: WorkerLimits } @@ -156,7 +156,7 @@ async function retryTombstonedLeaks( async function recordLeakRetryFailure( row: LeakedObjectRow, err: unknown, - repo: MediaWorkerRepo, + repo: MediaWorkerRepository, log: JobLogFn, report: JobReportFn, ): Promise { diff --git a/services/media-worker/src/jobs/reject-cleanup.ts b/services/media-worker/src/jobs/reject-cleanup.ts index 09af513f..4e5a28e0 100644 --- a/services/media-worker/src/jobs/reject-cleanup.ts +++ b/services/media-worker/src/jobs/reject-cleanup.ts @@ -1,5 +1,5 @@ import type { Storage } from "@civfix/shared/interfaces" -import type { MediaWorkerRepo } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-repo" import { MEDIA_CHECKS_JOB } from "@civfix/api/queue-names" import type { JobLogFn, JobReportFn } from "./obs.js" import { servedKey, thumbnailKey } from "./media-keys.js" @@ -12,7 +12,7 @@ export interface RejectedAsset { } export interface RejectCleanupDeps { - repo: Pick + repo: Pick storage: Pick } diff --git a/services/media-worker/src/jobs/stuck-sweep.ts b/services/media-worker/src/jobs/stuck-sweep.ts index 10c84991..2d1e7edb 100644 --- a/services/media-worker/src/jobs/stuck-sweep.ts +++ b/services/media-worker/src/jobs/stuck-sweep.ts @@ -1,5 +1,5 @@ import type { Jobs, Storage } from "@civfix/shared/interfaces" -import type { MediaChecksJob, MediaWorkerRepo, StuckMediaRow } from "@civfix/api/media-repo" +import type { MediaChecksJob, MediaWorkerRepository, StuckMediaRow } from "@civfix/api/media-repo" import { MEDIA_CHECKS_JOB, MEDIA_STUCK_SWEEP_JOB, @@ -11,7 +11,7 @@ import { deleteRejectedObjects } from "./reject-cleanup.js" import { uploadReapDelaySec } from "./upload-reap.js" export interface StuckSweepDeps extends JobObsDeps { - repo: MediaWorkerRepo + repo: MediaWorkerRepository jobs: Pick storage: Storage limits: WorkerLimits diff --git a/services/media-worker/src/jobs/upload-reap.ts b/services/media-worker/src/jobs/upload-reap.ts index 6eaa1250..07a0a5be 100644 --- a/services/media-worker/src/jobs/upload-reap.ts +++ b/services/media-worker/src/jobs/upload-reap.ts @@ -1,5 +1,5 @@ import type { Storage } from "@civfix/shared/interfaces" -import type { MediaWorkerRepo } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-repo" import { R2_PUT_TTL_SEC } from "@civfix/api/adapters/storage" import { MEDIA_UPLOAD_REAP_JOB } from "@civfix/api/queue-names" import { resolveJobObs, type JobObsDeps } from "./obs.js" @@ -17,7 +17,7 @@ export interface UploadReapPayload { } export interface UploadReapDeps extends JobObsDeps { - repo: MediaWorkerRepo + repo: MediaWorkerRepository storage: Storage } diff --git a/services/media-worker/src/seams.ts b/services/media-worker/src/seams.ts index 3ebdeeb7..ca96dd6a 100644 --- a/services/media-worker/src/seams.ts +++ b/services/media-worker/src/seams.ts @@ -2,9 +2,12 @@ import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import type { AbuseChecks, Storage } from "@civfix/shared/interfaces" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" import { makeDb, type DbHandle } from "@civfix/api/db" -import { makeDrizzleMediaWorkerRepo, type MediaWorkerRepo } from "@civfix/api/media-repo" -import { makeDrizzleAnonHoldReleaseRepo } from "@civfix/api/anon-hold-repo" -import type { AnonHoldReleaseRepo } from "@civfix/api/anon-hold-release" +import { + makeDrizzleMediaWorkerRepository, + type MediaWorkerRepository, +} from "@civfix/api/media-repo" +import { makeDrizzleAnonHoldReleaseRepository } from "@civfix/api/anon-hold-repo" +import type { AnonHoldReleaseRepository } from "@civfix/api/anon-hold-release" import { R2Storage } from "@civfix/api/adapters/storage" import { LOCAL_STORAGE_DEV_SIGNING_KEY, LocalDiskStorage } from "@civfix/api/adapters/storage-local" import { captureError, initErrorReporting, flushErrorReporting } from "@civfix/api/errors" @@ -21,8 +24,8 @@ export interface WorkerSeams { limits: WorkerLimits download: DownloadFn dbHandle: DbHandle | undefined - repo: MediaWorkerRepo | undefined - anonHoldRepo: AnonHoldReleaseRepo | undefined + repo: MediaWorkerRepository | undefined + anonHoldRepo: AnonHoldReleaseRepository | undefined findPhashDuplicate: FindPhashDuplicateFn | undefined report: (err: unknown, context?: Record) => void close(): Promise @@ -146,8 +149,8 @@ function makeDbSeams( const dbHandle = makeDb(databaseUrl) return { dbHandle, - repo: makeDrizzleMediaWorkerRepo(dbHandle.db, dbHandle.sql), - anonHoldRepo: makeDrizzleAnonHoldReleaseRepo(dbHandle.sql), + repo: makeDrizzleMediaWorkerRepository(dbHandle.db, dbHandle.sql), + anonHoldRepo: makeDrizzleAnonHoldReleaseRepository(dbHandle.sql), } } if (source.NODE_ENV === "production") { diff --git a/services/media-worker/test/helpers/in-memory-repo.ts b/services/media-worker/test/helpers/in-memory-media-worker-repository.ts similarity index 98% rename from services/media-worker/test/helpers/in-memory-repo.ts rename to services/media-worker/test/helpers/in-memory-media-worker-repository.ts index e90001c5..4beb5268 100644 --- a/services/media-worker/test/helpers/in-memory-repo.ts +++ b/services/media-worker/test/helpers/in-memory-media-worker-repository.ts @@ -3,7 +3,7 @@ import type { LegacyServedKeyAdoption, MediaResultPatch, MediaWorkerAsset, - MediaWorkerRepo, + MediaWorkerRepository, NewAbuseFlag, OrphanRow, StuckMediaRow, @@ -33,7 +33,7 @@ export interface RecordedModerationEnqueue { note?: string | null } -export class InMemoryWorkerRepo implements MediaWorkerRepo { +export class InMemoryMediaWorkerRepository implements MediaWorkerRepository { readonly byId = new Map() readonly flags: RecordedFlag[] = [] readonly moderationEnqueues: RecordedModerationEnqueue[] = [] diff --git a/services/media-worker/test/integration/media-checks-pg.test.ts b/services/media-worker/test/integration/media-checks-pg.test.ts index 6d7b0fbe..e3e64afb 100644 --- a/services/media-worker/test/integration/media-checks-pg.test.ts +++ b/services/media-worker/test/integration/media-checks-pg.test.ts @@ -2,7 +2,7 @@ * Worker integration test (Docker-gated; SKIPS without Docker). * * Runs the FULL media.checks job against a live PostGIS database using the production Drizzle worker - * repo (makeDrizzleMediaWorkerRepo) + FakeStorage + FakeAbuseChecks. Proves the real DB writes: + * repo (makeDrizzleMediaWorkerRepository) + FakeStorage + FakeAbuseChecks. Proves the real DB writes: * - a valid image -> status ready, width/height/phash/thumb_key persisted. * - a crafted bad image -> status rejected (safe failure end-to-end through Postgres). * Also exercises the orphan sweep and partition maintenance against real SQL. @@ -11,9 +11,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import { - makeDrizzleMediaWorkerRepo, + makeDrizzleMediaWorkerRepository, ensureNextMonthChatPartition, - type MediaWorkerRepo, + type MediaWorkerRepository, } from "@civfix/api/media-repo" import { loadLimits } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" @@ -28,12 +28,12 @@ const limits = loadLimits({}) describe.skipIf(!pg)("worker media.checks (integration)", () => { let h: WorkerPgHarness - let repo: MediaWorkerRepo + let repo: MediaWorkerRepository let storage: FakeStorage beforeAll(() => { h = pg as WorkerPgHarness - repo = makeDrizzleMediaWorkerRepo(h.db, h.sql) + repo = makeDrizzleMediaWorkerRepository(h.db, h.sql) storage = new FakeStorage() }) diff --git a/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts b/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts index 21cc7e9c..ca8845df 100644 --- a/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts +++ b/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts @@ -1,7 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { FakeStorage } from "@civfix/shared/fakes" -import { makeDrizzleMediaWorkerRepo, type MediaWorkerRepo } from "@civfix/api/media-repo" +import { + makeDrizzleMediaWorkerRepository, + type MediaWorkerRepository, +} from "@civfix/api/media-repo" import { loadLimits } from "../../src/config.js" import { runOrphanSweep } from "../../src/jobs/orphan-sweep.js" import { servedKey, thumbnailKey } from "../../src/jobs/media-keys.js" @@ -12,12 +15,12 @@ const limits = loadLimits({}) describe.skipIf(!pg)("served_key + conditional orphan reap (integration)", () => { let h: WorkerPgHarness - let repo: MediaWorkerRepo + let repo: MediaWorkerRepository let storage: FakeStorage beforeAll(() => { h = pg as WorkerPgHarness - repo = makeDrizzleMediaWorkerRepo(h.db, h.sql) + repo = makeDrizzleMediaWorkerRepository(h.db, h.sql) storage = new FakeStorage() }) @@ -97,7 +100,7 @@ describe.skipIf(!pg)("served_key + conditional orphan reap (integration)", () => } await h.sql`UPDATE media_assets SET served_key = ${servedKey(r2Key)} WHERE id = ${id}` - const racingRepo: MediaWorkerRepo = { + const racingRepo: MediaWorkerRepository = { ...repo, async findOrphans(olderThan, limit) { const rows = await repo.findOrphans(olderThan, limit) diff --git a/services/media-worker/test/unit/anon-hold-release.test.ts b/services/media-worker/test/unit/anon-hold-release.test.ts index 96269c34..aa21c009 100644 --- a/services/media-worker/test/unit/anon-hold-release.test.ts +++ b/services/media-worker/test/unit/anon-hold-release.test.ts @@ -9,21 +9,21 @@ import type { WorkerSeams } from "../../src/seams.js" import { runHoldReleaseSweep } from "../../src/jobs/hold-release-sweep.js" import { MEDIA_CHECKS_JOB } from "@civfix/api/queue-names" import type { - AnonHoldReleaseRepo, + AnonHoldReleaseRepository, HeldReportView, ReleaseMediaView, } from "@civfix/api/anon-hold-release" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" -class MemHoldRepo implements AnonHoldReleaseRepo { +class MemHoldRepo implements AnonHoldReleaseRepository { report: HeldReportView openFlags = 0 published = false constructor( report: HeldReportView, - private readonly mediaRepo: InMemoryWorkerRepo, + private readonly mediaRepo: InMemoryMediaWorkerRepository, ) { this.report = report } @@ -55,8 +55,8 @@ class MemHoldRepo implements AnonHoldReleaseRepo { } function makeSeams(opts: { - repo: InMemoryWorkerRepo - anonHoldRepo: AnonHoldReleaseRepo + repo: InMemoryMediaWorkerRepository + anonHoldRepo: AnonHoldReleaseRepository abuse: FakeAbuseChecks storage: FakeStorage }): WorkerSeams { @@ -94,7 +94,7 @@ function heldReport(id: string): HeldReportView { async function seedMedia( storage: FakeStorage, - repo: InMemoryWorkerRepo, + repo: InMemoryMediaWorkerRepository, reportId: string, bytes: Buffer, ): Promise<{ mediaId: string; uploadId: string; r2Key: string }> { @@ -109,7 +109,7 @@ async function seedMedia( describe("media-worker hold-release wiring", () => { it("publishes a held anon report once its media goes ready and is clean", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const abuse = new FakeAbuseChecks() const reportId = "anon-report-ready" const holdRepo = new MemHoldRepo(heldReport(reportId), repo) @@ -133,7 +133,7 @@ describe("media-worker hold-release wiring", () => { it("keeps the report held when the media is NSFW (held), even though the hook fires", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const abuse = new FakeAbuseChecks() const reportId = "anon-report-nsfw" const holdRepo = new MemHoldRepo(heldReport(reportId), repo) @@ -157,7 +157,7 @@ describe("media-worker hold-release wiring", () => { describe("hold-release self-healing sweep (P2-8)", () => { it("publishes a held anon report whose media are ready even if NO inline enqueue ever fired", async () => { - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const reportId = "anon-report-stuck" repo.seed({ id: "m1", uploadId: "u1", kind: "image", r2Key: "k1", reportId, status: "ready" }) const holdRepo = new MemHoldRepo(heldReport(reportId), repo) @@ -187,7 +187,7 @@ describe("hold-release self-healing sweep (P2-8)", () => { }) it("leaves a held report held when its media are NOT yet ready (re-checked next run)", async () => { - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const reportId = "anon-report-pending" repo.seed({ id: "m1", diff --git a/services/media-worker/test/unit/maintenance.test.ts b/services/media-worker/test/unit/maintenance.test.ts index 90ad764c..112b615b 100644 --- a/services/media-worker/test/unit/maintenance.test.ts +++ b/services/media-worker/test/unit/maintenance.test.ts @@ -9,7 +9,7 @@ import { import { runPartitionMaintenance } from "../../src/jobs/partition-maintenance.js" import { runStuckSweep } from "../../src/jobs/stuck-sweep.js" import { MEDIA_CHECKS_JOB, MEDIA_UPLOAD_REAP_JOB } from "@civfix/api/queue-names" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import { uploadReapDelaySec } from "../../src/jobs/upload-reap.js" const limits = loadLimits({}) @@ -17,7 +17,7 @@ const limits = loadLimits({}) describe("orphan.sweep", () => { it("deletes only never-attached rows older than the TTL, removing their objects", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const old = new Date(now.getTime() - limits.orphanTtlMs - 60_000) const fresh = new Date(now.getTime() - 60_000) @@ -67,7 +67,7 @@ describe("orphan.sweep", () => { it("never reaps a BOUND row: chat/DM, post, avatar and verification lanes all survive", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const old = new Date(now.getTime() - limits.orphanTtlMs - 60_000) @@ -108,7 +108,7 @@ describe("orphan.sweep", () => { it("M10: DRAINS the backlog across pages instead of reaping one bounded batch per run", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const old = new Date(now.getTime() - limits.orphanTtlMs - 60_000) @@ -141,7 +141,7 @@ describe("orphan.sweep", () => { it("M10: stops at orphanSweepMaxPages so one run cannot monopolize the worker", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const old = new Date(now.getTime() - limits.orphanTtlMs - 60_000) @@ -172,7 +172,7 @@ describe("orphan.sweep", () => { it("never throws: a per-row delete failure is counted, not propagated", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") repo.seed({ id: "boom", @@ -200,7 +200,7 @@ describe("orphan.sweep", () => { it("tombstones objects whose delete failed, then reclaims them on the next run", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const realDelete = storage.delete.bind(storage) let r2Down = true @@ -233,7 +233,7 @@ describe("orphan.sweep", () => { }) it("counts a key repeated inside ONE call as a single attempt (mirrors the real upsert)", async () => { - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() await repo.recordLeakedObjects({ mediaId: "dup-1", @@ -248,7 +248,7 @@ describe("orphan.sweep", () => { it("stops retrying a tombstone at the attempt cap and reports it as permanent", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") storage.delete = () => Promise.reject(new Error("R2 unavailable")) await repo.recordLeakedObjects({ mediaId: "gone-1", keys: ["uploads/stuck"], error: "first" }) @@ -276,7 +276,7 @@ describe("orphan.sweep: legacy served-key adoption stops once drained", () => { it("adopts pre-0097 rows, then never scans again in this process", async () => { resetLegacyServedKeyAdoption() const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const old = new Date(now.getTime() - limits.orphanTtlMs - 60_000) @@ -297,7 +297,7 @@ describe("orphan.sweep: legacy served-key adoption stops once drained", () => { scans += 1 return repo.adoptLegacyServedKeys(olderThan, limit) }, - }) as InMemoryWorkerRepo + }) as InMemoryMediaWorkerRepository await runOrphanSweep({ repo: counting, storage, limits, now: () => now, log: () => {} }) expect(scans).toBe(1) @@ -313,7 +313,7 @@ describe("orphan.sweep: legacy served-key adoption stops once drained", () => { it("keeps scanning while a row still needs adoption", async () => { resetLegacyServedKeyAdoption() const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const now = new Date("2026-06-01T12:00:00Z") const pending = repo.seed({ @@ -420,8 +420,8 @@ describe("media.stuck.sweep", () => { return { jobs, enqueued } } - function makeRepo(): InMemoryWorkerRepo { - const repo = new InMemoryWorkerRepo() + function makeRepo(): InMemoryMediaWorkerRepository { + const repo = new InMemoryMediaWorkerRepository() repo.now = () => now return repo } @@ -429,7 +429,7 @@ describe("media.stuck.sweep", () => { let storage: FakeStorage function run( - repo: InMemoryWorkerRepo, + repo: InMemoryMediaWorkerRepository, jobs: { enqueue: (n: string, d: unknown, o?: unknown) => Promise }, overrides: Partial = {}, report?: (e: unknown) => void, diff --git a/services/media-worker/test/unit/media-checks.test.ts b/services/media-worker/test/unit/media-checks.test.ts index 5ea0e7c1..0c34bd92 100644 --- a/services/media-worker/test/unit/media-checks.test.ts +++ b/services/media-worker/test/unit/media-checks.test.ts @@ -18,7 +18,7 @@ import { type MediaChecksDeps, type DownloadFn, } from "../../src/jobs/media-checks.js" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" const limits: WorkerLimits = loadLimits({}) @@ -26,12 +26,12 @@ const limits: WorkerLimits = loadLimits({}) function makeDeps(over?: Partial): { deps: MediaChecksDeps storage: FakeStorage - repo: InMemoryWorkerRepo + repo: InMemoryMediaWorkerRepository abuse: FakeAbuseChecks reports: unknown[] } { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const abuse = new FakeAbuseChecks() const reports: unknown[] = [] const deps: MediaChecksDeps = { @@ -49,7 +49,7 @@ function makeDeps(over?: Partial): { async function seedAsset( storage: FakeStorage, - repo: InMemoryWorkerRepo, + repo: InMemoryMediaWorkerRepository, kind: "image" | "video", bytes: Uint8Array, ): Promise<{ id: string; uploadId: string; r2Key: string }> { @@ -747,9 +747,13 @@ describe("media.checks terminal-status CAS (F087d)", () => { }) describe("media.checks with the REAL AbuseChecks (default-flag PUBLISH path)", () => { - function realDeps(): { deps: MediaChecksDeps; storage: FakeStorage; repo: InMemoryWorkerRepo } { + function realDeps(): { + deps: MediaChecksDeps + storage: FakeStorage + repo: InMemoryMediaWorkerRepository + } { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const abuse = new RealAbuseChecks({ perceptualHash: (bytes: Uint8Array) => perceptualHash(bytes, limits), log: () => {}, @@ -840,7 +844,7 @@ describe("media.checks with the REAL AbuseChecks (default-flag PUBLISH path)", ( it("a real NSFW POSITIVE (model returns high) -> HELD + abuse_flag nsfw", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const abuse = new RealAbuseChecks({ useRealNsfw: true, nsfwModel: () => Promise.resolve(0.99), @@ -871,7 +875,7 @@ describe("media.checks with the REAL AbuseChecks (default-flag PUBLISH path)", ( it("#43: a near-duplicate (injected lookup reports dup) is ALLOWED (ready, no phash_dup flag)", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const abuse = new RealAbuseChecks({ perceptualHash: (bytes: Uint8Array) => perceptualHash(bytes, limits), findPhashDuplicate: () => Promise.resolve({ dup: true, ofReportId: "prior-report" }), diff --git a/services/media-worker/test/unit/orphan-race.test.ts b/services/media-worker/test/unit/orphan-race.test.ts index 68f034bc..4cb9abe1 100644 --- a/services/media-worker/test/unit/orphan-race.test.ts +++ b/services/media-worker/test/unit/orphan-race.test.ts @@ -3,12 +3,12 @@ import { FakeStorage } from "@civfix/shared/fakes" import { loadLimits, type WorkerLimits } from "../../src/config.js" import { runOrphanSweep } from "../../src/jobs/orphan-sweep.js" import { servedKey, thumbnailKey } from "../../src/jobs/media-keys.js" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" const limits: WorkerLimits = loadLimits({}) const NOW = new Date("2026-09-02T12:00:00Z") -async function seedOrphan(storage: FakeStorage, repo: InMemoryWorkerRepo, id: string) { +async function seedOrphan(storage: FakeStorage, repo: InMemoryMediaWorkerRepository, id: string) { const r2Key = `uploads/2026/09/${id}` repo.seed({ id, @@ -26,14 +26,14 @@ async function seedOrphan(storage: FakeStorage, repo: InMemoryWorkerRepo, id: st return r2Key } -function sweep(repo: InMemoryWorkerRepo, storage: FakeStorage) { +function sweep(repo: InMemoryMediaWorkerRepository, storage: FakeStorage) { return runOrphanSweep({ repo, storage, limits, now: () => NOW, log: () => {}, report: () => {} }) } describe("orphan sweep legacy served-key adoption", () => { it("stamps served_key = r2_key on pre-0097 ready rows once their PUT window has passed", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = "uploads/2026/01/legacy" repo.seed({ id: "legacy", @@ -55,7 +55,7 @@ describe("orphan sweep legacy served-key adoption", () => { it("leaves a row alone while its presigned PUT could still be replayed", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() repo.seed({ id: "fresh", uploadId: "fresh-u", @@ -77,7 +77,7 @@ describe("orphan sweep legacy served-key adoption", () => { describe("orphan sweep race with a bind", () => { it("skips the reap and the object deletes when the row was bound after the SELECT", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = await seedOrphan(storage, repo, "raced") const realFind = repo.findOrphans.bind(repo) @@ -102,7 +102,7 @@ describe("orphan sweep race with a bind", () => { it("still reaps a genuine orphan, including its served and thumbnail objects", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = await seedOrphan(storage, repo, "genuine") const result = await sweep(repo, storage) @@ -117,7 +117,7 @@ describe("orphan sweep race with a bind", () => { it("skips a row an avatar claimed after the SELECT", async () => { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = await seedOrphan(storage, repo, "avatar") const realFind = repo.findOrphans.bind(repo) diff --git a/services/media-worker/test/unit/scratch-infra-classification.test.ts b/services/media-worker/test/unit/scratch-infra-classification.test.ts index 357e3250..fab33b74 100644 --- a/services/media-worker/test/unit/scratch-infra-classification.test.ts +++ b/services/media-worker/test/unit/scratch-infra-classification.test.ts @@ -47,7 +47,8 @@ const { makeScratch, ScratchSetupError } = await import("../../src/sandbox/tmp.j const { mediaToolPath, resetMediaToolPaths } = await import("../../src/sandbox/binaries.js") const { processMedia } = await import("../../src/jobs/media-pipeline.js") const { runMediaChecksJob, MediaInfraError } = await import("../../src/jobs/media-checks.js") -const { InMemoryWorkerRepo } = await import("../helpers/in-memory-repo.js") +const { InMemoryMediaWorkerRepository } = + await import("../helpers/in-memory-media-worker-repository.js") const { makeDownloader } = await import("../../src/download.js") const fx = await import("../fixtures/make.js") @@ -176,7 +177,7 @@ describe("scratch setup failures are infrastructure, not a verdict on the bytes" it("media.checks retries a scratch failure and keeps the asset validating with its upload intact", async () => { lane.impl = () => Promise.reject(scratchInfraError()) const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = "uploads/2026/09/scratch" repo.seed({ id: "m1", uploadId: "u1", kind: "image", r2Key }) await storage.put(r2Key, Buffer.from(await fx.makeValidPng()), { contentType: "image/png" }) diff --git a/services/media-worker/test/unit/served-key.test.ts b/services/media-worker/test/unit/served-key.test.ts index aefb8963..86237bd2 100644 --- a/services/media-worker/test/unit/served-key.test.ts +++ b/services/media-worker/test/unit/served-key.test.ts @@ -5,7 +5,7 @@ import { loadLimits, type WorkerLimits } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" import { runMediaChecksJob, type MediaChecksDeps } from "../../src/jobs/media-checks.js" import { servedKey, thumbnailKey } from "../../src/jobs/media-keys.js" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" const limits: WorkerLimits = loadLimits({}) @@ -13,10 +13,10 @@ const limits: WorkerLimits = loadLimits({}) function makeDeps(over?: Partial): { deps: MediaChecksDeps storage: FakeStorage - repo: InMemoryWorkerRepo + repo: InMemoryMediaWorkerRepository } { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const deps: MediaChecksDeps = { repo, storage, @@ -32,7 +32,7 @@ function makeDeps(over?: Partial): { async function seed( storage: FakeStorage, - repo: InMemoryWorkerRepo, + repo: InMemoryMediaWorkerRepository, bytes: Uint8Array, ): Promise<{ id: string; uploadId: string; r2Key: string }> { const id = `media-${Math.random().toString(36).slice(2, 10)}` diff --git a/services/media-worker/test/unit/spawn-failure-classification.test.ts b/services/media-worker/test/unit/spawn-failure-classification.test.ts index 51c93667..1d9e077c 100644 --- a/services/media-worker/test/unit/spawn-failure-classification.test.ts +++ b/services/media-worker/test/unit/spawn-failure-classification.test.ts @@ -15,7 +15,8 @@ const { SandboxSpawnError, SandboxToolError, resetSandboxIdentity } = const { processMedia } = await import("../../src/jobs/media-pipeline.js") const { runMediaChecksJob, MediaInfraError } = await import("../../src/jobs/media-checks.js") const { assertSandboxPreflight } = await import("../../src/sandbox/preflight.js") -const { InMemoryWorkerRepo } = await import("../helpers/in-memory-repo.js") +const { InMemoryMediaWorkerRepository } = + await import("../helpers/in-memory-media-worker-repository.js") const { makeDownloader } = await import("../../src/download.js") const fx = await import("../fixtures/make.js") @@ -89,7 +90,7 @@ describe("media.checks classification", () => { }), ) const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = "uploads/2026/09/segv" repo.seed({ id: "m2", uploadId: "u2", kind: "image", r2Key }) await storage.put(r2Key, Buffer.from(await fx.makeValidPng()), { contentType: "image/png" }) @@ -114,7 +115,7 @@ describe("media.checks classification", () => { it("turns a spawn-level failure into MediaInfraError, leaving the asset validating and its bytes intact", async () => { processImageLaneMock.mockRejectedValue(new SandboxSpawnError("image-lane", new Error("EACCES"))) const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const r2Key = "uploads/2026/09/spawn" repo.seed({ id: "m1", uploadId: "u1", kind: "image", r2Key }) await storage.put(r2Key, Buffer.from(await fx.makeValidPng()), { contentType: "image/png" }) diff --git a/services/media-worker/test/unit/stuck-sweep-etag.test.ts b/services/media-worker/test/unit/stuck-sweep-etag.test.ts index 383fae17..b407ea72 100644 --- a/services/media-worker/test/unit/stuck-sweep-etag.test.ts +++ b/services/media-worker/test/unit/stuck-sweep-etag.test.ts @@ -4,14 +4,14 @@ import { MEDIA_CHECKS_JOB } from "@civfix/api/queue-names" import { loadLimits } from "../../src/config.js" import { parsePayload } from "../../src/jobs/media-checks.js" import { runStuckSweep } from "../../src/jobs/stuck-sweep.js" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" const limits = loadLimits({}) const NOW = new Date("2026-06-01T12:00:00Z") const STUCK_SINCE = new Date(NOW.getTime() - limits.stuckMediaTtlMs - 60_000) const FINALIZED_ETAG = "5d41402abc4b2a76b9719d911017c592" -async function sweepOnce(repo: InMemoryWorkerRepo): Promise { +async function sweepOnce(repo: InMemoryMediaWorkerRepository): Promise { const payloads: unknown[] = [] await runStuckSweep({ repo, @@ -29,7 +29,7 @@ async function sweepOnce(repo: InMemoryWorkerRepo): Promise { return payloads } -function seedStuck(repo: InMemoryWorkerRepo, uploadEtag: string | null): void { +function seedStuck(repo: InMemoryMediaWorkerRepository, uploadEtag: string | null): void { repo.now = () => NOW repo.seed({ id: "stuck-1", @@ -45,7 +45,7 @@ function seedStuck(repo: InMemoryWorkerRepo, uploadEtag: string | null): void { describe("media.stuck.sweep keeps the finalize-time overwrite check", () => { it("requeues media.checks with the upload etag recorded at finalize", async () => { - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() seedStuck(repo, FINALIZED_ETAG) const payloads = await sweepOnce(repo) @@ -63,7 +63,7 @@ describe("media.stuck.sweep keeps the finalize-time overwrite check", () => { }) it("a row finalized before the etag was stored requeues with no etag, as before", async () => { - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() seedStuck(repo, null) const payloads = await sweepOnce(repo) diff --git a/services/media-worker/test/unit/upload-reap.test.ts b/services/media-worker/test/unit/upload-reap.test.ts index 152ae213..66595022 100644 --- a/services/media-worker/test/unit/upload-reap.test.ts +++ b/services/media-worker/test/unit/upload-reap.test.ts @@ -7,11 +7,11 @@ import { runUploadReapJob, uploadReapDelaySec, } from "../../src/jobs/upload-reap.js" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" function env() { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() return { storage, repo, @@ -21,7 +21,7 @@ function env() { async function seedPublished( storage: FakeStorage, - repo: InMemoryWorkerRepo, + repo: InMemoryMediaWorkerRepository, over: { status?: "ready" | "held" | "rejected" | "validating"; servedKey?: string | null } = {}, ) { const id = "m1" diff --git a/services/media-worker/test/unit/video-policy.test.ts b/services/media-worker/test/unit/video-policy.test.ts index de3c8fe5..8074bdf6 100644 --- a/services/media-worker/test/unit/video-policy.test.ts +++ b/services/media-worker/test/unit/video-policy.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import { loadLimits, type WorkerLimits } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" const stub = vi.hoisted(() => ({ grabFrameError: null as Error | null })) @@ -27,11 +27,11 @@ const limits: WorkerLimits = loadLimits({}) function makeEnv(over?: Partial): { deps: MediaChecksDeps storage: FakeStorage - repo: InMemoryWorkerRepo + repo: InMemoryMediaWorkerRepository reports: unknown[] } { const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const reports: unknown[] = [] const deps: MediaChecksDeps = { repo, @@ -48,7 +48,7 @@ function makeEnv(over?: Partial): { async function seedVideo( storage: FakeStorage, - repo: InMemoryWorkerRepo, + repo: InMemoryMediaWorkerRepository, bytes: Uint8Array, over: { reportId?: string } = {}, ): Promise<{ id: string; uploadId: string; r2Key: string }> { diff --git a/services/media-worker/test/unit/worker.test.ts b/services/media-worker/test/unit/worker.test.ts index 5b56f270..e0edccbe 100644 --- a/services/media-worker/test/unit/worker.test.ts +++ b/services/media-worker/test/unit/worker.test.ts @@ -21,9 +21,9 @@ import { loadLimits, } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" -import type { AnonHoldReleaseRepo, HeldReportView } from "@civfix/api/anon-hold-release" +import type { AnonHoldReleaseRepository, HeldReportView } from "@civfix/api/anon-hold-release" import { FakeJobs, FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" -import { InMemoryWorkerRepo } from "../helpers/in-memory-repo.js" +import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" describe("media-worker wiring", () => { @@ -195,7 +195,7 @@ describe("F25: post-success hold-release hook is gated on anon+held report state } } - function makeAnonHoldRepo(report: HeldReportView): AnonHoldReleaseRepo { + function makeAnonHoldRepo(report: HeldReportView): AnonHoldReleaseRepository { return { findReport: () => Promise.resolve(report), findMedia: () => Promise.resolve([{ id: "m1", status: "ready" }]), @@ -206,12 +206,12 @@ describe("F25: post-success hold-release hook is gated on anon+held report state } async function runMediaJobWith( - anonHoldRepo: AnonHoldReleaseRepo, - ): Promise<{ fake: FakeJobs; repo: InMemoryWorkerRepo }> { + anonHoldRepo: AnonHoldReleaseRepository, + ): Promise<{ fake: FakeJobs; repo: InMemoryMediaWorkerRepository }> { const handle = makeJobs() const fake = handle.jobs as unknown as FakeJobs const storage = new FakeStorage() - const repo = new InMemoryWorkerRepo() + const repo = new InMemoryMediaWorkerRepository() const reportId = "report-1" repo.seed({ id: "m1", From 52f0d3f032b429b29f39dc61a30ff4ee2fd8bc2c Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:47:17 +0000 Subject: [PATCH 12/20] repository part files and binders named after their repositories --- services/api/src/db/schema/post_mentions.ts | 2 +- services/api/src/routes/map.routes.ts | 7 ++++-- ...=> chat-attachments-repository.drizzle.ts} | 4 ++-- .../chat-mentions-repository.drizzle.ts | 7 ++++-- ...re.drizzle.ts => chat-message-core-sql.ts} | 4 ++-- ...s => chat-reactions-repository.drizzle.ts} | 7 ++++-- .../src/services/chat-repository.drizzle.ts | 6 ++--- ...y.ts => cleanup-map-repository.drizzle.ts} | 2 +- .../api/src/services/dm-repository.drizzle.ts | 6 ++--- ...nouncement-identity-repository.drizzle.ts} | 0 .../src/services/host/announcement-service.ts | 2 +- .../api/src/services/host/comms-wiring.ts | 2 +- .../host/host-team-repository.drizzle.ts | 2 +- ... organization-repository-admin.drizzle.ts} | 2 +- ...rganization-repository-invites.drizzle.ts} | 4 ++-- ...rganization-repository-members.drizzle.ts} | 0 ...rganization-repository-profile.drizzle.ts} | 2 +- ...> organization-repository-rows.drizzle.ts} | 0 ...zation-repository-verification.drizzle.ts} | 2 +- .../host/organization-repository.drizzle.ts | 10 ++++----- ...egistration-repository-checkin.drizzle.ts} | 4 ++-- ...> registration-repository-load.drizzle.ts} | 0 ... registration-repository-pages.drizzle.ts} | 2 +- ...istration-repository-questions.drizzle.ts} | 2 +- ...gistration-repository-register.drizzle.ts} | 6 ++++- ...registration-repository-roster.drizzle.ts} | 4 ++-- ...ration-repository-ticket-types.drizzle.ts} | 2 +- ...gistration-repository-waitlist.drizzle.ts} | 4 ++-- .../host/registration-repository.drizzle.ts | 22 +++++++++---------- .../host/registration-repository.memory.ts | 10 ++++----- .../message-attachments-repository.drizzle.ts | 6 +++-- .../message-mentions-repository.drizzle.ts | 6 ++--- .../message-reactions-repository.drizzle.ts | 7 ++++-- .../src/services/post-repository.drizzle.ts | 7 ++++-- .../media-attach-claim-security-pg.test.ts | 2 +- .../unit/chat-attachments-servable.test.ts | 8 +++---- .../host/announcement-send-failure.test.ts | 2 +- .../unit/host/announcement-service.test.ts | 2 +- .../unit/media-attach-claim-security.test.ts | 2 +- .../test/unit/media-claim-uploader.test.ts | 2 +- .../test/unit/media-public-served-key.test.ts | 2 +- 41 files changed, 97 insertions(+), 76 deletions(-) rename services/api/src/services/{chat-attachments.drizzle.ts => chat-attachments-repository.drizzle.ts} (89%) rename services/api/src/services/{chat-message-core.drizzle.ts => chat-message-core-sql.ts} (96%) rename services/api/src/services/{chat-reactions.drizzle.ts => chat-reactions-repository.drizzle.ts} (83%) rename services/api/src/services/{cleanup-map-repository.ts => cleanup-map-repository.drizzle.ts} (95%) rename services/api/src/services/host/{announcement-repository.drizzle.ts => announcement-identity-repository.drizzle.ts} (100%) rename services/api/src/services/host/{organization-admin.drizzle.ts => organization-repository-admin.drizzle.ts} (99%) rename services/api/src/services/host/{organization-invites.drizzle.ts => organization-repository-invites.drizzle.ts} (99%) rename services/api/src/services/host/{organization-members.drizzle.ts => organization-repository-members.drizzle.ts} (100%) rename services/api/src/services/host/{organization-profile.drizzle.ts => organization-repository-profile.drizzle.ts} (99%) rename services/api/src/services/host/{organization-rows.drizzle.ts => organization-repository-rows.drizzle.ts} (100%) rename services/api/src/services/host/{organization-verification.drizzle.ts => organization-repository-verification.drizzle.ts} (99%) rename services/api/src/services/host/{registration-checkin.drizzle.ts => registration-repository-checkin.drizzle.ts} (98%) rename services/api/src/services/host/{registration-load.drizzle.ts => registration-repository-load.drizzle.ts} (100%) rename services/api/src/services/host/{registration-pages.drizzle.ts => registration-repository-pages.drizzle.ts} (99%) rename services/api/src/services/host/{registration-questions.drizzle.ts => registration-repository-questions.drizzle.ts} (98%) rename services/api/src/services/host/{registration-register.drizzle.ts => registration-repository-register.drizzle.ts} (99%) rename services/api/src/services/host/{registration-roster.drizzle.ts => registration-repository-roster.drizzle.ts} (99%) rename services/api/src/services/host/{registration-ticket-types.drizzle.ts => registration-repository-ticket-types.drizzle.ts} (99%) rename services/api/src/services/host/{registration-waitlist.drizzle.ts => registration-repository-waitlist.drizzle.ts} (99%) diff --git a/services/api/src/db/schema/post_mentions.ts b/services/api/src/db/schema/post_mentions.ts index 9c5101ed..ecf37110 100644 --- a/services/api/src/db/schema/post_mentions.ts +++ b/services/api/src/db/schema/post_mentions.ts @@ -1,5 +1,5 @@ /** - * Same shape as chat_message_mentions so makeMentionRepo serves both, except that post_id can carry an + * Same shape as chat_message_mentions so makeMessageMentionRepository serves both, except that post_id can carry an * FK: posts is not partitioned. */ diff --git a/services/api/src/routes/map.routes.ts b/services/api/src/routes/map.routes.ts index 2cadf6d8..81aea04a 100644 --- a/services/api/src/routes/map.routes.ts +++ b/services/api/src/routes/map.routes.ts @@ -22,7 +22,10 @@ import { makeCachedAddressResolver, makeRouteJurisdictionService, } from "../services/route-geo-helpers.js" -import { makeCleanupMapRepository, MAP_CLEANUPS_LIMIT } from "../services/cleanup-map-repository.js" +import { + makeDrizzleCleanupMapRepository, + MAP_CLEANUPS_LIMIT, +} from "../services/cleanup-map-repository.drizzle.js" import { insertAuditRow } from "../services/admin/audit-repository.drizzle.js" import { CappedBBoxQueryParam } from "./query-encoding.js" import { parse, trimTextFields } from "./_validate.js" @@ -200,7 +203,7 @@ export async function registerMapRoutes(app: FastifyInstance, container: Contain ...(q.when !== undefined ? { when: q.when } : {}), }) - const repo = makeCleanupMapRepository(container.getDb().sql) + const repo = makeDrizzleCleanupMapRepository(container.getDb().sql) const pins = await repo.listCleanupPins(bbox, when) const payload: MapCleanupsResponse = { pins } reply.header("Cache-Control", MAP_CLEANUPS_CACHE_CONTROL) diff --git a/services/api/src/services/chat-attachments.drizzle.ts b/services/api/src/services/chat-attachments-repository.drizzle.ts similarity index 89% rename from services/api/src/services/chat-attachments.drizzle.ts rename to services/api/src/services/chat-attachments-repository.drizzle.ts index 8183eeb2..f0fd6b7a 100644 --- a/services/api/src/services/chat-attachments.drizzle.ts +++ b/services/api/src/services/chat-attachments-repository.drizzle.ts @@ -3,7 +3,7 @@ import type { MediaDTO } from "@civfix/shared" import type { PresignMedia } from "./media-presign.js" import { loadServableAttachmentsFor, - makeAttachmentRepo, + makeMessageAttachmentRepository, type MessageMediaColumn, } from "./message-attachments-repository.drizzle.js" @@ -16,7 +16,7 @@ export function attachChatMedia( messageCreatedAt: Date, senderId: string, ): Promise { - return makeAttachmentRepo(CHAT_MESSAGE_COLUMN).attach( + return makeMessageAttachmentRepository(CHAT_MESSAGE_COLUMN).attach( sql, messageId, uploadIds, diff --git a/services/api/src/services/chat-mentions-repository.drizzle.ts b/services/api/src/services/chat-mentions-repository.drizzle.ts index e0e616b0..3a1fa687 100644 --- a/services/api/src/services/chat-mentions-repository.drizzle.ts +++ b/services/api/src/services/chat-mentions-repository.drizzle.ts @@ -5,7 +5,10 @@ import type { Queryable, Sql } from "../db/client.js" import type { UserMentionDTO } from "@civfix/shared" -import { loadMentionsFor, makeMentionRepo } from "./message-mentions-repository.drizzle.js" +import { + loadMentionsFor, + makeMessageMentionRepository, +} from "./message-mentions-repository.drizzle.js" const CHAT_MENTIONS = "chat_message_mentions" as const @@ -33,7 +36,7 @@ export async function recordChatMentions( messageId: string, mentionedUserIds: string[], ): Promise { - const repo = makeMentionRepo(sql, CHAT_MENTIONS) + const repo = makeMessageMentionRepository(sql, CHAT_MENTIONS) await sql.begin(async (tx) => { await repo.recordFor(tx, messageId, mentionedUserIds) }) diff --git a/services/api/src/services/chat-message-core.drizzle.ts b/services/api/src/services/chat-message-core-sql.ts similarity index 96% rename from services/api/src/services/chat-message-core.drizzle.ts rename to services/api/src/services/chat-message-core-sql.ts index 2377500e..5c9ac64e 100644 --- a/services/api/src/services/chat-message-core.drizzle.ts +++ b/services/api/src/services/chat-message-core-sql.ts @@ -11,9 +11,9 @@ import type { } from "@civfix/shared" import type { Queryable } from "../db/client.js" import { publicAuthorIdentity } from "./public-author.js" -import { loadChatReactionsFor } from "./chat-reactions.drizzle.js" +import { loadChatReactionsFor } from "./chat-reactions-repository.drizzle.js" import { loadChatMentionsFor } from "./chat-mentions-repository.drizzle.js" -import { loadChatAttachments } from "./chat-attachments.drizzle.js" +import { loadChatAttachments } from "./chat-attachments-repository.drizzle.js" import { replyMapForRows } from "./chat-reply-hydration.js" import type { ReplyTable } from "./reply-targets-repository.drizzle.js" import type { PresignMedia } from "./media-presign.js" diff --git a/services/api/src/services/chat-reactions.drizzle.ts b/services/api/src/services/chat-reactions-repository.drizzle.ts similarity index 83% rename from services/api/src/services/chat-reactions.drizzle.ts rename to services/api/src/services/chat-reactions-repository.drizzle.ts index 0837fb1a..afbe579d 100644 --- a/services/api/src/services/chat-reactions.drizzle.ts +++ b/services/api/src/services/chat-reactions-repository.drizzle.ts @@ -3,7 +3,10 @@ import type { Queryable, Sql } from "../db/client.js" import type { ReactionEmoji, ReactionSummaryDTO } from "@civfix/shared" -import { loadReactionsFor, makeReactionRepo } from "./message-reactions-repository.drizzle.js" +import { + loadReactionsFor, + makeMessageReactionRepository, +} from "./message-reactions-repository.drizzle.js" const CHAT_REACTIONS = "chat_message_reactions" as const @@ -32,5 +35,5 @@ export function toggleChatReaction( userId: string, emoji: ReactionEmoji, ): Promise { - return makeReactionRepo(sql, CHAT_REACTIONS).toggle(messageId, userId, emoji) + return makeMessageReactionRepository(sql, CHAT_REACTIONS).toggle(messageId, userId, emoji) } diff --git a/services/api/src/services/chat-repository.drizzle.ts b/services/api/src/services/chat-repository.drizzle.ts index a7cc3d24..4a2cce46 100644 --- a/services/api/src/services/chat-repository.drizzle.ts +++ b/services/api/src/services/chat-repository.drizzle.ts @@ -10,9 +10,9 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { ChatHistoryPage, PersistChatInput } from "@civfix/shared/interfaces" -import { loadChatReactions, toggleChatReaction } from "./chat-reactions.drizzle.js" +import { loadChatReactions, toggleChatReaction } from "./chat-reactions-repository.drizzle.js" import { loadChatMentions } from "./chat-mentions-repository.drizzle.js" -import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" +import { attachChatMedia, loadChatAttachments } from "./chat-attachments-repository.drizzle.js" import type { PresignMedia } from "./media-presign.js" import { mapSystemRow } from "./report-chat-repository.drizzle.js" import { parseCityMention, effectiveJurisdictionHandle } from "./discussion-mentions.js" @@ -30,7 +30,7 @@ import { replyFor, senderColumns, type MessageCoreRow, -} from "./chat-message-core.drizzle.js" +} from "./chat-message-core-sql.js" export interface ReportCityContext { geoid: string diff --git a/services/api/src/services/cleanup-map-repository.ts b/services/api/src/services/cleanup-map-repository.drizzle.ts similarity index 95% rename from services/api/src/services/cleanup-map-repository.ts rename to services/api/src/services/cleanup-map-repository.drizzle.ts index 53bbed68..14723b13 100644 --- a/services/api/src/services/cleanup-map-repository.ts +++ b/services/api/src/services/cleanup-map-repository.drizzle.ts @@ -17,7 +17,7 @@ export interface CleanupMapRepository { ): Promise } -export function makeCleanupMapRepository(sql: Sql): CleanupMapRepository { +export function makeDrizzleCleanupMapRepository(sql: Sql): CleanupMapRepository { return { async listCleanupPins(bbox, when): Promise { const whenFilter = buildWhenFilter(sql, when ?? "upcoming") diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index 31c5e52b..b1ebbaa2 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -10,9 +10,9 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { ChatHistoryPage } from "@civfix/shared/interfaces" -import { loadChatReactions, toggleChatReaction } from "./chat-reactions.drizzle.js" +import { loadChatReactions, toggleChatReaction } from "./chat-reactions-repository.drizzle.js" import { loadChatMentions } from "./chat-mentions-repository.drizzle.js" -import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" +import { attachChatMedia, loadChatAttachments } from "./chat-attachments-repository.drizzle.js" import { monotonicReadWatermark } from "./read-watermark-repository.drizzle.js" import { assertReplyTarget, replyMapForRows } from "./chat-reply-hydration.js" import { msKeysetFilter, type TimeCursor } from "../db/cursor-helpers.js" @@ -28,7 +28,7 @@ import { replyFor, senderColumns, type MessageCoreRow, -} from "./chat-message-core.drizzle.js" +} from "./chat-message-core-sql.js" // dm_messages is range-partitioned on created_at and an ack carries only the message id, so the bound // lets the planner prune the lookup to recent partitions instead of probing every month ever created. diff --git a/services/api/src/services/host/announcement-repository.drizzle.ts b/services/api/src/services/host/announcement-identity-repository.drizzle.ts similarity index 100% rename from services/api/src/services/host/announcement-repository.drizzle.ts rename to services/api/src/services/host/announcement-identity-repository.drizzle.ts diff --git a/services/api/src/services/host/announcement-service.ts b/services/api/src/services/host/announcement-service.ts index a4f98eb6..c8a1a954 100644 --- a/services/api/src/services/host/announcement-service.ts +++ b/services/api/src/services/host/announcement-service.ts @@ -22,7 +22,7 @@ import type { BroadcastRecord } from "./broadcast-types.js" import { ANNOUNCEMENT_VISIBLE_STATUSES } from "./broadcast-types.js" import { announcementPath, broadcastLinkWarnings } from "./broadcast-render.js" import type { BroadcastConfig, BroadcastService } from "./broadcast-service.js" -import type { AnnouncementIdentityRepository } from "./announcement-repository.drizzle.js" +import type { AnnouncementIdentityRepository } from "./announcement-identity-repository.drizzle.js" const ANNOUNCEMENT_DEFAULT_LIMIT = 20 const ANNOUNCEMENT_MAX_LIMIT = 50 diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index ccde6cff..b1ca506e 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -11,7 +11,7 @@ import { makeDrizzleAnalyticsRepository } from "./analytics-repository.drizzle.j import { makeAnalyticsService, type AnalyticsService } from "./analytics-service.js" import { makeDrizzleEventAnalyticsRepository } from "./event-analytics-repository.drizzle.js" import { makeEventAnalyticsService, type EventAnalyticsService } from "./event-analytics-service.js" -import { makeDrizzleAnnouncementIdentityRepository } from "./announcement-repository.drizzle.js" +import { makeDrizzleAnnouncementIdentityRepository } from "./announcement-identity-repository.drizzle.js" import { makeAnnouncementService, type AnnouncementService } from "./announcement-service.js" import { MEDIA_GET_URL_TTL_SEC } from "../media-intake-service.js" import { makeInsightsService, type InsightsService } from "./insights-service.js" diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 98189c1a..9c6058b0 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -33,7 +33,7 @@ import type { RevokeTeamInviteOutcome, } from "./host-team-repository.types.js" import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" -import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-rows.drizzle.js" +import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-repository-rows.drizzle.js" const TEAM_INVITE_PENDING_CONSTRAINTS = [ "cleanup_team_invites_pending_user_uidx", diff --git a/services/api/src/services/host/organization-admin.drizzle.ts b/services/api/src/services/host/organization-repository-admin.drizzle.ts similarity index 99% rename from services/api/src/services/host/organization-admin.drizzle.ts rename to services/api/src/services/host/organization-repository-admin.drizzle.ts index 8d4e6456..4a9250cb 100644 --- a/services/api/src/services/host/organization-admin.drizzle.ts +++ b/services/api/src/services/host/organization-repository-admin.drizzle.ts @@ -13,7 +13,7 @@ import { organizationColumns, toOrganizationBaseRecord, type OrganizationRowSelect, -} from "./organization-rows.drizzle.js" +} from "./organization-repository-rows.drizzle.js" import type { AdminOrganizationCounts, AdminOrganizationListQuery, diff --git a/services/api/src/services/host/organization-invites.drizzle.ts b/services/api/src/services/host/organization-repository-invites.drizzle.ts similarity index 99% rename from services/api/src/services/host/organization-invites.drizzle.ts rename to services/api/src/services/host/organization-repository-invites.drizzle.ts index 9f0b0718..d007c743 100644 --- a/services/api/src/services/host/organization-invites.drizzle.ts +++ b/services/api/src/services/host/organization-repository-invites.drizzle.ts @@ -10,8 +10,8 @@ import { import type { Queryable, Sql } from "../../db/client.js" import { publicServedKeyExpr } from "../media-served-key.js" import { writeHostAudit } from "./host-audit.js" -import { lockOrgForActorIn } from "./organization-members.drizzle.js" -import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-rows.drizzle.js" +import { lockOrgForActorIn } from "./organization-repository-members.drizzle.js" +import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-repository-rows.drizzle.js" import type { AcceptOrganizationInviteOutcome, CreateOrganizationInviteArgs, diff --git a/services/api/src/services/host/organization-members.drizzle.ts b/services/api/src/services/host/organization-repository-members.drizzle.ts similarity index 100% rename from services/api/src/services/host/organization-members.drizzle.ts rename to services/api/src/services/host/organization-repository-members.drizzle.ts diff --git a/services/api/src/services/host/organization-profile.drizzle.ts b/services/api/src/services/host/organization-repository-profile.drizzle.ts similarity index 99% rename from services/api/src/services/host/organization-profile.drizzle.ts rename to services/api/src/services/host/organization-repository-profile.drizzle.ts index 86689fbe..b3fb7912 100644 --- a/services/api/src/services/host/organization-profile.drizzle.ts +++ b/services/api/src/services/host/organization-repository-profile.drizzle.ts @@ -8,7 +8,7 @@ import { readOrgHours, toOrganizationRecord, type OrganizationRowSelect, -} from "./organization-rows.drizzle.js" +} from "./organization-repository-rows.drizzle.js" import type { CreateOrganizationArgs, OrganizationRecord, diff --git a/services/api/src/services/host/organization-rows.drizzle.ts b/services/api/src/services/host/organization-repository-rows.drizzle.ts similarity index 100% rename from services/api/src/services/host/organization-rows.drizzle.ts rename to services/api/src/services/host/organization-repository-rows.drizzle.ts diff --git a/services/api/src/services/host/organization-verification.drizzle.ts b/services/api/src/services/host/organization-repository-verification.drizzle.ts similarity index 99% rename from services/api/src/services/host/organization-verification.drizzle.ts rename to services/api/src/services/host/organization-repository-verification.drizzle.ts index a5f3ac6a..8dd919c3 100644 --- a/services/api/src/services/host/organization-verification.drizzle.ts +++ b/services/api/src/services/host/organization-repository-verification.drizzle.ts @@ -5,7 +5,7 @@ import { likeContains } from "../../db/like.js" import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" import { userUploader } from "../media-uploader.js" import { writeHostAudit } from "./host-audit.js" -import { adminActorOf } from "./organization-rows.drizzle.js" +import { adminActorOf } from "./organization-repository-rows.drizzle.js" import type { AdminOrgListQuery, AdminOrgVerificationRecord, diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 27fec5e7..5e63d778 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -1,10 +1,10 @@ import type { Sql } from "../../db/client.js" -import { makeOrganizationAdminMethods } from "./organization-admin.drizzle.js" -import { makeOrganizationInviteMethods } from "./organization-invites.drizzle.js" -import { makeOrganizationMemberMethods } from "./organization-members.drizzle.js" -import { makeOrganizationProfileMethods } from "./organization-profile.drizzle.js" +import { makeOrganizationAdminMethods } from "./organization-repository-admin.drizzle.js" +import { makeOrganizationInviteMethods } from "./organization-repository-invites.drizzle.js" +import { makeOrganizationMemberMethods } from "./organization-repository-members.drizzle.js" +import { makeOrganizationProfileMethods } from "./organization-repository-profile.drizzle.js" import type { OrganizationRepository } from "./organization-repository.types.js" -import { makeOrganizationVerificationMethods } from "./organization-verification.drizzle.js" +import { makeOrganizationVerificationMethods } from "./organization-repository-verification.drizzle.js" export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationRepository { return { diff --git a/services/api/src/services/host/registration-checkin.drizzle.ts b/services/api/src/services/host/registration-repository-checkin.drizzle.ts similarity index 98% rename from services/api/src/services/host/registration-checkin.drizzle.ts rename to services/api/src/services/host/registration-repository-checkin.drizzle.ts index 0e082d10..bcd67fbb 100644 --- a/services/api/src/services/host/registration-checkin.drizzle.ts +++ b/services/api/src/services/host/registration-repository-checkin.drizzle.ts @@ -1,8 +1,8 @@ import type { CheckinMethod } from "@civfix/shared" import type { Sql } from "../../db/client.js" import { toSeatRecord, waitlistEntryNotBanned, type SeatRowSelect } from "./registration-sql.js" -import { loadRegistrationById } from "./registration-load.drizzle.js" -import { MAX_TICKET_TYPES } from "./registration-ticket-types.drizzle.js" +import { loadRegistrationById } from "./registration-repository-load.drizzle.js" +import { MAX_TICKET_TYPES } from "./registration-repository-ticket-types.drizzle.js" import type { CheckinCountersRecord, CheckinResultRecord, diff --git a/services/api/src/services/host/registration-load.drizzle.ts b/services/api/src/services/host/registration-repository-load.drizzle.ts similarity index 100% rename from services/api/src/services/host/registration-load.drizzle.ts rename to services/api/src/services/host/registration-repository-load.drizzle.ts diff --git a/services/api/src/services/host/registration-pages.drizzle.ts b/services/api/src/services/host/registration-repository-pages.drizzle.ts similarity index 99% rename from services/api/src/services/host/registration-pages.drizzle.ts rename to services/api/src/services/host/registration-repository-pages.drizzle.ts index 47227232..547ce3f7 100644 --- a/services/api/src/services/host/registration-pages.drizzle.ts +++ b/services/api/src/services/host/registration-repository-pages.drizzle.ts @@ -9,7 +9,7 @@ import { loadLiveQuestions, loadPage, loadTicketTypes, -} from "./registration-load.drizzle.js" +} from "./registration-repository-load.drizzle.js" import type { HostRegistrationRepository, PageRecord, diff --git a/services/api/src/services/host/registration-questions.drizzle.ts b/services/api/src/services/host/registration-repository-questions.drizzle.ts similarity index 98% rename from services/api/src/services/host/registration-questions.drizzle.ts rename to services/api/src/services/host/registration-repository-questions.drizzle.ts index 7a7ef251..2b80e991 100644 --- a/services/api/src/services/host/registration-questions.drizzle.ts +++ b/services/api/src/services/host/registration-repository-questions.drizzle.ts @@ -6,7 +6,7 @@ import { type AnswerRowSelect, type QuestionRowSelect, } from "./registration-sql.js" -import { loadLiveQuestions } from "./registration-load.drizzle.js" +import { loadLiveQuestions } from "./registration-repository-load.drizzle.js" import type { AnswerRecord, DesiredQuestion, diff --git a/services/api/src/services/host/registration-register.drizzle.ts b/services/api/src/services/host/registration-repository-register.drizzle.ts similarity index 99% rename from services/api/src/services/host/registration-register.drizzle.ts rename to services/api/src/services/host/registration-repository-register.drizzle.ts index 2ff58ba0..ec5740d2 100644 --- a/services/api/src/services/host/registration-register.drizzle.ts +++ b/services/api/src/services/host/registration-repository-register.drizzle.ts @@ -6,7 +6,11 @@ import { deterministicUuid } from "../deterministic-uuid.js" import { isEventPubliclyVisible } from "./authz.js" import { isUniqueViolationOn } from "../../db/pg-errors.js" import { isReservedSeatsBackstopViolation } from "./registration-sql.js" -import { isBannedIn, loadRegistrationById, subjectIs } from "./registration-load.drizzle.js" +import { + isBannedIn, + loadRegistrationById, + subjectIs, +} from "./registration-repository-load.drizzle.js" import type { EventRegistrationContext, HostRegistrationRepository, diff --git a/services/api/src/services/host/registration-roster.drizzle.ts b/services/api/src/services/host/registration-repository-roster.drizzle.ts similarity index 99% rename from services/api/src/services/host/registration-roster.drizzle.ts rename to services/api/src/services/host/registration-repository-roster.drizzle.ts index c506d3c2..3cc5ed89 100644 --- a/services/api/src/services/host/registration-roster.drizzle.ts +++ b/services/api/src/services/host/registration-repository-roster.drizzle.ts @@ -15,12 +15,12 @@ import { registrationJoins, type RegistrationRowSelect, } from "./registration-sql.js" -import { hydrate, loadRegistrationById } from "./registration-load.drizzle.js" +import { hydrate, loadRegistrationById } from "./registration-repository-load.drizzle.js" import { cancelWaitlistEntriesIn, listWaitlistIn, waitlistEntryAsRegistration, -} from "./registration-waitlist.drizzle.js" +} from "./registration-repository-waitlist.drizzle.js" import type { CancelRegistrationOutcome, HostRegistrationRepository, diff --git a/services/api/src/services/host/registration-ticket-types.drizzle.ts b/services/api/src/services/host/registration-repository-ticket-types.drizzle.ts similarity index 99% rename from services/api/src/services/host/registration-ticket-types.drizzle.ts rename to services/api/src/services/host/registration-repository-ticket-types.drizzle.ts index e76ac94b..dbaf3067 100644 --- a/services/api/src/services/host/registration-ticket-types.drizzle.ts +++ b/services/api/src/services/host/registration-repository-ticket-types.drizzle.ts @@ -2,7 +2,7 @@ import type { Queryable, Sql, TransactionSql } from "../../db/client.js" import { constantTimeStringEqual } from "../../auth/crypto.js" import { isCheckViolationOn, isUniqueViolationOn } from "../../db/pg-errors.js" import { SALES_WINDOW_CONSTRAINT } from "./registration-sql.js" -import { loadTicketType, loadTicketTypes } from "./registration-load.drizzle.js" +import { loadTicketType, loadTicketTypes } from "./registration-repository-load.drizzle.js" import type { CreateTicketTypeOutcome, DeleteTicketTypeOutcome, diff --git a/services/api/src/services/host/registration-waitlist.drizzle.ts b/services/api/src/services/host/registration-repository-waitlist.drizzle.ts similarity index 99% rename from services/api/src/services/host/registration-waitlist.drizzle.ts rename to services/api/src/services/host/registration-repository-waitlist.drizzle.ts index 8e18fe9f..c64af759 100644 --- a/services/api/src/services/host/registration-waitlist.drizzle.ts +++ b/services/api/src/services/host/registration-repository-waitlist.drizzle.ts @@ -20,8 +20,8 @@ import { loadRegistrationById, loadWaitlistEntry, subjectIs, -} from "./registration-load.drizzle.js" -import { registerErrorOutcome, registerIn } from "./registration-register.drizzle.js" +} from "./registration-repository-load.drizzle.js" +import { registerErrorOutcome, registerIn } from "./registration-repository-register.drizzle.js" import type { ClaimWaitlistOutcome, EventRegistrationContext, diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 99e26086..e2a96a48 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -1,20 +1,20 @@ import type { Sql } from "../../db/client.js" -import { eventContextIn } from "./registration-load.drizzle.js" -import { makeTicketTypeMethods } from "./registration-ticket-types.drizzle.js" -import { makeQuestionMethods } from "./registration-questions.drizzle.js" -import { makeRegisterMethods } from "./registration-register.drizzle.js" -import { makeRosterMethods } from "./registration-roster.drizzle.js" -import { makeWaitlistMethods } from "./registration-waitlist.drizzle.js" -import { makeCheckinMethods } from "./registration-checkin.drizzle.js" -import { makePageMethods } from "./registration-pages.drizzle.js" +import { eventContextIn } from "./registration-repository-load.drizzle.js" +import { makeTicketTypeMethods } from "./registration-repository-ticket-types.drizzle.js" +import { makeQuestionMethods } from "./registration-repository-questions.drizzle.js" +import { makeRegisterMethods } from "./registration-repository-register.drizzle.js" +import { makeRosterMethods } from "./registration-repository-roster.drizzle.js" +import { makeWaitlistMethods } from "./registration-repository-waitlist.drizzle.js" +import { makeCheckinMethods } from "./registration-repository-checkin.drizzle.js" +import { makePageMethods } from "./registration-repository-pages.drizzle.js" import type { EventRegistrationContext, HostRegistrationRepository, } from "./registration-repository.types.js" -export { REGISTER_IDEMPOTENCY_SCOPE } from "./registration-register.drizzle.js" -export { applyBanIn } from "./registration-roster.drizzle.js" -export { cancelWaitlistEntriesIn } from "./registration-waitlist.drizzle.js" +export { REGISTER_IDEMPOTENCY_SCOPE } from "./registration-repository-register.drizzle.js" +export { applyBanIn } from "./registration-repository-roster.drizzle.js" +export { cancelWaitlistEntriesIn } from "./registration-repository-waitlist.drizzle.js" export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistrationRepository { return { diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index bb8009ae..18ef01ba 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -17,15 +17,15 @@ import { ARRIVAL_BUCKET_MINUTES, buildCheckinResult, emptyCheckinResult, -} from "./registration-checkin.drizzle.js" +} from "./registration-repository-checkin.drizzle.js" import { guestSelfRegistrationOnPrivateEvent, subjectOwner, withinSalesWindow, -} from "./registration-register.drizzle.js" -import type { AppliedBan } from "./registration-roster.drizzle.js" -import { MAX_TICKET_TYPES } from "./registration-ticket-types.drizzle.js" -import { waitlistEntryAsRegistration } from "./registration-waitlist.drizzle.js" +} from "./registration-repository-register.drizzle.js" +import type { AppliedBan } from "./registration-repository-roster.drizzle.js" +import { MAX_TICKET_TYPES } from "./registration-repository-ticket-types.drizzle.js" +import { waitlistEntryAsRegistration } from "./registration-repository-waitlist.drizzle.js" import type { AnswerRecord, CancelRegistrationOutcome, diff --git a/services/api/src/services/message-attachments-repository.drizzle.ts b/services/api/src/services/message-attachments-repository.drizzle.ts index 6f8ea88e..d3262255 100644 --- a/services/api/src/services/message-attachments-repository.drizzle.ts +++ b/services/api/src/services/message-attachments-repository.drizzle.ts @@ -12,7 +12,7 @@ export type MessageMediaColumn = "chat_message_id" const CLAIM_GUARD_COLUMNS: readonly string[] = ["report_id", "post_id"] -export interface MessageAttachmentRepo { +export interface MessageAttachmentRepository { attach( tx: Queryable, messageId: string, @@ -34,7 +34,9 @@ interface MediaRow { height: number | null } -export function makeAttachmentRepo(column: MessageMediaColumn): MessageAttachmentRepo { +export function makeMessageAttachmentRepository( + column: MessageMediaColumn, +): MessageAttachmentRepository { return { async attach(tx, messageId, uploadIds, messageCreatedAt, senderId) { if (uploadIds.length === 0) return diff --git a/services/api/src/services/message-mentions-repository.drizzle.ts b/services/api/src/services/message-mentions-repository.drizzle.ts index 81b3b453..16c8a050 100644 --- a/services/api/src/services/message-mentions-repository.drizzle.ts +++ b/services/api/src/services/message-mentions-repository.drizzle.ts @@ -7,18 +7,18 @@ export type MentionTable = "chat_message_mentions" | "post_mentions" export type MentionIdColumn = "message_id" | "post_id" -export interface MessageMentionRepo { +export interface MessageMentionRepository { // `mentionedUserIds` must already be deduped and self-excluded. Pass the create/edit tx so the // delete-then-insert replace is atomic with the message write. recordFor(tx: Queryable, messageId: string, mentionedUserIds: string[]): Promise loadFor(messageIds: string[]): Promise> } -export function makeMentionRepo( +export function makeMessageMentionRepository( sql: Sql, table: MentionTable, idColumn: MentionIdColumn = "message_id", -): MessageMentionRepo { +): MessageMentionRepository { return { async recordFor(tx, messageId, mentionedUserIds) { await tx`DELETE FROM ${tx(table)} WHERE ${tx(idColumn)} = ${messageId}` diff --git a/services/api/src/services/message-reactions-repository.drizzle.ts b/services/api/src/services/message-reactions-repository.drizzle.ts index ba928aa6..14eedfb6 100644 --- a/services/api/src/services/message-reactions-repository.drizzle.ts +++ b/services/api/src/services/message-reactions-repository.drizzle.ts @@ -5,7 +5,7 @@ import type { ReactionEmoji, ReactionSummaryDTO } from "@civfix/shared" // Every room kind and DMs share the one chat table because message ids are globally unique uuids. export type ReactionTable = "chat_message_reactions" -export interface MessageReactionRepo { +export interface MessageReactionRepository { toggle(messageId: string, userId: string, emoji: ReactionEmoji): Promise loadFor( messageIds: string[], @@ -13,7 +13,10 @@ export interface MessageReactionRepo { ): Promise> } -export function makeReactionRepo(sql: Sql, table: ReactionTable): MessageReactionRepo { +export function makeMessageReactionRepository( + sql: Sql, + table: ReactionTable, +): MessageReactionRepository { return { toggle(messageId, userId, emoji) { // One tx so a concurrent double-toggle can't interleave a delete and an insert out of order. diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index d982e268..5b1ffd2b 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -17,7 +17,10 @@ import { paginateKeyset, parseKeysetCursor, } from "../db/cursor-helpers.js" -import { loadMentionsFor, makeMentionRepo } from "./message-mentions-repository.drizzle.js" +import { + loadMentionsFor, + makeMessageMentionRepository, +} from "./message-mentions-repository.drizzle.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" import { claimableAsAttachment } from "./media-bindings.js" import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" @@ -1153,7 +1156,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep } if (args.mentionedUserIds.length > 0) { - await makeMentionRepo(sql, "post_mentions", "post_id").recordFor( + await makeMessageMentionRepository(sql, "post_mentions", "post_id").recordFor( tx, postId, args.mentionedUserIds, diff --git a/services/api/test/integration/media-attach-claim-security-pg.test.ts b/services/api/test/integration/media-attach-claim-security-pg.test.ts index d1111a7e..5f2c0e15 100644 --- a/services/api/test/integration/media-attach-claim-security-pg.test.ts +++ b/services/api/test/integration/media-attach-claim-security-pg.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" -import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { attachChatMedia } from "../../src/services/chat-attachments-repository.drizzle.js" import { userUploader } from "../../src/services/media-uploader.js" import { makeDrizzlePostRepository, diff --git a/services/api/test/unit/chat-attachments-servable.test.ts b/services/api/test/unit/chat-attachments-servable.test.ts index 882e45a1..d144c983 100644 --- a/services/api/test/unit/chat-attachments-servable.test.ts +++ b/services/api/test/unit/chat-attachments-servable.test.ts @@ -17,7 +17,7 @@ import { evalSqlPredicate } from "../helpers/sql-predicate.js" import type { Queryable } from "../../src/db/client.js" import { loadServableAttachmentsFor, - makeAttachmentRepo, + makeMessageAttachmentRepository, } from "../../src/services/message-attachments-repository.drizzle.js" const MESSAGE = "11111111-1111-4111-8111-111111111111" @@ -194,11 +194,11 @@ describe("loadServableAttachmentsFor", () => { }) }) -describe("makeAttachmentRepo().attach", () => { +describe("makeMessageAttachmentRepository().attach", () => { it("still claims a finalized validating asset: the read now matches what the claim accepts", async () => { const fake = makeFakeSql([{ match: /UPDATE media_assets/, rows: [{ upload_id: UPLOAD }] }]) - await makeAttachmentRepo("chat_message_id").attach( + await makeMessageAttachmentRepository("chat_message_id").attach( fake.sql as unknown as Queryable, MESSAGE, [UPLOAD], @@ -215,7 +215,7 @@ describe("makeAttachmentRepo().attach", () => { it("does nothing when no upload ids were sent", async () => { const fake = makeFakeSql([]) - await makeAttachmentRepo("chat_message_id").attach( + await makeMessageAttachmentRepository("chat_message_id").attach( fake.sql as unknown as Queryable, MESSAGE, [], diff --git a/services/api/test/unit/host/announcement-send-failure.test.ts b/services/api/test/unit/host/announcement-send-failure.test.ts index d4c3b301..caeb088b 100644 --- a/services/api/test/unit/host/announcement-send-failure.test.ts +++ b/services/api/test/unit/host/announcement-send-failure.test.ts @@ -7,7 +7,7 @@ import { type BroadcastConfig, } from "../../../src/services/host/broadcast-service.js" import { makeAnnouncementService } from "../../../src/services/host/announcement-service.js" -import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-repository.drizzle.js" +import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-identity-repository.drizzle.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" const HOST = "00000000-0000-0000-0000-0000000000aa" diff --git a/services/api/test/unit/host/announcement-service.test.ts b/services/api/test/unit/host/announcement-service.test.ts index 229cc47a..4aaf1f0a 100644 --- a/services/api/test/unit/host/announcement-service.test.ts +++ b/services/api/test/unit/host/announcement-service.test.ts @@ -19,7 +19,7 @@ import { makeAnnouncementService, type AnnouncementService, } from "../../../src/services/host/announcement-service.js" -import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-repository.drizzle.js" +import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-identity-repository.drizzle.js" import type { EventBroadcastContext } from "../../../src/services/host/broadcast-types.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" diff --git a/services/api/test/unit/media-attach-claim-security.test.ts b/services/api/test/unit/media-attach-claim-security.test.ts index 39456e25..cb2116f6 100644 --- a/services/api/test/unit/media-attach-claim-security.test.ts +++ b/services/api/test/unit/media-attach-claim-security.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import type { Queryable, Sql } from "../../src/db/client.js" -import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { attachChatMedia } from "../../src/services/chat-attachments-repository.drizzle.js" import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" import { makeDrizzlePostRepository, diff --git a/services/api/test/unit/media-claim-uploader.test.ts b/services/api/test/unit/media-claim-uploader.test.ts index e555ae02..358f124c 100644 --- a/services/api/test/unit/media-claim-uploader.test.ts +++ b/services/api/test/unit/media-claim-uploader.test.ts @@ -5,7 +5,7 @@ import type { ReportDTO } from "@civfix/shared" import type { Queryable, Sql } from "../../src/db/client.js" import { makeDrizzleAnonReportRepository } from "../../src/services/anon-repository.drizzle.js" import type { CreateAnonReportTxArgs } from "../../src/services/anon-service.js" -import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { attachChatMedia } from "../../src/services/chat-attachments-repository.drizzle.js" import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" import { claimableAsReportMedia } from "../../src/services/media-bindings.js" import { avatarClaimQuery } from "../../src/services/media-claim-repository.drizzle.js" diff --git a/services/api/test/unit/media-public-served-key.test.ts b/services/api/test/unit/media-public-served-key.test.ts index 21786d0e..275b0af3 100644 --- a/services/api/test/unit/media-public-served-key.test.ts +++ b/services/api/test/unit/media-public-served-key.test.ts @@ -6,7 +6,7 @@ import type { Queryable, Sql } from "../../src/db/client.js" import { loadPrimaryAffiliations } from "../../src/services/affiliation.js" import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" -import { makeDrizzleAnnouncementIdentityRepository } from "../../src/services/host/announcement-repository.drizzle.js" +import { makeDrizzleAnnouncementIdentityRepository } from "../../src/services/host/announcement-identity-repository.drizzle.js" import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import { publicServedKeyExpr } from "../../src/services/media-served-key.js" From cf5e725e028fe4244577a1df0c1103ad6d61c22c Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:50:19 +0000 Subject: [PATCH 13/20] repository contracts live in -repository.ts --- services/api/src/routes/admin/mail.routes.ts | 2 +- services/api/src/routes/admin/reports.routes.ts | 2 +- services/api/src/routes/conversations.routes.ts | 2 +- services/api/src/routes/host/orgs.routes.ts | 2 +- services/api/src/routes/host/team.routes.ts | 2 +- services/api/src/routes/messages.routes.ts | 2 +- services/api/src/routes/report-chat.routes.ts | 2 +- services/api/src/services/admin/admin-report-types.ts | 2 +- services/api/src/services/admin/autoforward-jobs.ts | 2 +- .../services/admin/forward-template-repository.drizzle.ts | 2 +- .../services/admin/forward-template-repository.memory.ts | 2 +- ...ard-template-types.ts => forward-template-repository.ts} | 0 services/api/src/services/admin/forward-template-service.ts | 2 +- services/api/src/services/admin/home-repository.drizzle.ts | 2 +- services/api/src/services/admin/home-repository.memory.ts | 2 +- .../services/admin/{home-types.ts => home-repository.ts} | 0 services/api/src/services/admin/home-service.ts | 4 ++-- services/api/src/services/cleanup-dto.ts | 2 +- services/api/src/services/cleanup-map-repository.drizzle.ts | 2 +- services/api/src/services/cleanup-notifications.ts | 2 +- services/api/src/services/cleanup-repository.drizzle.ts | 2 +- .../{cleanup-repository.types.ts => cleanup-repository.ts} | 0 services/api/src/services/cleanup-service.ts | 4 ++-- services/api/src/services/cleanup-slots.ts | 2 +- services/api/src/services/cleanup-sql.ts | 2 +- services/api/src/services/discussion-repository.drizzle.ts | 2 +- .../{discussion-types.ts => discussion-repository.ts} | 0 services/api/src/services/host/checkin-service.ts | 2 +- .../api/src/services/host/host-team-repository.drizzle.ts | 4 ++-- .../api/src/services/host/host-team-repository.memory.ts | 6 +++--- ...ost-team-repository.types.ts => host-team-repository.ts} | 2 +- services/api/src/services/host/host-team-service.ts | 2 +- services/api/src/services/host/insights-service.ts | 2 +- .../services/host/organization-repository-admin.drizzle.ts | 2 +- .../host/organization-repository-invites.drizzle.ts | 4 ++-- .../host/organization-repository-members.drizzle.ts | 4 ++-- .../host/organization-repository-profile.drizzle.ts | 2 +- .../services/host/organization-repository-rows.drizzle.ts | 4 ++-- .../host/organization-repository-verification.drizzle.ts | 2 +- .../src/services/host/organization-repository.drizzle.ts | 2 +- .../api/src/services/host/organization-repository.memory.ts | 6 +++--- ...ation-repository.types.ts => organization-repository.ts} | 2 +- services/api/src/services/host/organization-service.ts | 2 +- services/api/src/services/host/page-service.ts | 2 +- services/api/src/services/host/question-service.ts | 5 +---- services/api/src/services/host/question-validation.ts | 2 +- services/api/src/services/host/registration-counts.ts | 2 +- services/api/src/services/host/registration-dto.ts | 2 +- .../host/registration-repository-checkin.drizzle.ts | 2 +- .../services/host/registration-repository-load.drizzle.ts | 2 +- .../services/host/registration-repository-pages.drizzle.ts | 2 +- .../host/registration-repository-questions.drizzle.ts | 2 +- .../host/registration-repository-register.drizzle.ts | 2 +- .../services/host/registration-repository-roster.drizzle.ts | 2 +- .../host/registration-repository-ticket-types.drizzle.ts | 2 +- .../host/registration-repository-waitlist.drizzle.ts | 2 +- .../src/services/host/registration-repository.drizzle.ts | 2 +- .../api/src/services/host/registration-repository.memory.ts | 2 +- ...ation-repository.types.ts => registration-repository.ts} | 0 services/api/src/services/host/registration-service.ts | 2 +- services/api/src/services/host/registration-sql.ts | 2 +- services/api/src/services/host/registration-wiring.ts | 2 +- services/api/src/services/host/ticket-type-service.ts | 2 +- services/api/src/services/host/waitlist-service.ts | 2 +- services/api/src/services/report-chat-repository.drizzle.ts | 2 +- services/api/src/services/report-city-forward-wiring.ts | 2 +- services/api/src/services/report-city-forward.ts | 2 +- services/api/src/services/report-clustering.ts | 2 +- services/api/src/services/report-repository.drizzle.ts | 2 +- services/api/src/services/report-service.ts | 4 ++-- services/api/src/services/report-sql.ts | 2 +- .../services/{report-service.types.ts => report-types.ts} | 0 services/api/src/services/report-visibility.ts | 4 ++-- services/api/test/helpers/discussion.ts | 2 +- .../api/test/integration/cleanup-derived-status-pg.test.ts | 2 +- services/api/test/integration/cleanup-slots-pg.test.ts | 2 +- services/api/test/integration/host-capacity-race-pg.test.ts | 2 +- .../test/integration/host-checkin-idempotency-pg.test.ts | 2 +- services/api/test/integration/host-correctness-pg.test.ts | 2 +- services/api/test/integration/host-lock-order-pg.test.ts | 2 +- .../test/integration/host-media-claim-uploader-pg.test.ts | 4 ++-- services/api/test/integration/host-orgs-team-pg.test.ts | 6 +++--- services/api/test/integration/host-page-media-pg.test.ts | 2 +- .../api/test/integration/host-waitlist-promotion-pg.test.ts | 2 +- .../integration/organization-repository-security-pg.test.ts | 2 +- .../integration/registration-repository-security-pg.test.ts | 2 +- services/api/test/integration/signup-seats-pg.test.ts | 4 ++-- services/api/test/unit/city-forward-logger-wiring.test.ts | 2 +- services/api/test/unit/city-forward-wiring.test.ts | 2 +- services/api/test/unit/cleanup-update-atomic.test.ts | 2 +- services/api/test/unit/host-insights.test.ts | 2 +- services/api/test/unit/host/checkin-service.test.ts | 2 +- .../api/test/unit/host/host-list-keyset-precision.test.ts | 2 +- .../api/test/unit/host/host-service-correctness.test.ts | 2 +- .../api/test/unit/host/insights-invalidation-wiring.test.ts | 2 +- services/api/test/unit/host/question-validation.test.ts | 2 +- .../unit/host/registration-repository-correctness.test.ts | 2 +- .../test/unit/host/registration-repository-security.test.ts | 2 +- services/api/test/unit/host/registration-state.test.ts | 2 +- .../api/test/unit/host/roster-checked-in-cursor.test.ts | 2 +- services/api/test/unit/host/signup-seats.test.ts | 2 +- services/api/test/unit/host/waitlist-service.test.ts | 2 +- services/api/test/unit/media-claim-actor.test.ts | 4 ++-- services/api/test/unit/media-claim-uploader.test.ts | 2 +- services/api/test/unit/report-chat.test.ts | 2 +- services/api/test/unit/report-forward-audit.test.ts | 2 +- services/api/test/unit/report-owner-resolve.test.ts | 2 +- services/api/test/unit/report-repository-security.test.ts | 2 +- 108 files changed, 119 insertions(+), 122 deletions(-) rename services/api/src/services/admin/{forward-template-types.ts => forward-template-repository.ts} (100%) rename services/api/src/services/admin/{home-types.ts => home-repository.ts} (100%) rename services/api/src/services/{cleanup-repository.types.ts => cleanup-repository.ts} (100%) rename services/api/src/services/{discussion-types.ts => discussion-repository.ts} (100%) rename services/api/src/services/host/{host-team-repository.types.ts => host-team-repository.ts} (98%) rename services/api/src/services/host/{organization-repository.types.ts => organization-repository.ts} (99%) rename services/api/src/services/host/{registration-repository.types.ts => registration-repository.ts} (100%) rename services/api/src/services/{report-service.types.ts => report-types.ts} (100%) diff --git a/services/api/src/routes/admin/mail.routes.ts b/services/api/src/routes/admin/mail.routes.ts index 6a48946b..68aac9c5 100644 --- a/services/api/src/routes/admin/mail.routes.ts +++ b/services/api/src/routes/admin/mail.routes.ts @@ -39,7 +39,7 @@ import { makeForwardTemplateService, type ForwardTemplateService, } from "../../services/admin/forward-template-service.js" -import type { ForwardTemplateRepository } from "../../services/admin/forward-template-types.js" +import type { ForwardTemplateRepository } from "../../services/admin/forward-template-repository.js" export const ADMIN_OUTBOUND_MAIL_RATE_LIMIT = perIdentity({ max: 20, diff --git a/services/api/src/routes/admin/reports.routes.ts b/services/api/src/routes/admin/reports.routes.ts index 857a1d30..78d0362e 100644 --- a/services/api/src/routes/admin/reports.routes.ts +++ b/services/api/src/routes/admin/reports.routes.ts @@ -31,7 +31,7 @@ import { } from "../../services/admin/admin-report-service.js" import { makeDrizzleAdminReportRepository } from "../../services/admin/admin-report-repository.drizzle.js" import { makeDrizzleForwardTemplateRepository } from "../../services/admin/forward-template-repository.drizzle.js" -import type { ForwardTemplateRepository } from "../../services/admin/forward-template-types.js" +import type { ForwardTemplateRepository } from "../../services/admin/forward-template-repository.js" import { makeContainerOutboundMailService, type OutboundMailService, diff --git a/services/api/src/routes/conversations.routes.ts b/services/api/src/routes/conversations.routes.ts index ff8568a9..f670aa42 100644 --- a/services/api/src/routes/conversations.routes.ts +++ b/services/api/src/routes/conversations.routes.ts @@ -32,7 +32,7 @@ import { type ChatGroupRepository, } from "../services/chat-group-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" -import type { DiscussionRepository } from "../services/discussion-types.js" +import type { DiscussionRepository } from "../services/discussion-repository.js" import { isReportVisibleTo } from "../services/report-visibility.js" import { conversationReadSeam } from "./chat-gateway-wiring.js" import type { MarkRoomRead } from "../services/room-read-service.js" diff --git a/services/api/src/routes/host/orgs.routes.ts b/services/api/src/routes/host/orgs.routes.ts index fccc7e65..5c0dbb08 100644 --- a/services/api/src/routes/host/orgs.routes.ts +++ b/services/api/src/routes/host/orgs.routes.ts @@ -49,7 +49,7 @@ import { import { makeContainerCleanupService } from "../cleanups.routes.js" import { CLEANUPS_DEFAULT_LIMIT } from "../../services/cleanup-service.js" import { makeDrizzleOrganizationRepository } from "../../services/host/organization-repository.drizzle.js" -import type { OrganizationRepository } from "../../services/host/organization-repository.types.js" +import type { OrganizationRepository } from "../../services/host/organization-repository.js" import { webBaseUrlOf } from "../../lib/base-url.js" const ONE_MINUTE = "1 minute" diff --git a/services/api/src/routes/host/team.routes.ts b/services/api/src/routes/host/team.routes.ts index 7a3ea47e..fbc36119 100644 --- a/services/api/src/routes/host/team.routes.ts +++ b/services/api/src/routes/host/team.routes.ts @@ -30,7 +30,7 @@ import { type HostTeamServiceDeps, } from "../../services/host/host-team-service.js" import { makeDrizzleHostTeamRepository } from "../../services/host/host-team-repository.drizzle.js" -import type { HostTeamRepository } from "../../services/host/host-team-repository.types.js" +import type { HostTeamRepository } from "../../services/host/host-team-repository.js" import { makeEventMediaPresigner } from "../../services/host/event-media.js" import { makeRouteNotificationService } from "../../services/route-notifier.js" import { makeRouteCleanupReader } from "../../services/route-cleanup-reader.js" diff --git a/services/api/src/routes/messages.routes.ts b/services/api/src/routes/messages.routes.ts index 560f8257..5e2cd1ed 100644 --- a/services/api/src/routes/messages.routes.ts +++ b/services/api/src/routes/messages.routes.ts @@ -35,7 +35,7 @@ import { } from "../services/chat-group-repository.drizzle.js" import { makeChatReactionService } from "../services/chat-reaction-service.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" -import type { DiscussionRepository } from "../services/discussion-types.js" +import type { DiscussionRepository } from "../services/discussion-repository.js" import { isReportVisibleTo } from "../services/report-visibility.js" import { makeDmPeerOf } from "../services/dm-peer.js" import { messageRoomMatches, REPORT_NOT_FOUND } from "./chat-route-helpers.js" diff --git a/services/api/src/routes/report-chat.routes.ts b/services/api/src/routes/report-chat.routes.ts index 4e0e07da..18f5c234 100644 --- a/services/api/src/routes/report-chat.routes.ts +++ b/services/api/src/routes/report-chat.routes.ts @@ -34,7 +34,7 @@ import { type ReportChatRepository, } from "../services/report-chat-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" -import type { DiscussionRepository } from "../services/discussion-types.js" +import type { DiscussionRepository } from "../services/discussion-repository.js" import { isReportVisibleTo } from "../services/report-visibility.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" import { withAffiliation } from "../services/affiliation.js" diff --git a/services/api/src/services/admin/admin-report-types.ts b/services/api/src/services/admin/admin-report-types.ts index d8f5eead..46fb205d 100644 --- a/services/api/src/services/admin/admin-report-types.ts +++ b/services/api/src/services/admin/admin-report-types.ts @@ -16,7 +16,7 @@ import type { NotificationService } from "../notification-service.js" import type { LinkedEventView } from "../cleanup-service.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" import type { AdminPersonRecord } from "./admin-person.js" -import type { ForwardTemplateReader } from "./forward-template-types.js" +import type { ForwardTemplateReader } from "./forward-template-repository.js" import type { PresignPacketMedia } from "../media-presign.js" export type AdminReporterRecord = AdminPersonRecord diff --git a/services/api/src/services/admin/autoforward-jobs.ts b/services/api/src/services/admin/autoforward-jobs.ts index e8b46a92..55f683c9 100644 --- a/services/api/src/services/admin/autoforward-jobs.ts +++ b/services/api/src/services/admin/autoforward-jobs.ts @@ -1,6 +1,6 @@ import { AppError, ErrorCode } from "@civfix/shared" import type { Container } from "../../di.js" -import type { ReportAutoForwardJob } from "../report-service.types.js" +import type { ReportAutoForwardJob } from "../report-types.js" import { REPORT_AUTOFORWARD_JOB } from "../../lib/queue-names.js" import { isAlreadyRoutedConflict, diff --git a/services/api/src/services/admin/forward-template-repository.drizzle.ts b/services/api/src/services/admin/forward-template-repository.drizzle.ts index c227a4e8..1415b01f 100644 --- a/services/api/src/services/admin/forward-template-repository.drizzle.ts +++ b/services/api/src/services/admin/forward-template-repository.drizzle.ts @@ -4,7 +4,7 @@ import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, SetForwardTemplateInput, -} from "./forward-template-types.js" +} from "./forward-template-repository.js" const FORWARD_TEMPLATE_AUDIT_TARGET = "mail:forward-template" diff --git a/services/api/src/services/admin/forward-template-repository.memory.ts b/services/api/src/services/admin/forward-template-repository.memory.ts index 87e7c33b..0ed3b919 100644 --- a/services/api/src/services/admin/forward-template-repository.memory.ts +++ b/services/api/src/services/admin/forward-template-repository.memory.ts @@ -2,7 +2,7 @@ import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, SetForwardTemplateInput, -} from "./forward-template-types.js" +} from "./forward-template-repository.js" export class InMemoryForwardTemplateRepository implements ForwardTemplateRepository { record: ForwardTemplateSettingsRecord | null = null diff --git a/services/api/src/services/admin/forward-template-types.ts b/services/api/src/services/admin/forward-template-repository.ts similarity index 100% rename from services/api/src/services/admin/forward-template-types.ts rename to services/api/src/services/admin/forward-template-repository.ts diff --git a/services/api/src/services/admin/forward-template-service.ts b/services/api/src/services/admin/forward-template-service.ts index 6d4c5c92..9af6af56 100644 --- a/services/api/src/services/admin/forward-template-service.ts +++ b/services/api/src/services/admin/forward-template-service.ts @@ -11,7 +11,7 @@ import type { AdminReportRecord, AdminReportRoutingRecord } from "./admin-report import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, -} from "./forward-template-types.js" +} from "./forward-template-repository.js" export interface ForwardTemplateServiceDeps { repo: ForwardTemplateRepository diff --git a/services/api/src/services/admin/home-repository.drizzle.ts b/services/api/src/services/admin/home-repository.drizzle.ts index ec798e2d..758f6b25 100644 --- a/services/api/src/services/admin/home-repository.drizzle.ts +++ b/services/api/src/services/admin/home-repository.drizzle.ts @@ -13,7 +13,7 @@ import type { MailSectionCounts, ReportsSectionCounts, UsersSectionCounts, -} from "./home-types.js" +} from "./home-repository.js" import type { EventKind, ReportCategory } from "@civfix/shared" import { adminEventStatusExpr } from "../cleanup-sql.js" diff --git a/services/api/src/services/admin/home-repository.memory.ts b/services/api/src/services/admin/home-repository.memory.ts index 59c13fdb..4d745391 100644 --- a/services/api/src/services/admin/home-repository.memory.ts +++ b/services/api/src/services/admin/home-repository.memory.ts @@ -6,7 +6,7 @@ import type { MailSectionCounts, ReportsSectionCounts, UsersSectionCounts, -} from "./home-types.js" +} from "./home-repository.js" export class InMemoryHomeRepository implements HomeRepository { discoveryValue: DiscoverySectionCounts = { queue: 0, reportsWaiting: 0, overSla: 0 } diff --git a/services/api/src/services/admin/home-types.ts b/services/api/src/services/admin/home-repository.ts similarity index 100% rename from services/api/src/services/admin/home-types.ts rename to services/api/src/services/admin/home-repository.ts diff --git a/services/api/src/services/admin/home-service.ts b/services/api/src/services/admin/home-service.ts index 56d8774b..bb4d16cb 100644 --- a/services/api/src/services/admin/home-service.ts +++ b/services/api/src/services/admin/home-service.ts @@ -17,10 +17,10 @@ import type { MailSectionCounts, ReportsSectionCounts, UsersSectionCounts, -} from "./home-types.js" +} from "./home-repository.js" import { mapWithLimit } from "../../lib/concurrency.js" -export * from "./home-types.js" +export * from "./home-repository.js" const ZERO_DISCOVERY: DiscoverySectionCounts = { queue: 0, reportsWaiting: 0, overSla: 0 } const ZERO_REPORTS: ReportsSectionCounts = { flagged: 0, inProgress: 0, completed: 0 } diff --git a/services/api/src/services/cleanup-dto.ts b/services/api/src/services/cleanup-dto.ts index af47b4e5..a62cd05a 100644 --- a/services/api/src/services/cleanup-dto.ts +++ b/services/api/src/services/cleanup-dto.ts @@ -18,7 +18,7 @@ import type { EventSlotView, LinkedEventView, LinkedReportView, -} from "./cleanup-repository.types.js" +} from "./cleanup-repository.js" export const CLEANUPS_DEFAULT_LIMIT = 20 diff --git a/services/api/src/services/cleanup-map-repository.drizzle.ts b/services/api/src/services/cleanup-map-repository.drizzle.ts index 14723b13..3ed2df9d 100644 --- a/services/api/src/services/cleanup-map-repository.drizzle.ts +++ b/services/api/src/services/cleanup-map-repository.drizzle.ts @@ -1,6 +1,6 @@ import type { CleanupPinDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" -import type { CleanupBBox } from "./cleanup-repository.types.js" +import type { CleanupBBox } from "./cleanup-repository.js" import { buildBboxFilter, buildVisibilityFilter, diff --git a/services/api/src/services/cleanup-notifications.ts b/services/api/src/services/cleanup-notifications.ts index b27d4531..35a44377 100644 --- a/services/api/src/services/cleanup-notifications.ts +++ b/services/api/src/services/cleanup-notifications.ts @@ -3,7 +3,7 @@ import type { MessageKey } from "../i18n/messages/en.js" import type { NotificationService } from "./notification-service.js" import { mapWithLimit } from "../lib/concurrency.js" import { MS_PER_HOUR } from "../lib/time.js" -import type { CleanupRepository, SlotReconcileResult } from "./cleanup-repository.types.js" +import type { CleanupRepository, SlotReconcileResult } from "./cleanup-repository.js" import type { GuestUpdateFanoutJob } from "./guest-rsvp-service.js" import { CLEANUP_CANCEL_FANOUT_JOB, CLEANUP_GUEST_UPDATE_FANOUT_JOB } from "../lib/queue-names.js" diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index 57eea789..d2e062a2 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -56,7 +56,7 @@ import type { SignupSeat, SlotReconcileResult, UpdateCleanupPatch, -} from "./cleanup-repository.types.js" +} from "./cleanup-repository.js" import { eventWindowOfRow, hasEventEnded } from "./cleanup-rules.js" import { hiddenIdentity } from "./hidden-identity.js" import { blockedPairExpr } from "./blocks-sql.js" diff --git a/services/api/src/services/cleanup-repository.types.ts b/services/api/src/services/cleanup-repository.ts similarity index 100% rename from services/api/src/services/cleanup-repository.types.ts rename to services/api/src/services/cleanup-repository.ts diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index 1f72e93a..a282cb23 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -60,7 +60,7 @@ import type { SignupSeat, SlotReconcileResult, UpdateCleanupPatch, -} from "./cleanup-repository.types.js" +} from "./cleanup-repository.js" import { assertMayGrantRole, hasHostStanding, @@ -92,7 +92,7 @@ import { eventAddressPatch, resolveEventAddress } from "./cleanup-address.js" import { assertKnownSlotIds, assertTimedSlotsFitWindow, toDesiredSlots } from "./cleanup-slots.js" import { makeCleanupNotifications, type CleanupCancelFanoutJob } from "./cleanup-notifications.js" -export * from "./cleanup-repository.types.js" +export * from "./cleanup-repository.js" export { CANCEL_FANOUT_MEMBER_CAP } from "./cleanup-notifications.js" export { CLEANUPS_DEFAULT_LIMIT, diff --git a/services/api/src/services/cleanup-slots.ts b/services/api/src/services/cleanup-slots.ts index 4a792df0..cdf65909 100644 --- a/services/api/src/services/cleanup-slots.ts +++ b/services/api/src/services/cleanup-slots.ts @@ -1,7 +1,7 @@ import { AppError, MAX_EVENT_SLOTS, MIN_SLOT_DURATION_MINUTES } from "@civfix/shared" import type { EventSlotInput } from "@civfix/shared" import { assertNoSlur } from "../abuse/slur-filter.js" -import type { DesiredSlot, EventSlotView } from "./cleanup-repository.types.js" +import type { DesiredSlot, EventSlotView } from "./cleanup-repository.js" import type { EventWindow } from "./cleanup-rules.js" import { MS_PER_MINUTE } from "../lib/time.js" diff --git a/services/api/src/services/cleanup-sql.ts b/services/api/src/services/cleanup-sql.ts index 5b1343b5..ff559c6b 100644 --- a/services/api/src/services/cleanup-sql.ts +++ b/services/api/src/services/cleanup-sql.ts @@ -5,7 +5,7 @@ import type { CleanupPersonView, CleanupRecord, NearPoint, -} from "./cleanup-repository.types.js" +} from "./cleanup-repository.js" import type { CleanupMemberRole, CleanupStatus, diff --git a/services/api/src/services/discussion-repository.drizzle.ts b/services/api/src/services/discussion-repository.drizzle.ts index 4a712920..83e02b56 100644 --- a/services/api/src/services/discussion-repository.drizzle.ts +++ b/services/api/src/services/discussion-repository.drizzle.ts @@ -7,7 +7,7 @@ import type { DiscussionReportView, DiscussionRepository, ReportJurisdictionView, -} from "./discussion-types.js" +} from "./discussion-repository.js" import type { ReportCategory } from "@civfix/shared" export function makeDrizzleDiscussionRepository(sql: Sql): DiscussionRepository { diff --git a/services/api/src/services/discussion-types.ts b/services/api/src/services/discussion-repository.ts similarity index 100% rename from services/api/src/services/discussion-types.ts rename to services/api/src/services/discussion-repository.ts diff --git a/services/api/src/services/host/checkin-service.ts b/services/api/src/services/host/checkin-service.ts index a9c4d5ef..6417fea7 100644 --- a/services/api/src/services/host/checkin-service.ts +++ b/services/api/src/services/host/checkin-service.ts @@ -20,7 +20,7 @@ import type { CheckinResultRecord, HostRegistrationRepository, RegistrationRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" import type { RegistrationAudit, RegistrationService } from "./registration-service.js" import { normalizeTicketToken, type TicketTokenSigner } from "./ticket-token.js" diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 9c6058b0..6e0ae43e 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -31,8 +31,8 @@ import type { OpenTeamInviteQuery, PendingInviteForUserRecord, RevokeTeamInviteOutcome, -} from "./host-team-repository.types.js" -import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" +} from "./host-team-repository.js" +import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.js" import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-repository-rows.drizzle.js" const TEAM_INVITE_PENDING_CONSTRAINTS = [ diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index 9b2fd7bd..4b29a50b 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -14,7 +14,7 @@ import { parseTimeCursor, } from "../../db/cursor-helpers.js" import { eventWindowOf, hasEventEnded } from "../cleanup-rules.js" -import type { CleanupPersonView } from "../cleanup-repository.types.js" +import type { CleanupPersonView } from "../cleanup-repository.js" import type { AcceptTeamInviteByIdOutcome, AcceptTeamInviteOutcome, @@ -29,8 +29,8 @@ import type { OpenTeamInviteQuery, PendingInviteForUserRecord, RevokeTeamInviteOutcome, -} from "./host-team-repository.types.js" -import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" +} from "./host-team-repository.js" +import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.js" import { MS_PER_DAY } from "../../lib/time.js" interface StoredInvite { diff --git a/services/api/src/services/host/host-team-repository.types.ts b/services/api/src/services/host/host-team-repository.ts similarity index 98% rename from services/api/src/services/host/host-team-repository.types.ts rename to services/api/src/services/host/host-team-repository.ts index 512de232..ab2f2f9b 100644 --- a/services/api/src/services/host/host-team-repository.types.ts +++ b/services/api/src/services/host/host-team-repository.ts @@ -5,7 +5,7 @@ import type { EventTeamRole, EventVisibility, } from "@civfix/shared" -import type { CleanupPersonView } from "../cleanup-repository.types.js" +import type { CleanupPersonView } from "../cleanup-repository.js" export const TEAM_INVITE_CAP_MESSAGE = "This event already has the maximum number of open invitations." diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index e8aad9e1..d7557bb7 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -37,7 +37,7 @@ import type { EventTeamMemberRecord, HostTeamRepository, PendingInviteForUserRecord, -} from "./host-team-repository.types.js" +} from "./host-team-repository.js" export const TEAM_INVITES_PER_EVENT_PER_DAY = 30 const TEAM_INVITE_WINDOW_SEC = SECONDS_PER_DAY diff --git a/services/api/src/services/host/insights-service.ts b/services/api/src/services/host/insights-service.ts index edd9f19c..07bdbfdd 100644 --- a/services/api/src/services/host/insights-service.ts +++ b/services/api/src/services/host/insights-service.ts @@ -31,7 +31,7 @@ import { leaderboardEntryOf } from "../volunteer-hours-service.js" import type { CheckinCountersRecord, HostRegistrationRepository, -} from "./registration-repository.types.js" +} from "./registration-repository.js" import { CHECKIN_COARSEN_DAYS } from "./registration-retention.js" import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" import { PORTFOLIO_EVENT_LIMIT, clockPhase } from "./host-analytics-shaping.js" diff --git a/services/api/src/services/host/organization-repository-admin.drizzle.ts b/services/api/src/services/host/organization-repository-admin.drizzle.ts index 4a9250cb..7c3b2866 100644 --- a/services/api/src/services/host/organization-repository-admin.drizzle.ts +++ b/services/api/src/services/host/organization-repository-admin.drizzle.ts @@ -21,7 +21,7 @@ import type { OrganizationRepository, SetOrganizationSuspendedArgs, SetOrganizationSuspendedOutcome, -} from "./organization-repository.types.js" +} from "./organization-repository.js" interface AdminOrganizationRowSelect extends OrganizationRowSelect { owner_id: string | null diff --git a/services/api/src/services/host/organization-repository-invites.drizzle.ts b/services/api/src/services/host/organization-repository-invites.drizzle.ts index d007c743..f5b66ff0 100644 --- a/services/api/src/services/host/organization-repository-invites.drizzle.ts +++ b/services/api/src/services/host/organization-repository-invites.drizzle.ts @@ -22,8 +22,8 @@ import type { OrganizationRepository, PendingOrganizationInviteRecord, RevokeOrganizationInviteOutcome, -} from "./organization-repository.types.js" -import { inviterRevocationReason, ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" +} from "./organization-repository.js" +import { inviterRevocationReason, ORG_INVITE_CAP_MESSAGE } from "./organization-repository.js" type InviteLookup = { tokenHash: string } | { inviteId: string } diff --git a/services/api/src/services/host/organization-repository-members.drizzle.ts b/services/api/src/services/host/organization-repository-members.drizzle.ts index 78316b55..e2af0b34 100644 --- a/services/api/src/services/host/organization-repository-members.drizzle.ts +++ b/services/api/src/services/host/organization-repository-members.drizzle.ts @@ -22,8 +22,8 @@ import type { OrgMemberIdentifier, RemoveOrganizationMemberOutcome, SetOrganizationMemberRoleOutcome, -} from "./organization-repository.types.js" -import { canManageOrgMembers, roleChangeWithdrawsInvites } from "./organization-repository.types.js" +} from "./organization-repository.js" +import { canManageOrgMembers, roleChangeWithdrawsInvites } from "./organization-repository.js" interface MemberRowSelect { user_id: string diff --git a/services/api/src/services/host/organization-repository-profile.drizzle.ts b/services/api/src/services/host/organization-repository-profile.drizzle.ts index b3fb7912..8e07d187 100644 --- a/services/api/src/services/host/organization-repository-profile.drizzle.ts +++ b/services/api/src/services/host/organization-repository-profile.drizzle.ts @@ -16,7 +16,7 @@ import type { UpdateOrganizationAudit, UpdateOrganizationOutcome, UpdateOrganizationPatch, -} from "./organization-repository.types.js" +} from "./organization-repository.js" import { isUniqueViolationOn } from "../../db/pg-errors.js" /** The citext unique index on organizations.slug (0105). */ diff --git a/services/api/src/services/host/organization-repository-rows.drizzle.ts b/services/api/src/services/host/organization-repository-rows.drizzle.ts index 525688ea..78ea3381 100644 --- a/services/api/src/services/host/organization-repository-rows.drizzle.ts +++ b/services/api/src/services/host/organization-repository-rows.drizzle.ts @@ -5,14 +5,14 @@ import type { SocialLinks, } from "@civfix/shared" import type { Queryable } from "../../db/client.js" -import type { CleanupPersonView } from "../cleanup-repository.types.js" +import type { CleanupPersonView } from "../cleanup-repository.js" import { publicServedKeyExpr } from "../media-served-key.js" import type { AdminActorView, OrganizationBaseRecord, OrganizationRecord, OrgHoursTotals, -} from "./organization-repository.types.js" +} from "./organization-repository.js" export const UNKNOWN_PERSON_NAME = "Unknown" diff --git a/services/api/src/services/host/organization-repository-verification.drizzle.ts b/services/api/src/services/host/organization-repository-verification.drizzle.ts index 8dd919c3..1f8544aa 100644 --- a/services/api/src/services/host/organization-repository-verification.drizzle.ts +++ b/services/api/src/services/host/organization-repository-verification.drizzle.ts @@ -14,7 +14,7 @@ import type { DecideOrgVerificationOutcome, OrganizationRepository, OrgVerificationRecord, -} from "./organization-repository.types.js" +} from "./organization-repository.js" type VerificationDocuments = { mediaId?: string }[] | null | undefined diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 5e63d778..77c1a9af 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -3,7 +3,7 @@ import { makeOrganizationAdminMethods } from "./organization-repository-admin.dr import { makeOrganizationInviteMethods } from "./organization-repository-invites.drizzle.js" import { makeOrganizationMemberMethods } from "./organization-repository-members.drizzle.js" import { makeOrganizationProfileMethods } from "./organization-repository-profile.drizzle.js" -import type { OrganizationRepository } from "./organization-repository.types.js" +import type { OrganizationRepository } from "./organization-repository.js" import { makeOrganizationVerificationMethods } from "./organization-repository-verification.drizzle.js" export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationRepository { diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index 52865823..851f4be8 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -47,13 +47,13 @@ import type { UpdateOrganizationPatch, InviterRevocationReason, InviterStanding, -} from "./organization-repository.types.js" +} from "./organization-repository.js" import { canManageOrgMembers, inviterRevocationReason, roleChangeWithdrawsInvites, -} from "./organization-repository.types.js" -import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" +} from "./organization-repository.js" +import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.js" import { encodeTimeCursor } from "../../db/cursor-helpers.js" const SEED_DISPLAY_NAME = "Member" diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.ts similarity index 99% rename from services/api/src/services/host/organization-repository.types.ts rename to services/api/src/services/host/organization-repository.ts index d78a94fe..6eeea89f 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.ts @@ -7,7 +7,7 @@ import type { SocialLinks, } from "@civfix/shared" import { can } from "@civfix/shared/host" -import type { CleanupOrganizationView, CleanupPersonView } from "../cleanup-repository.types.js" +import type { CleanupOrganizationView, CleanupPersonView } from "../cleanup-repository.js" export type InviterRevocationReason = "inviter_removed" | "inviter_demoted" | "account_deleted" diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index 241fc763..534f2836 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -48,7 +48,7 @@ import type { OrganizationRepository, OrgVerificationRecord, UpdateOrganizationPatch, -} from "./organization-repository.types.js" +} from "./organization-repository.js" export const ORGS_CREATED_PER_DAY = 5 const ORG_CREATE_WINDOW_SEC = SECONDS_PER_DAY diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index d694a650..00b973cb 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -20,7 +20,7 @@ import { PRESIGN_CONCURRENCY } from "../media-presign.js" import { assertSlugAllowed, RESERVED_SLUGS } from "./slugs.js" import type { CounterStore } from "../../abuse/counter-store.js" import { toEventPageDTO, toEventQuestionDTO, toPublicTicketType } from "./registration-dto.js" -import type { HostRegistrationRepository, PageRecord } from "./registration-repository.types.js" +import type { HostRegistrationRepository, PageRecord } from "./registration-repository.js" import type { RegistrationAudit } from "./registration-service.js" const HOST_PAGE_PUBLISH_COUNTER_KEY = "host:pagePublish" diff --git a/services/api/src/services/host/question-service.ts b/services/api/src/services/host/question-service.ts index 1f1f358b..e31f9c83 100644 --- a/services/api/src/services/host/question-service.ts +++ b/services/api/src/services/host/question-service.ts @@ -7,10 +7,7 @@ import type { } from "@civfix/shared" import { assertNoSlur } from "../../abuse/slur-filter.js" import { toEventQuestionDTO } from "./registration-dto.js" -import type { - DesiredQuestion, - HostRegistrationRepository, -} from "./registration-repository.types.js" +import type { DesiredQuestion, HostRegistrationRepository } from "./registration-repository.js" export interface QuestionServiceDeps { repo: HostRegistrationRepository diff --git a/services/api/src/services/host/question-validation.ts b/services/api/src/services/host/question-validation.ts index 9953046e..a0d1a5b2 100644 --- a/services/api/src/services/host/question-validation.ts +++ b/services/api/src/services/host/question-validation.ts @@ -1,6 +1,6 @@ import { MAX_LONG_TEXT_ANSWER, MAX_QUESTION_OPTIONS, MAX_SHORT_TEXT_ANSWER } from "@civfix/shared" import type { EventAnswerInput } from "@civfix/shared" -import type { AnswerWrite, QuestionRecord } from "./registration-repository.types.js" +import type { AnswerWrite, QuestionRecord } from "./registration-repository.js" export type AnswerValidation = | { ok: true; writes: AnswerWrite[] } diff --git a/services/api/src/services/host/registration-counts.ts b/services/api/src/services/host/registration-counts.ts index 14840f98..922ada93 100644 --- a/services/api/src/services/host/registration-counts.ts +++ b/services/api/src/services/host/registration-counts.ts @@ -1,6 +1,6 @@ import type { Sql } from "../../db/client.js" import { makeDrizzleHostRegistrationRepository } from "./registration-repository.drizzle.js" -import type { HostedEventCounts } from "./registration-repository.types.js" +import type { HostedEventCounts } from "./registration-repository.js" export type { HostedEventCounts } diff --git a/services/api/src/services/host/registration-dto.ts b/services/api/src/services/host/registration-dto.ts index 5616ae75..0c730e3a 100644 --- a/services/api/src/services/host/registration-dto.ts +++ b/services/api/src/services/host/registration-dto.ts @@ -26,7 +26,7 @@ import type { SeatRecord, TicketTypeRecord, WaitlistRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" import { eventWindowOf, hasEventEnded } from "../cleanup-rules.js" export const REGISTRATION_ROSTER_DEFAULT_LIMIT = 25 diff --git a/services/api/src/services/host/registration-repository-checkin.drizzle.ts b/services/api/src/services/host/registration-repository-checkin.drizzle.ts index bcd67fbb..893e431b 100644 --- a/services/api/src/services/host/registration-repository-checkin.drizzle.ts +++ b/services/api/src/services/host/registration-repository-checkin.drizzle.ts @@ -10,7 +10,7 @@ import type { HostedEventCounts, RegistrationRecord, SeatRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" export const ARRIVAL_BUCKET_MINUTES = 15 diff --git a/services/api/src/services/host/registration-repository-load.drizzle.ts b/services/api/src/services/host/registration-repository-load.drizzle.ts index 0769318e..29256790 100644 --- a/services/api/src/services/host/registration-repository-load.drizzle.ts +++ b/services/api/src/services/host/registration-repository-load.drizzle.ts @@ -33,7 +33,7 @@ import type { SeatRecord, TicketTypeRecord, WaitlistRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" interface EventContextRow { id: string diff --git a/services/api/src/services/host/registration-repository-pages.drizzle.ts b/services/api/src/services/host/registration-repository-pages.drizzle.ts index 547ce3f7..3d33c0d9 100644 --- a/services/api/src/services/host/registration-repository-pages.drizzle.ts +++ b/services/api/src/services/host/registration-repository-pages.drizzle.ts @@ -17,7 +17,7 @@ import type { PublishPageOutcome, SavePageArgs, SavePageOutcome, -} from "./registration-repository.types.js" +} from "./registration-repository.js" const PAGE_SLUG_CONSTRAINT = "cleanups_page_slug_uidx" diff --git a/services/api/src/services/host/registration-repository-questions.drizzle.ts b/services/api/src/services/host/registration-repository-questions.drizzle.ts index 2b80e991..a3cc1580 100644 --- a/services/api/src/services/host/registration-repository-questions.drizzle.ts +++ b/services/api/src/services/host/registration-repository-questions.drizzle.ts @@ -12,7 +12,7 @@ import type { DesiredQuestion, HostRegistrationRepository, QuestionRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" export type QuestionMethods = Pick< HostRegistrationRepository, diff --git a/services/api/src/services/host/registration-repository-register.drizzle.ts b/services/api/src/services/host/registration-repository-register.drizzle.ts index ec5740d2..b20fa05b 100644 --- a/services/api/src/services/host/registration-repository-register.drizzle.ts +++ b/services/api/src/services/host/registration-repository-register.drizzle.ts @@ -21,7 +21,7 @@ import type { TicketTypeRecord, WalkupCheckIn, WalkupRegisterArgs, -} from "./registration-repository.types.js" +} from "./registration-repository.js" export const REGISTER_IDEMPOTENCY_SCOPE = "event.register" diff --git a/services/api/src/services/host/registration-repository-roster.drizzle.ts b/services/api/src/services/host/registration-repository-roster.drizzle.ts index 3cc5ed89..0ebd1cb3 100644 --- a/services/api/src/services/host/registration-repository-roster.drizzle.ts +++ b/services/api/src/services/host/registration-repository-roster.drizzle.ts @@ -30,7 +30,7 @@ import type { RosterPage, RosterQuery, TransferRegistrationOutcome, -} from "./registration-repository.types.js" +} from "./registration-repository.js" export type RosterMethods = Pick< HostRegistrationRepository, diff --git a/services/api/src/services/host/registration-repository-ticket-types.drizzle.ts b/services/api/src/services/host/registration-repository-ticket-types.drizzle.ts index dbaf3067..05d3feeb 100644 --- a/services/api/src/services/host/registration-repository-ticket-types.drizzle.ts +++ b/services/api/src/services/host/registration-repository-ticket-types.drizzle.ts @@ -12,7 +12,7 @@ import type { TicketTypeRecord, TicketTypeWriteArgs, UpdateTicketTypeOutcome, -} from "./registration-repository.types.js" +} from "./registration-repository.js" export const MAX_TICKET_TYPES = 20 diff --git a/services/api/src/services/host/registration-repository-waitlist.drizzle.ts b/services/api/src/services/host/registration-repository-waitlist.drizzle.ts index c64af759..e96c0f75 100644 --- a/services/api/src/services/host/registration-repository-waitlist.drizzle.ts +++ b/services/api/src/services/host/registration-repository-waitlist.drizzle.ts @@ -33,7 +33,7 @@ import type { TicketTypeRecord, WaitlistOffer, WaitlistRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" const DEFAULT_EVENT_DURATION_SEC = DEFAULT_EVENT_DURATION_MS / 1000 diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index e2a96a48..955bbc1c 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -10,7 +10,7 @@ import { makePageMethods } from "./registration-repository-pages.drizzle.js" import type { EventRegistrationContext, HostRegistrationRepository, -} from "./registration-repository.types.js" +} from "./registration-repository.js" export { REGISTER_IDEMPOTENCY_SCOPE } from "./registration-repository-register.drizzle.js" export { applyBanIn } from "./registration-repository-roster.drizzle.js" diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index 18ef01ba..33016fc2 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -61,7 +61,7 @@ import type { WalkupRegisterArgs, WaitlistOffer, WaitlistRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" interface MemoryRegistration extends RegistrationRecord { answers: AnswerRecord[] diff --git a/services/api/src/services/host/registration-repository.types.ts b/services/api/src/services/host/registration-repository.ts similarity index 100% rename from services/api/src/services/host/registration-repository.types.ts rename to services/api/src/services/host/registration-repository.ts diff --git a/services/api/src/services/host/registration-service.ts b/services/api/src/services/host/registration-service.ts index 5d1b2784..c852b9a0 100644 --- a/services/api/src/services/host/registration-service.ts +++ b/services/api/src/services/host/registration-service.ts @@ -41,7 +41,7 @@ import type { RegistrationRecord, RegistrationSubject, SeatDraft, -} from "./registration-repository.types.js" +} from "./registration-repository.js" const REGISTER_FLIP_COUNTER_KEY = "event:register" diff --git a/services/api/src/services/host/registration-sql.ts b/services/api/src/services/host/registration-sql.ts index 1cabbe0f..ebf4f10a 100644 --- a/services/api/src/services/host/registration-sql.ts +++ b/services/api/src/services/host/registration-sql.ts @@ -24,7 +24,7 @@ import type { SeatRecord, TicketTypeRecord, WaitlistRecord, -} from "./registration-repository.types.js" +} from "./registration-repository.js" import { isCheckViolationOn } from "../../db/pg-errors.js" const RESERVED_SEATS_BACKSTOP_CONSTRAINT = "cleanup_ticket_types_reserved_bounds" diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index e307692e..ced59e45 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -30,7 +30,7 @@ import { import { makeTicketTypeService, type TicketTypeService } from "./ticket-type-service.js" import { makeWaitlistService, type WaitlistService } from "./waitlist-service.js" import type { TicketTokenSigner } from "./ticket-token.js" -import type { HostRegistrationRepository } from "./registration-repository.types.js" +import type { HostRegistrationRepository } from "./registration-repository.js" export interface HostServiceLogger { warn(obj: unknown, msg?: string): void diff --git a/services/api/src/services/host/ticket-type-service.ts b/services/api/src/services/host/ticket-type-service.ts index d5759b26..cd23f6ec 100644 --- a/services/api/src/services/host/ticket-type-service.ts +++ b/services/api/src/services/host/ticket-type-service.ts @@ -15,7 +15,7 @@ import type { Jobs } from "@civfix/shared/interfaces" import type { CounterStore } from "../../abuse/counter-store.js" import { toTicketTypeDTO } from "./registration-dto.js" import type { InsightsInvalidator } from "./host-analytics-cache.js" -import type { HostRegistrationRepository } from "./registration-repository.types.js" +import type { HostRegistrationRepository } from "./registration-repository.js" import { enqueueWaitlistPromotion } from "./waitlist-promotion.js" import { reserveCounterBudget } from "./counter-budget.js" diff --git a/services/api/src/services/host/waitlist-service.ts b/services/api/src/services/host/waitlist-service.ts index dad59758..53c04fdf 100644 --- a/services/api/src/services/host/waitlist-service.ts +++ b/services/api/src/services/host/waitlist-service.ts @@ -20,7 +20,7 @@ import type { HostRegistrationRepository, RegistrationSubject, WaitlistOffer, -} from "./registration-repository.types.js" +} from "./registration-repository.js" import type { RegistrationAudit, RegistrationNotifier, diff --git a/services/api/src/services/report-chat-repository.drizzle.ts b/services/api/src/services/report-chat-repository.drizzle.ts index f5c41f18..c220890d 100644 --- a/services/api/src/services/report-chat-repository.drizzle.ts +++ b/services/api/src/services/report-chat-repository.drizzle.ts @@ -5,7 +5,7 @@ import { type ReportChatParticipantDTO, } from "@civfix/shared" import type { PresignMedia } from "./media-presign.js" -import type { ReportChatMeta } from "./report-service.types.js" +import type { ReportChatMeta } from "./report-types.js" import { monotonicReadWatermarkUpdate } from "./read-watermark-repository.drizzle.js" import { blockedPairExpr } from "./blocks-sql.js" import { toRoomMemberPerson, type RoomMemberIdentityRow } from "./room-member-person.js" diff --git a/services/api/src/services/report-city-forward-wiring.ts b/services/api/src/services/report-city-forward-wiring.ts index 3a40cb29..1e911ceb 100644 --- a/services/api/src/services/report-city-forward-wiring.ts +++ b/services/api/src/services/report-city-forward-wiring.ts @@ -7,7 +7,7 @@ import { type ReportForwardAuditRepository, } from "./report-forward-audit-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "./discussion-repository.drizzle.js" -import type { DiscussionRepository } from "./discussion-types.js" +import type { DiscussionRepository } from "./discussion-repository.js" import { forwardReportCityMention, makeCityForwardThrottle, diff --git a/services/api/src/services/report-city-forward.ts b/services/api/src/services/report-city-forward.ts index 0628c24b..05cfde7a 100644 --- a/services/api/src/services/report-city-forward.ts +++ b/services/api/src/services/report-city-forward.ts @@ -3,7 +3,7 @@ import { parseCityMention, effectiveJurisdictionHandle } from "./discussion-ment import { replySubject, type OutboundMailService } from "./admin/outbound-mail-service.js" import type { MailThreadRecord } from "./admin/mail-repository.drizzle.js" import type { ReportForwardAuditRepository } from "./report-forward-audit-repository.drizzle.js" -import type { ReportJurisdictionView } from "./discussion-types.js" +import type { ReportJurisdictionView } from "./discussion-repository.js" import type { CounterStore } from "../abuse/counter-store.js" export interface CityForwardContext { diff --git a/services/api/src/services/report-clustering.ts b/services/api/src/services/report-clustering.ts index ac4e2f99..1cfa39f0 100644 --- a/services/api/src/services/report-clustering.ts +++ b/services/api/src/services/report-clustering.ts @@ -1,6 +1,6 @@ import { latLngToCell } from "h3-js" import type { ReportCategory, ReportClusterDTO, ReportStatus, ReportType } from "@civfix/shared" -import { REPORT_H3_RESOLUTION, type ReportMapPoint } from "./report-service.types.js" +import { REPORT_H3_RESOLUTION, type ReportMapPoint } from "./report-types.js" // Bounds the payload and per-request work for a wide bbox; clustering then collapses them to far fewer pins. export const MAP_REPORTS_CANDIDATE_CAP = 2000 diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index e4beb7fc..fd0023b8 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -28,7 +28,7 @@ import type { ReportRepository, ReportTimelineView, ReportVisibilityTimelineKind, -} from "./report-service.types.js" +} from "./report-types.js" import { servedKeyExpr, servableMediaFilter } from "./media-served-key.js" import { claimableAsReportMedia } from "./media-bindings.js" import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index 1d7aba91..c16e93ce 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -55,9 +55,9 @@ import { type ReportService, type ReportServiceDeps, type ReportTimelineView, -} from "./report-service.types.js" +} from "./report-types.js" -export * from "./report-service.types.js" +export * from "./report-types.js" export * from "./report-clustering.js" const REPORT_NOT_FOUND = "Report not found" diff --git a/services/api/src/services/report-sql.ts b/services/api/src/services/report-sql.ts index edf1018f..7edc15f0 100644 --- a/services/api/src/services/report-sql.ts +++ b/services/api/src/services/report-sql.ts @@ -13,7 +13,7 @@ import type { ReportMediaView, ReportRecord, ReportTimelineView, -} from "./report-service.types.js" +} from "./report-types.js" export interface ReportRowSelect { id: string diff --git a/services/api/src/services/report-service.types.ts b/services/api/src/services/report-types.ts similarity index 100% rename from services/api/src/services/report-service.types.ts rename to services/api/src/services/report-types.ts diff --git a/services/api/src/services/report-visibility.ts b/services/api/src/services/report-visibility.ts index 0d562586..92cae007 100644 --- a/services/api/src/services/report-visibility.ts +++ b/services/api/src/services/report-visibility.ts @@ -1,6 +1,6 @@ import type { ReportStatus } from "@civfix/shared" -import type { DiscussionReportView } from "./discussion-types.js" -import type { OwnerToggleStatus } from "./report-service.types.js" +import type { DiscussionReportView } from "./discussion-repository.js" +import type { OwnerToggleStatus } from "./report-types.js" /** * The statuses at which a report is PUBLICLY readable. diff --git a/services/api/test/helpers/discussion.ts b/services/api/test/helpers/discussion.ts index fb95c81d..602e30fd 100644 --- a/services/api/test/helpers/discussion.ts +++ b/services/api/test/helpers/discussion.ts @@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto" import type { DiscussionReportView, DiscussionRepository, -} from "../../src/services/discussion-types.js" +} from "../../src/services/discussion-repository.js" /** A seeded jurisdiction (name/handle/contact resolved for a report). */ export interface SeededJurisdiction { diff --git a/services/api/test/integration/cleanup-derived-status-pg.test.ts b/services/api/test/integration/cleanup-derived-status-pg.test.ts index 36080c80..be201ffa 100644 --- a/services/api/test/integration/cleanup-derived-status-pg.test.ts +++ b/services/api/test/integration/cleanup-derived-status-pg.test.ts @@ -21,7 +21,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" -import type { CleanupRepository } from "../../src/services/cleanup-repository.types.js" +import type { CleanupRepository } from "../../src/services/cleanup-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/cleanup-slots-pg.test.ts b/services/api/test/integration/cleanup-slots-pg.test.ts index 8f78d357..9f459abf 100644 --- a/services/api/test/integration/cleanup-slots-pg.test.ts +++ b/services/api/test/integration/cleanup-slots-pg.test.ts @@ -7,7 +7,7 @@ import type { ClaimSlotOutcome, CleanupRepository, DesiredSlot, -} from "../../src/services/cleanup-repository.types.js" +} from "../../src/services/cleanup-repository.js" import type { CleanupStatus } from "@civfix/shared" const pg = await withPg() diff --git a/services/api/test/integration/host-capacity-race-pg.test.ts b/services/api/test/integration/host-capacity-race-pg.test.ts index a801cf65..ac6c0cc6 100644 --- a/services/api/test/integration/host-capacity-race-pg.test.ts +++ b/services/api/test/integration/host-capacity-race-pg.test.ts @@ -7,7 +7,7 @@ import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" import type { HostRegistrationRepository, SeatDraft, -} from "../../src/services/host/registration-repository.types.js" +} from "../../src/services/host/registration-repository.js" const pg = await withPg() const tokens = makeTicketTokenSigner("integration-capacity-race-secret-long-enough") diff --git a/services/api/test/integration/host-checkin-idempotency-pg.test.ts b/services/api/test/integration/host-checkin-idempotency-pg.test.ts index ffa182e6..a731433b 100644 --- a/services/api/test/integration/host-checkin-idempotency-pg.test.ts +++ b/services/api/test/integration/host-checkin-idempotency-pg.test.ts @@ -8,7 +8,7 @@ import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" import type { HostRegistrationRepository, SeatDraft, -} from "../../src/services/host/registration-repository.types.js" +} from "../../src/services/host/registration-repository.js" const pg = await withPg() const tokens = makeTicketTokenSigner("integration-checkin-secret-long-enough") diff --git a/services/api/test/integration/host-correctness-pg.test.ts b/services/api/test/integration/host-correctness-pg.test.ts index fe15c10c..c447936b 100644 --- a/services/api/test/integration/host-correctness-pg.test.ts +++ b/services/api/test/integration/host-correctness-pg.test.ts @@ -9,7 +9,7 @@ import type { HostRegistrationRepository, RegisterTxArgs, SeatDraft, -} from "../../src/services/host/registration-repository.types.js" +} from "../../src/services/host/registration-repository.js" const pg = await withPg() const tokens = makeTicketTokenSigner("integration-host-correctness-secret-long") diff --git a/services/api/test/integration/host-lock-order-pg.test.ts b/services/api/test/integration/host-lock-order-pg.test.ts index d4fd7549..12aebe0c 100644 --- a/services/api/test/integration/host-lock-order-pg.test.ts +++ b/services/api/test/integration/host-lock-order-pg.test.ts @@ -8,7 +8,7 @@ import { PgUserStore } from "../../src/auth/pg-stores.js" import type { HostRegistrationRepository, SeatDraft, -} from "../../src/services/host/registration-repository.types.js" +} from "../../src/services/host/registration-repository.js" const pg = await withPg() const tokens = makeTicketTokenSigner("integration-lock-order-secret-long-enough") diff --git a/services/api/test/integration/host-media-claim-uploader-pg.test.ts b/services/api/test/integration/host-media-claim-uploader-pg.test.ts index f205fc4d..ee1034cf 100644 --- a/services/api/test/integration/host-media-claim-uploader-pg.test.ts +++ b/services/api/test/integration/host-media-claim-uploader-pg.test.ts @@ -4,9 +4,9 @@ import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" -import type { CleanupRepository } from "../../src/services/cleanup-repository.types.js" +import type { CleanupRepository } from "../../src/services/cleanup-repository.js" import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" -import type { OrganizationRepository } from "../../src/services/host/organization-repository.types.js" +import type { OrganizationRepository } from "../../src/services/host/organization-repository.js" import { userUploader } from "../../src/services/media-uploader.js" const pg = await withPg() diff --git a/services/api/test/integration/host-orgs-team-pg.test.ts b/services/api/test/integration/host-orgs-team-pg.test.ts index 56ea7ef0..f55122c3 100644 --- a/services/api/test/integration/host-orgs-team-pg.test.ts +++ b/services/api/test/integration/host-orgs-team-pg.test.ts @@ -12,9 +12,9 @@ import { hostStandingOf, orgStandingOf, } from "../../src/services/host/host-standing-repository.drizzle.js" -import type { CleanupRepository } from "../../src/services/cleanup-repository.types.js" -import type { OrganizationRepository } from "../../src/services/host/organization-repository.types.js" -import type { HostTeamRepository } from "../../src/services/host/host-team-repository.types.js" +import type { CleanupRepository } from "../../src/services/cleanup-repository.js" +import type { OrganizationRepository } from "../../src/services/host/organization-repository.js" +import type { HostTeamRepository } from "../../src/services/host/host-team-repository.js" const pg = await withPg() const FUTURE = new Date(Date.now() + 7 * 86_400_000) diff --git a/services/api/test/integration/host-page-media-pg.test.ts b/services/api/test/integration/host-page-media-pg.test.ts index 4e967c06..53222f12 100644 --- a/services/api/test/integration/host-page-media-pg.test.ts +++ b/services/api/test/integration/host-page-media-pg.test.ts @@ -4,7 +4,7 @@ import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import type { EventPageBlock } from "@civfix/shared" -import type { HostRegistrationRepository } from "../../src/services/host/registration-repository.types.js" +import type { HostRegistrationRepository } from "../../src/services/host/registration-repository.js" import { userUploader } from "../../src/services/media-uploader.js" const pg = await withPg() diff --git a/services/api/test/integration/host-waitlist-promotion-pg.test.ts b/services/api/test/integration/host-waitlist-promotion-pg.test.ts index 011a4656..59b7aa49 100644 --- a/services/api/test/integration/host-waitlist-promotion-pg.test.ts +++ b/services/api/test/integration/host-waitlist-promotion-pg.test.ts @@ -7,7 +7,7 @@ import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" import type { HostRegistrationRepository, SeatDraft, -} from "../../src/services/host/registration-repository.types.js" +} from "../../src/services/host/registration-repository.js" const pg = await withPg() const tokens = makeTicketTokenSigner("integration-waitlist-secret-long-enough") diff --git a/services/api/test/integration/organization-repository-security-pg.test.ts b/services/api/test/integration/organization-repository-security-pg.test.ts index ce710acb..a727ec5e 100644 --- a/services/api/test/integration/organization-repository-security-pg.test.ts +++ b/services/api/test/integration/organization-repository-security-pg.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { testHandle, withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" -import type { OrganizationRepository } from "../../src/services/host/organization-repository.types.js" +import type { OrganizationRepository } from "../../src/services/host/organization-repository.js" const pg = await withPg() const INVITE_TTL_MS = 14 * 86_400_000 diff --git a/services/api/test/integration/registration-repository-security-pg.test.ts b/services/api/test/integration/registration-repository-security-pg.test.ts index 651e3ad9..9a7b2918 100644 --- a/services/api/test/integration/registration-repository-security-pg.test.ts +++ b/services/api/test/integration/registration-repository-security-pg.test.ts @@ -9,7 +9,7 @@ import type { HostRegistrationRepository, RegisterTxArgs, SeatDraft, -} from "../../src/services/host/registration-repository.types.js" +} from "../../src/services/host/registration-repository.js" const pg = await withPg() const tokens = makeTicketTokenSigner("integration-registration-security-secret") diff --git a/services/api/test/integration/signup-seats-pg.test.ts b/services/api/test/integration/signup-seats-pg.test.ts index d4a4809c..8202d7d1 100644 --- a/services/api/test/integration/signup-seats-pg.test.ts +++ b/services/api/test/integration/signup-seats-pg.test.ts @@ -9,8 +9,8 @@ import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" -import type { CleanupRepository, SignupSeat } from "../../src/services/cleanup-repository.types.js" -import type { HostRegistrationRepository } from "../../src/services/host/registration-repository.types.js" +import type { CleanupRepository, SignupSeat } from "../../src/services/cleanup-repository.js" +import type { HostRegistrationRepository } from "../../src/services/host/registration-repository.js" const pg = await withPg() diff --git a/services/api/test/unit/city-forward-logger-wiring.test.ts b/services/api/test/unit/city-forward-logger-wiring.test.ts index eff698ab..3725fda7 100644 --- a/services/api/test/unit/city-forward-logger-wiring.test.ts +++ b/services/api/test/unit/city-forward-logger-wiring.test.ts @@ -5,7 +5,7 @@ import type { FastifyBaseLogger, FastifyInstance } from "fastify" import type { Container } from "../../src/di.js" import type { OnReportMessage } from "../../src/ws/types.js" import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" -import type { DiscussionReportView } from "../../src/services/discussion-types.js" +import type { DiscussionReportView } from "../../src/services/discussion-repository.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" import { InMemoryChatReadState } from "../../src/services/threads-service.js" diff --git a/services/api/test/unit/city-forward-wiring.test.ts b/services/api/test/unit/city-forward-wiring.test.ts index f063f0c9..4ebda5dd 100644 --- a/services/api/test/unit/city-forward-wiring.test.ts +++ b/services/api/test/unit/city-forward-wiring.test.ts @@ -5,7 +5,7 @@ import type { FastifyInstance } from "fastify" import type { Container } from "../../src/di.js" import type { OnReportMessage } from "../../src/ws/types.js" import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" -import type { DiscussionReportView } from "../../src/services/discussion-types.js" +import type { DiscussionReportView } from "../../src/services/discussion-repository.js" import type { AppendOutboundInput, SendReportInput, diff --git a/services/api/test/unit/cleanup-update-atomic.test.ts b/services/api/test/unit/cleanup-update-atomic.test.ts index d4830b8f..935b12c1 100644 --- a/services/api/test/unit/cleanup-update-atomic.test.ts +++ b/services/api/test/unit/cleanup-update-atomic.test.ts @@ -8,7 +8,7 @@ import { MAX_EVENTS_PER_REPORT, makeDrizzleCleanupRepository, } from "../../src/services/cleanup-repository.drizzle.js" -import type { DesiredSlot } from "../../src/services/cleanup-repository.types.js" +import type { DesiredSlot } from "../../src/services/cleanup-repository.js" import { makeCleanupService, type CleanupService } from "../../src/services/cleanup-service.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" diff --git a/services/api/test/unit/host-insights.test.ts b/services/api/test/unit/host-insights.test.ts index 3ec2f29b..454cd699 100644 --- a/services/api/test/unit/host-insights.test.ts +++ b/services/api/test/unit/host-insights.test.ts @@ -15,7 +15,7 @@ import { type InsightsServiceDeps, } from "../../src/services/host/insights-service.js" import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" -import type { SeatDraft } from "../../src/services/host/registration-repository.types.js" +import type { SeatDraft } from "../../src/services/host/registration-repository.js" import type { AnalyticsRepository, EventClockRecord, diff --git a/services/api/test/unit/host/checkin-service.test.ts b/services/api/test/unit/host/checkin-service.test.ts index d574e310..fbdbe37a 100644 --- a/services/api/test/unit/host/checkin-service.test.ts +++ b/services/api/test/unit/host/checkin-service.test.ts @@ -8,7 +8,7 @@ import { type CheckinService, } from "../../../src/services/host/checkin-service.js" import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" -import type { SeatDraft } from "../../../src/services/host/registration-repository.types.js" +import type { SeatDraft } from "../../../src/services/host/registration-repository.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" const OTHER_EVENT = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" diff --git a/services/api/test/unit/host/host-list-keyset-precision.test.ts b/services/api/test/unit/host/host-list-keyset-precision.test.ts index ed5d8871..3c7c944b 100644 --- a/services/api/test/unit/host/host-list-keyset-precision.test.ts +++ b/services/api/test/unit/host/host-list-keyset-precision.test.ts @@ -10,7 +10,7 @@ import type { Sql } from "../../../src/db/client.js" import { makeDrizzleHostTeamRepository } from "../../../src/services/host/host-team-repository.drizzle.js" import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" -import type { RosterQuery } from "../../../src/services/host/registration-repository.types.js" +import type { RosterQuery } from "../../../src/services/host/registration-repository.js" const AT = new Date("2026-09-01T10:00:00.123Z") const AT_TEXT = "2026-09-01T10:00:00.123456Z" diff --git a/services/api/test/unit/host/host-service-correctness.test.ts b/services/api/test/unit/host/host-service-correctness.test.ts index 62d68400..603cb8f7 100644 --- a/services/api/test/unit/host/host-service-correctness.test.ts +++ b/services/api/test/unit/host/host-service-correctness.test.ts @@ -9,7 +9,7 @@ import { makeQuestionService } from "../../../src/services/host/question-service import { makeWaitlistService } from "../../../src/services/host/waitlist-service.js" import { WAITLIST_PROMOTE_JOB } from "../../../src/lib/queue-names.js" import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" -import type { SeatDraft } from "../../../src/services/host/registration-repository.types.js" +import type { SeatDraft } from "../../../src/services/host/registration-repository.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" const OTHER_EVENT = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" diff --git a/services/api/test/unit/host/insights-invalidation-wiring.test.ts b/services/api/test/unit/host/insights-invalidation-wiring.test.ts index d876211f..346f44c7 100644 --- a/services/api/test/unit/host/insights-invalidation-wiring.test.ts +++ b/services/api/test/unit/host/insights-invalidation-wiring.test.ts @@ -23,7 +23,7 @@ import { } from "../../../src/services/host/insights-service.js" import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" import type { InsightsServiceDeps } from "../../../src/services/host/insights-service.js" -import type { SeatDraft } from "../../../src/services/host/registration-repository.types.js" +import type { SeatDraft } from "../../../src/services/host/registration-repository.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" const HOST = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" diff --git a/services/api/test/unit/host/question-validation.test.ts b/services/api/test/unit/host/question-validation.test.ts index 2df25fc6..317c6cff 100644 --- a/services/api/test/unit/host/question-validation.test.ts +++ b/services/api/test/unit/host/question-validation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { validateAnswers } from "../../../src/services/host/question-validation.js" -import type { QuestionRecord } from "../../../src/services/host/registration-repository.types.js" +import type { QuestionRecord } from "../../../src/services/host/registration-repository.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/host/registration-repository-correctness.test.ts b/services/api/test/unit/host/registration-repository-correctness.test.ts index 6fcd9826..271fb4a4 100644 --- a/services/api/test/unit/host/registration-repository-correctness.test.ts +++ b/services/api/test/unit/host/registration-repository-correctness.test.ts @@ -8,7 +8,7 @@ import { deterministicUuid } from "../../../src/services/deterministic-uuid.js" import type { RegisterTxArgs, SeatDraft, -} from "../../../src/services/host/registration-repository.types.js" +} from "../../../src/services/host/registration-repository.js" import { WAITLIST_CLAIM_WINDOW_MS } from "../../../src/services/host/waitlist-service.js" import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../../helpers/fake-sql.js" import type { Sql } from "../../../src/db/client.js" diff --git a/services/api/test/unit/host/registration-repository-security.test.ts b/services/api/test/unit/host/registration-repository-security.test.ts index b066e9a1..817cbdcf 100644 --- a/services/api/test/unit/host/registration-repository-security.test.ts +++ b/services/api/test/unit/host/registration-repository-security.test.ts @@ -8,7 +8,7 @@ import type { QuestionRecord, RegisterTxArgs, SeatDraft, -} from "../../../src/services/host/registration-repository.types.js" +} from "../../../src/services/host/registration-repository.js" import { makeRegistrationService, type RegistrationService, diff --git a/services/api/test/unit/host/registration-state.test.ts b/services/api/test/unit/host/registration-state.test.ts index c6604b22..aff02175 100644 --- a/services/api/test/unit/host/registration-state.test.ts +++ b/services/api/test/unit/host/registration-state.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest" import { registrationStateOf } from "../../../src/services/host/registration-dto.js" -import type { EventRegistrationContext } from "../../../src/services/host/registration-repository.types.js" +import type { EventRegistrationContext } from "../../../src/services/host/registration-repository.js" const NOW = new Date("2026-06-01T12:00:00.000Z") const HOUR = 3_600_000 diff --git a/services/api/test/unit/host/roster-checked-in-cursor.test.ts b/services/api/test/unit/host/roster-checked-in-cursor.test.ts index e35b7b28..7d6783d5 100644 --- a/services/api/test/unit/host/roster-checked-in-cursor.test.ts +++ b/services/api/test/unit/host/roster-checked-in-cursor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../../src/db/client.js" import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" -import type { RosterQuery } from "../../../src/services/host/registration-repository.types.js" +import type { RosterQuery } from "../../../src/services/host/registration-repository.js" import { makeFakeSql } from "../../helpers/fake-sql.js" const EVENT = "11111111-1111-4111-8111-111111111111" diff --git a/services/api/test/unit/host/signup-seats.test.ts b/services/api/test/unit/host/signup-seats.test.ts index 9867f726..fe2174c6 100644 --- a/services/api/test/unit/host/signup-seats.test.ts +++ b/services/api/test/unit/host/signup-seats.test.ts @@ -22,7 +22,7 @@ import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { makeCleanupService, type CleanupService } from "../../../src/services/cleanup-service.js" import { InMemoryCleanupRepository } from "../../helpers/cleanups.js" import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" -import type { TicketTypeRecord } from "../../../src/services/host/registration-repository.types.js" +import type { TicketTypeRecord } from "../../../src/services/host/registration-repository.js" import { makeCheckinService, type CheckinService, diff --git a/services/api/test/unit/host/waitlist-service.test.ts b/services/api/test/unit/host/waitlist-service.test.ts index 69202111..42ce7e45 100644 --- a/services/api/test/unit/host/waitlist-service.test.ts +++ b/services/api/test/unit/host/waitlist-service.test.ts @@ -8,7 +8,7 @@ import { WAITLIST_CLAIM_WINDOW_MS, type WaitlistService, } from "../../../src/services/host/waitlist-service.js" -import type { SeatDraft } from "../../../src/services/host/registration-repository.types.js" +import type { SeatDraft } from "../../../src/services/host/registration-repository.js" import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/media-claim-actor.test.ts b/services/api/test/unit/media-claim-actor.test.ts index a79849ea..e1b59faa 100644 --- a/services/api/test/unit/media-claim-actor.test.ts +++ b/services/api/test/unit/media-claim-actor.test.ts @@ -8,11 +8,11 @@ import { resolveAuthContext } from "../../src/auth/context.js" import type { Sql } from "../../src/db/client.js" import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" -import type { CreateCleanupTxArgs } from "../../src/services/cleanup-repository.types.js" +import type { CreateCleanupTxArgs } from "../../src/services/cleanup-repository.js" import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" -import type { SavePageArgs } from "../../src/services/host/registration-repository.types.js" +import type { SavePageArgs } from "../../src/services/host/registration-repository.js" import { makeMediaIntakeService } from "../../src/services/media-intake-service.js" import { MEDIA_CHECKS_JOB } from "../../src/lib/queue-names.js" import { anonUploader, userUploader } from "../../src/services/media-uploader.js" diff --git a/services/api/test/unit/media-claim-uploader.test.ts b/services/api/test/unit/media-claim-uploader.test.ts index 358f124c..eda83476 100644 --- a/services/api/test/unit/media-claim-uploader.test.ts +++ b/services/api/test/unit/media-claim-uploader.test.ts @@ -20,7 +20,7 @@ import { } from "../../src/services/media-uploader.js" import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" -import type { CreateReportTxArgs } from "../../src/services/report-service.types.js" +import type { CreateReportTxArgs } from "../../src/services/report-types.js" import { makeFakeSql, type FakeSqlControl, type RecordedStatement } from "../helpers/fake-sql.js" import { InMemoryMediaRepository } from "../helpers/media.js" diff --git a/services/api/test/unit/report-chat.test.ts b/services/api/test/unit/report-chat.test.ts index 235d43d8..356d5033 100644 --- a/services/api/test/unit/report-chat.test.ts +++ b/services/api/test/unit/report-chat.test.ts @@ -24,7 +24,7 @@ import type { SendReportInput, } from "../../src/services/admin/outbound-mail-service.js" import type { MailThreadRecord } from "../../src/services/admin/mail-repository.drizzle.js" -import type { ReportJurisdictionView } from "../../src/services/discussion-types.js" +import type { ReportJurisdictionView } from "../../src/services/discussion-repository.js" import { makeServer } from "../../src/server.js" import { loadEnv } from "../../src/env.js" import { FakeMailer } from "@civfix/shared/fakes" diff --git a/services/api/test/unit/report-forward-audit.test.ts b/services/api/test/unit/report-forward-audit.test.ts index d04b76ed..ca23fa35 100644 --- a/services/api/test/unit/report-forward-audit.test.ts +++ b/services/api/test/unit/report-forward-audit.test.ts @@ -13,7 +13,7 @@ import type { SendReportInput, } from "../../src/services/admin/outbound-mail-service.js" import type { MailThreadRecord } from "../../src/services/admin/mail-repository.drizzle.js" -import type { ReportJurisdictionView } from "../../src/services/discussion-types.js" +import type { ReportJurisdictionView } from "../../src/services/discussion-repository.js" const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const MSG = "dddddddd-dddd-dddd-dddd-dddddddddddd" diff --git a/services/api/test/unit/report-owner-resolve.test.ts b/services/api/test/unit/report-owner-resolve.test.ts index d07baf9f..4f97617c 100644 --- a/services/api/test/unit/report-owner-resolve.test.ts +++ b/services/api/test/unit/report-owner-resolve.test.ts @@ -4,7 +4,7 @@ import { makeFakeSql } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" import { makeReportService } from "../../src/services/report-service.js" -import type { ReportRepository } from "../../src/services/report-service.types.js" +import type { ReportRepository } from "../../src/services/report-types.js" import { InMemoryReportRepository } from "../helpers/reports.js" const REPORT_ID = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/report-repository-security.test.ts b/services/api/test/unit/report-repository-security.test.ts index 5f11dccc..d06ecdea 100644 --- a/services/api/test/unit/report-repository-security.test.ts +++ b/services/api/test/unit/report-repository-security.test.ts @@ -5,7 +5,7 @@ import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" import type { Queryable, Sql } from "../../src/db/client.js" import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" -import type { CreateReportTxArgs } from "../../src/services/report-service.types.js" +import type { CreateReportTxArgs } from "../../src/services/report-types.js" const UNAVAILABLE = "One or more media uploads are unavailable." From 233a4d1a9b29d0af6e6765a323a84d6852f83f97 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 16:59:03 +0000 Subject: [PATCH 14/20] repository contracts move out of services and types modules --- services/api/scripts/render-email-gallery.ts | 2 +- services/api/src/di.ts | 4 +- .../api/src/routes/admin/activity.routes.ts | 6 +- .../api/src/routes/admin/analytics.routes.ts | 6 +- services/api/src/routes/admin/audit.routes.ts | 3 +- .../api/src/routes/admin/discovery.routes.ts | 6 +- .../api/src/routes/admin/events.routes.ts | 2 +- services/api/src/routes/admin/gov.routes.ts | 2 +- services/api/src/routes/admin/home.routes.ts | 2 +- .../src/routes/admin/jurisdictions.routes.ts | 2 +- services/api/src/routes/admin/media.routes.ts | 2 +- .../api/src/routes/admin/moderation.routes.ts | 2 +- .../api/src/routes/admin/reports.routes.ts | 2 +- services/api/src/routes/admin/users.routes.ts | 2 +- services/api/src/routes/chat.routes.ts | 2 +- services/api/src/routes/media.routes.ts | 2 +- .../api/src/routes/notifications.routes.ts | 2 +- services/api/src/routes/reports.routes.ts | 2 +- .../service-hours-certificates.routes.ts | 4 +- services/api/src/routes/social.routes.ts | 2 +- .../api/src/routes/volunteer-hours.routes.ts | 2 +- services/api/src/server.ts | 2 +- .../admin/activity-repository.drizzle.ts | 10 +- .../admin/activity-repository.memory.ts | 12 +- .../src/services/admin/activity-repository.ts | 32 ++++ .../src/services/admin/activity-service.ts | 39 +---- .../admin/admin-event-repository.drizzle.ts | 2 +- .../admin/admin-event-repository.memory.ts | 4 +- .../services/admin/admin-event-repository.ts | 87 ++++++++++ .../src/services/admin/admin-event-service.ts | 94 +---------- .../admin/admin-report-repository.drizzle.ts | 22 +-- .../admin/admin-report-repository.memory.ts | 4 +- .../services/admin/admin-report-repository.ts | 134 +++++++++++++++ .../services/admin/admin-report-service.ts | 12 +- .../src/services/admin/admin-report-types.ts | 132 +-------------- .../admin/admin-user-repository.drizzle.ts | 2 +- .../admin/admin-user-repository.memory.ts | 2 +- .../services/admin/admin-user-repository.ts | 116 +++++++++++++ .../src/services/admin/admin-user-service.ts | 116 +------------ .../admin/analytics-repository.drizzle.ts | 2 +- .../admin/analytics-repository.memory.ts | 2 +- .../services/admin/analytics-repository.ts | 103 ++++++++++++ .../src/services/admin/analytics-shaping.ts | 24 +-- .../api/src/services/admin/analytics-types.ts | 103 +----------- .../admin/audit-repository.drizzle.ts | 2 +- .../services/admin/audit-repository.memory.ts | 2 +- .../src/services/admin/audit-repository.ts | 22 +++ .../api/src/services/admin/audit-service.ts | 24 +-- .../admin/discovery-repository.drizzle.ts | 20 +-- .../admin/discovery-repository.memory.ts | 22 +-- .../services/admin/discovery-repository.ts | 103 ++++++++++++ .../src/services/admin/discovery-service.ts | 109 +----------- .../admin/forward-template-service.ts | 2 +- .../admin/gov-claims-repository.drizzle.ts | 14 +- .../admin/gov-claims-repository.memory.ts | 15 +- .../services/admin/gov-claims-repository.ts | 68 ++++++++ .../src/services/admin/gov-claims-service.ts | 71 +------- .../api/src/services/admin/home-service.ts | 2 +- .../src/services/admin/inbound-processor.ts | 3 +- .../admin/inbound-thread-correlation.ts | 3 +- ...urisdiction-contacts-repository.drizzle.ts | 2 +- ...jurisdiction-contacts-repository.memory.ts | 2 +- .../admin/jurisdiction-contacts-repository.ts | 120 +++++++++++++ .../admin/jurisdiction-contacts-service.ts | 9 +- .../admin/jurisdiction-contacts-types.ts | 126 +------------- .../jurisdiction-directory-projection.ts | 13 +- .../api/src/services/admin/mail-format.ts | 2 +- .../admin/moderation-repository.drizzle.ts | 14 +- .../admin/moderation-repository.memory.ts | 15 +- .../services/admin/moderation-repository.ts | 107 ++++++++++++ .../src/services/admin/moderation-service.ts | 109 +----------- .../admin/outreach-repository.drizzle.ts | 7 +- .../admin/outreach-repository.memory.ts | 7 +- .../src/services/admin/outreach-repository.ts | 16 ++ .../src/services/admin/outreach-service.ts | 16 +- .../src/services/anon-repository.drizzle.ts | 7 +- services/api/src/services/anon-repository.ts | 64 +++++++ services/api/src/services/anon-service.ts | 52 +----- .../certificate-repository.drizzle.ts | 17 +- .../services/certificate-repository.memory.ts | 16 +- .../src/services/certificate-repository.ts | 95 +++++++++++ .../api/src/services/certificate-service.ts | 101 +---------- services/api/src/services/claim-service.ts | 12 +- services/api/src/services/guest-notify.ts | 2 +- .../services/guest-rsvp-repository.drizzle.ts | 2 +- .../api/src/services/guest-rsvp-repository.ts | 99 +++++++++++ .../api/src/services/guest-rsvp-service.ts | 108 +----------- .../services/host/broadcast-inapp-sender.ts | 3 +- .../host/broadcast-repository.drizzle.ts | 2 +- .../host/broadcast-repository.memory.ts | 2 +- .../src/services/host/broadcast-repository.ts | 2 +- .../api/src/services/media-authorization.ts | 3 +- .../api/src/services/media-intake-service.ts | 43 +---- .../src/services/media-repository.drizzle.ts | 2 +- services/api/src/services/media-repository.ts | 42 +++++ .../api/src/services/notification-helpers.ts | 2 +- .../notification-repository.drizzle.ts | 2 +- .../src/services/notification-repository.ts | 104 ++++++++++++ .../api/src/services/notification-service.ts | 114 +------------ .../api/src/services/report-clustering.ts | 3 +- .../src/services/report-repository.drizzle.ts | 4 +- .../api/src/services/report-repository.ts | 159 ++++++++++++++++++ services/api/src/services/report-service.ts | 12 +- services/api/src/services/report-sql.ts | 2 +- services/api/src/services/report-types.ts | 153 +---------------- .../api/src/services/report-visibility.ts | 2 +- .../src/services/social-repository.drizzle.ts | 2 +- .../api/src/services/social-repository.ts | 85 ++++++++++ services/api/src/services/social-service.ts | 92 +--------- .../services/threads-repository.drizzle.ts | 3 +- .../api/src/services/threads-repository.ts | 23 +++ services/api/src/services/threads-service.ts | 23 +-- .../src/services/volunteer-hours-anomaly.ts | 2 +- .../volunteer-hours-repository.drizzle.ts | 2 +- .../volunteer-hours-repository.memory.ts | 2 +- .../services/volunteer-hours-repository.ts | 133 +++++++++++++++ .../src/services/volunteer-hours-service.ts | 137 ++------------- services/api/test/helpers/anon.ts | 5 +- services/api/test/helpers/chat.ts | 2 +- services/api/test/helpers/guest-rsvp.ts | 2 +- services/api/test/helpers/media.ts | 2 +- services/api/test/helpers/notifications.ts | 4 +- services/api/test/helpers/reports.ts | 4 +- services/api/test/helpers/social.ts | 2 +- .../test/integration/admin-activity.test.ts | 2 +- .../test/integration/admin-analytics.test.ts | 2 +- .../api/test/integration/admin-audit.test.ts | 2 +- .../test/integration/admin-discovery.test.ts | 4 +- .../api/test/integration/admin-events.test.ts | 2 +- .../test/integration/admin-gov-claims.test.ts | 2 +- .../admin-jurisdiction-directory.test.ts | 2 +- .../test/integration/admin-moderation.test.ts | 2 +- .../test/integration/admin-reports.test.ts | 2 +- .../api/test/integration/admin-users.test.ts | 2 +- .../test/integration/guest-rsvp-pg.test.ts | 2 +- .../guest-rsvp-security-pg.test.ts | 2 +- .../guest-rsvp-upsert-created-pg.test.ts | 5 +- .../moderation-destination-refs-pg.test.ts | 2 +- .../integration/social-connections-pg.test.ts | 2 +- .../integration/suggest-follows-pg.test.ts | 2 +- services/api/test/unit/admin-home.test.ts | 6 +- .../unit/admin-host-platform-routes.test.ts | 2 +- .../unit/admin-jurisdiction-contacts.test.ts | 2 +- .../unit/anon-repository-security.test.ts | 6 +- .../test/unit/certificate-verify-url.test.ts | 2 +- .../test/unit/directory-facet-cache.test.ts | 2 +- services/api/test/unit/email-format.test.ts | 2 +- .../api/test/unit/guest-rsvp-refusal.test.ts | 2 +- .../api/test/unit/media-authorization.test.ts | 2 +- .../test/unit/media-claim-uploader.test.ts | 10 +- .../test/unit/notification-service.test.ts | 2 +- .../api/test/unit/outreach-pipeline.test.ts | 2 +- .../api/test/unit/packet-media-links.test.ts | 2 +- .../unit/report-chat-notifications.test.ts | 2 +- .../test/unit/report-owner-resolve.test.ts | 2 +- .../unit/report-repository-security.test.ts | 2 +- services/api/test/unit/report-service.test.ts | 2 +- services/api/test/unit/social-routes.test.ts | 3 +- services/api/test/unit/social-service.test.ts | 3 +- 159 files changed, 2070 insertions(+), 1941 deletions(-) create mode 100644 services/api/src/services/admin/activity-repository.ts create mode 100644 services/api/src/services/admin/admin-event-repository.ts create mode 100644 services/api/src/services/admin/admin-report-repository.ts create mode 100644 services/api/src/services/admin/admin-user-repository.ts create mode 100644 services/api/src/services/admin/analytics-repository.ts create mode 100644 services/api/src/services/admin/audit-repository.ts create mode 100644 services/api/src/services/admin/discovery-repository.ts create mode 100644 services/api/src/services/admin/gov-claims-repository.ts create mode 100644 services/api/src/services/admin/jurisdiction-contacts-repository.ts create mode 100644 services/api/src/services/admin/moderation-repository.ts create mode 100644 services/api/src/services/admin/outreach-repository.ts create mode 100644 services/api/src/services/anon-repository.ts create mode 100644 services/api/src/services/certificate-repository.ts create mode 100644 services/api/src/services/guest-rsvp-repository.ts create mode 100644 services/api/src/services/media-repository.ts create mode 100644 services/api/src/services/notification-repository.ts create mode 100644 services/api/src/services/report-repository.ts create mode 100644 services/api/src/services/social-repository.ts create mode 100644 services/api/src/services/threads-repository.ts create mode 100644 services/api/src/services/volunteer-hours-repository.ts diff --git a/services/api/scripts/render-email-gallery.ts b/services/api/scripts/render-email-gallery.ts index c5ea1003..9d6759cc 100644 --- a/services/api/scripts/render-email-gallery.ts +++ b/services/api/scripts/render-email-gallery.ts @@ -14,7 +14,7 @@ import { orgVerificationDecisionEmailVars, } from "../src/services/host/organization-service.js" import { teamInviteEmailVars } from "../src/services/host/host-team-service.js" -import type { AdminReportRecord } from "../src/services/admin/admin-report-types.js" +import type { AdminReportRecord } from "../src/services/admin/admin-report-repository.js" interface GalleryEntry { name: string diff --git a/services/api/src/di.ts b/services/api/src/di.ts index 1c83a23e..20506b54 100644 --- a/services/api/src/di.ts +++ b/services/api/src/di.ts @@ -62,9 +62,9 @@ import { type BlocksRepository, } from "./services/blocks-repository.drizzle.js" import { makeDrizzleVolunteerHoursRepository } from "./services/volunteer-hours-repository.drizzle.js" -import type { VolunteerHoursRepository } from "./services/volunteer-hours-service.js" +import type { VolunteerHoursRepository } from "./services/volunteer-hours-repository.js" +import type { CertificateRepository } from "./services/certificate-repository.js" import { makeDrizzleCertificateRepository } from "./services/certificate-repository.drizzle.js" -import type { CertificateRepository } from "./services/certificate-service.js" import { makeDrizzlePostRepository, type PostRepository, diff --git a/services/api/src/routes/admin/activity.routes.ts b/services/api/src/routes/admin/activity.routes.ts index c23b936e..9092fadb 100644 --- a/services/api/src/routes/admin/activity.routes.ts +++ b/services/api/src/routes/admin/activity.routes.ts @@ -10,10 +10,8 @@ import { ActivityListQuerySchema, type ActivityListResponse } from "@civfix/shar import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { overridableService, parse, spreadNow } from "./_route-utils.js" -import { - makeActivityService, - type ActivityRepository, -} from "../../services/admin/activity-service.js" +import { makeActivityService } from "../../services/admin/activity-service.js" +import type { ActivityRepository } from "../../services/admin/activity-repository.js" import { makeDrizzleActivityRepository } from "../../services/admin/activity-repository.drizzle.js" import { route } from "../../versioning/route.js" diff --git a/services/api/src/routes/admin/analytics.routes.ts b/services/api/src/routes/admin/analytics.routes.ts index 5c6351ab..bd2f8c2c 100644 --- a/services/api/src/routes/admin/analytics.routes.ts +++ b/services/api/src/routes/admin/analytics.routes.ts @@ -1,9 +1,7 @@ import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" -import { - makeAnalyticsService, - type AnalyticsRepository, -} from "../../services/admin/analytics-service.js" +import { makeAnalyticsService } from "../../services/admin/analytics-service.js" +import type { AnalyticsRepository } from "../../services/admin/analytics-repository.js" import { ANALYTICS_CACHE_TTL_MS, makeDrizzleAnalyticsRepository, diff --git a/services/api/src/routes/admin/audit.routes.ts b/services/api/src/routes/admin/audit.routes.ts index a92c656b..04f5d00e 100644 --- a/services/api/src/routes/admin/audit.routes.ts +++ b/services/api/src/routes/admin/audit.routes.ts @@ -4,7 +4,8 @@ import { AuditListQuerySchema, type AuditListResponse } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { overridableService, parse } from "./_route-utils.js" -import { makeAuditService, type AuditRepository } from "../../services/admin/audit-service.js" +import { makeAuditService } from "../../services/admin/audit-service.js" +import type { AuditRepository } from "../../services/admin/audit-repository.js" import { makeDrizzleAuditRepository } from "../../services/admin/audit-repository.drizzle.js" import { route } from "../../versioning/route.js" diff --git a/services/api/src/routes/admin/discovery.routes.ts b/services/api/src/routes/admin/discovery.routes.ts index c44a9784..5b9123cf 100644 --- a/services/api/src/routes/admin/discovery.routes.ts +++ b/services/api/src/routes/admin/discovery.routes.ts @@ -23,10 +23,8 @@ import { sendOk, spreadNow, } from "./_route-utils.js" -import { - makeDiscoveryService, - type DiscoveryRepository, -} from "../../services/admin/discovery-service.js" +import { makeDiscoveryService } from "../../services/admin/discovery-service.js" +import type { DiscoveryRepository } from "../../services/admin/discovery-repository.js" import { makeDrizzleDiscoveryRepository } from "../../services/admin/discovery-repository.drizzle.js" const FALLBACK_OPERATOR_LABEL = "operator" diff --git a/services/api/src/routes/admin/events.routes.ts b/services/api/src/routes/admin/events.routes.ts index 8a889cbc..cfef58a9 100644 --- a/services/api/src/routes/admin/events.routes.ts +++ b/services/api/src/routes/admin/events.routes.ts @@ -29,9 +29,9 @@ import { } from "./_route-utils.js" import { makeAdminEventService, - type AdminEventRepository, type AdminEventService, } from "../../services/admin/admin-event-service.js" +import type { AdminEventRepository } from "../../services/admin/admin-event-repository.js" import { makeDrizzleAdminEventRepository } from "../../services/admin/admin-event-repository.drizzle.js" import { MEDIA_GET_URL_TTL_SEC } from "../../services/media-intake-service.js" diff --git a/services/api/src/routes/admin/gov.routes.ts b/services/api/src/routes/admin/gov.routes.ts index 81ac2b94..91af8214 100644 --- a/services/api/src/routes/admin/gov.routes.ts +++ b/services/api/src/routes/admin/gov.routes.ts @@ -21,10 +21,10 @@ import { } from "./_route-utils.js" import { makeGovClaimsService, - type GovClaimsRepository, type ProvisionedUser, type UserProvisioner, } from "../../services/admin/gov-claims-service.js" +import type { GovClaimsRepository } from "../../services/admin/gov-claims-repository.js" import { makeDrizzleGovClaimsRepository } from "../../services/admin/gov-claims-repository.drizzle.js" import type { RevokeAllSessions } from "../../services/admin/role-change.js" import type { UserRecord, UserStore } from "../../auth/stores.js" diff --git a/services/api/src/routes/admin/home.routes.ts b/services/api/src/routes/admin/home.routes.ts index 277061c9..068e7c3d 100644 --- a/services/api/src/routes/admin/home.routes.ts +++ b/services/api/src/routes/admin/home.routes.ts @@ -7,7 +7,7 @@ import { ANALYTICS_CACHE_TTL_MS, makeDrizzleAnalyticsRepository, } from "../../services/admin/analytics-repository.drizzle.js" -import type { AnalyticsRepository } from "../../services/admin/analytics-service.js" +import type { AnalyticsRepository } from "../../services/admin/analytics-repository.js" import { route } from "../../versioning/route.js" import { overridableService, spreadNow } from "./_route-utils.js" diff --git a/services/api/src/routes/admin/jurisdictions.routes.ts b/services/api/src/routes/admin/jurisdictions.routes.ts index fb4cad21..f360f700 100644 --- a/services/api/src/routes/admin/jurisdictions.routes.ts +++ b/services/api/src/routes/admin/jurisdictions.routes.ts @@ -24,9 +24,9 @@ import { } from "./_route-utils.js" import { makeJurisdictionContactsService, - type JurisdictionContactsRepository, type OutreachEnqueuer, } from "../../services/admin/jurisdiction-contacts-service.js" +import type { JurisdictionContactsRepository } from "../../services/admin/jurisdiction-contacts-repository.js" import { makeDrizzleJurisdictionContactsRepository } from "../../services/admin/jurisdiction-contacts-repository.drizzle.js" /** Test-only: an in-memory repo and a fake jobs enqueuer so the whole HTTP flow runs offline. */ diff --git a/services/api/src/routes/admin/media.routes.ts b/services/api/src/routes/admin/media.routes.ts index 273ca848..a75e00f7 100644 --- a/services/api/src/routes/admin/media.routes.ts +++ b/services/api/src/routes/admin/media.routes.ts @@ -7,7 +7,7 @@ import { parse } from "../_validate.js" import { auditRead } from "./_audit-read.js" import { makeDrizzleMediaRepository } from "../../services/media-repository.drizzle.js" import { makePrivateMediaPresigner } from "../../services/media-presign.js" -import type { MediaRepository } from "../../services/media-intake-service.js" +import type { MediaRepository } from "../../services/media-repository.js" import { MEDIA_PRIVATE_GET_URL_TTL_SEC } from "../../services/media-intake-service.js" export interface AdminMediaOverrides { diff --git a/services/api/src/routes/admin/moderation.routes.ts b/services/api/src/routes/admin/moderation.routes.ts index 83849f1c..ea3f2f35 100644 --- a/services/api/src/routes/admin/moderation.routes.ts +++ b/services/api/src/routes/admin/moderation.routes.ts @@ -22,9 +22,9 @@ import { } from "./_route-utils.js" import { makeModerationService, - type ModerationRepository, type ModerationSessionControl, } from "../../services/admin/moderation-service.js" +import type { ModerationRepository } from "../../services/admin/moderation-repository.js" import { makeDrizzleModerationRepository } from "../../services/admin/moderation-repository.drizzle.js" import { makeContainerReportChatEmitter } from "../../services/report-chat-emitter.js" import { makePrivateMediaPresigner, type PresignMedia } from "../../services/media-presign.js" diff --git a/services/api/src/routes/admin/reports.routes.ts b/services/api/src/routes/admin/reports.routes.ts index 78d0362e..34d9365a 100644 --- a/services/api/src/routes/admin/reports.routes.ts +++ b/services/api/src/routes/admin/reports.routes.ts @@ -26,9 +26,9 @@ import { import { requireOperator } from "../../auth/admin-guard.js" import { makeAdminReportService, - type AdminReportRepository, type ReporterNotifier, } from "../../services/admin/admin-report-service.js" +import type { AdminReportRepository } from "../../services/admin/admin-report-repository.js" import { makeDrizzleAdminReportRepository } from "../../services/admin/admin-report-repository.drizzle.js" import { makeDrizzleForwardTemplateRepository } from "../../services/admin/forward-template-repository.drizzle.js" import type { ForwardTemplateRepository } from "../../services/admin/forward-template-repository.js" diff --git a/services/api/src/routes/admin/users.routes.ts b/services/api/src/routes/admin/users.routes.ts index d03a8208..e597d2d4 100644 --- a/services/api/src/routes/admin/users.routes.ts +++ b/services/api/src/routes/admin/users.routes.ts @@ -29,9 +29,9 @@ import { import { auditRead } from "./_audit-read.js" import { makeAdminUserService, - type AdminUserRepository, type SessionControl, } from "../../services/admin/admin-user-service.js" +import type { AdminUserRepository } from "../../services/admin/admin-user-repository.js" import { makeDrizzleAdminUserRepository } from "../../services/admin/admin-user-repository.drizzle.js" export interface AdminUserRouteOverrides { diff --git a/services/api/src/routes/chat.routes.ts b/services/api/src/routes/chat.routes.ts index 29c1f32f..6bd198f4 100644 --- a/services/api/src/routes/chat.routes.ts +++ b/services/api/src/routes/chat.routes.ts @@ -43,9 +43,9 @@ import { type DmThreadsSource, type GroupThreadsSource, type ReportThreadsSource, - type ThreadsRepository, type ThreadsService, } from "../services/threads-service.js" +import type { ThreadsRepository } from "../services/threads-repository.js" import type { NotificationService } from "../services/notification-service.js" import type { ResolveChatPowers } from "../services/chat-room-roles.js" import { wireChatPowers } from "./chat-powers-wiring.js" diff --git a/services/api/src/routes/media.routes.ts b/services/api/src/routes/media.routes.ts index 9f21123b..ab26beff 100644 --- a/services/api/src/routes/media.routes.ts +++ b/services/api/src/routes/media.routes.ts @@ -51,8 +51,8 @@ import { type MediaByteQuota, type MediaIntakeService, type MediaOwner, - type MediaRepository, } from "../services/media-intake-service.js" +import type { MediaRepository } from "../services/media-repository.js" import { makeDrizzleMediaRepository } from "../services/media-repository.drizzle.js" import { makeDrizzleMediaViewAuthorizer, diff --git a/services/api/src/routes/notifications.routes.ts b/services/api/src/routes/notifications.routes.ts index 947f5e4f..692ddcc5 100644 --- a/services/api/src/routes/notifications.routes.ts +++ b/services/api/src/routes/notifications.routes.ts @@ -17,9 +17,9 @@ import type { Container } from "../di.js" import { requireAuth } from "../auth/context.js" import { makeNotificationService, - type NotificationRepository, type NotificationService, } from "../services/notification-service.js" +import type { NotificationRepository } from "../services/notification-repository.js" import { makeRouteNotificationService } from "../services/route-notifier.js" import { perIdentity } from "../plugins/rate-limit.js" import { route } from "../versioning/route.js" diff --git a/services/api/src/routes/reports.routes.ts b/services/api/src/routes/reports.routes.ts index 34559b12..b742d6af 100644 --- a/services/api/src/routes/reports.routes.ts +++ b/services/api/src/routes/reports.routes.ts @@ -28,10 +28,10 @@ import { makeReportService, type ReportDiscussionMeta, type ReportOwner, - type ReportRepository, type ReportService, type ReportServiceDeps, } from "../services/report-service.js" +import type { ReportRepository } from "../services/report-repository.js" import { makeDrizzleReportRepository } from "../services/report-repository.drizzle.js" import { resolveJurisdictionCode } from "../db/reference-code.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" diff --git a/services/api/src/routes/service-hours-certificates.routes.ts b/services/api/src/routes/service-hours-certificates.routes.ts index 2467996e..3e222862 100644 --- a/services/api/src/routes/service-hours-certificates.routes.ts +++ b/services/api/src/routes/service-hours-certificates.routes.ts @@ -22,11 +22,11 @@ import { requireAuth } from "../auth/context.js" import { CERTIFICATE_VERIFY_PATH } from "../services/certificate-pdf.js" import { makeCertificateService, - type CertificateRepository, type CertificateService, type CertificateStorage, } from "../services/certificate-service.js" -import type { VolunteerHoursRepository } from "../services/volunteer-hours-service.js" +import type { CertificateRepository } from "../services/certificate-repository.js" +import type { VolunteerHoursRepository } from "../services/volunteer-hours-repository.js" import { route } from "../versioning/route.js" import { parse } from "./_validate.js" diff --git a/services/api/src/routes/social.routes.ts b/services/api/src/routes/social.routes.ts index 7ad1fa04..98c0d58c 100644 --- a/services/api/src/routes/social.routes.ts +++ b/services/api/src/routes/social.routes.ts @@ -20,11 +20,11 @@ import { makeSocialService, PERSON_NOT_FOUND_MESSAGE, type SocialNotifier, - type SocialRepository, type SocialService, type SocialServiceDeps, type SocialViewer, } from "../services/social-service.js" +import type { SocialRepository } from "../services/social-repository.js" import { makeDrizzleSocialRepository } from "../services/social-repository.drizzle.js" import { makeRouteNotificationService } from "../services/route-notifier.js" import { MEDIA_GET_URL_TTL_SEC } from "../services/media-intake-service.js" diff --git a/services/api/src/routes/volunteer-hours.routes.ts b/services/api/src/routes/volunteer-hours.routes.ts index 1abdef2c..e3d311d6 100644 --- a/services/api/src/routes/volunteer-hours.routes.ts +++ b/services/api/src/routes/volunteer-hours.routes.ts @@ -20,9 +20,9 @@ import { makeVolunteerHoursService, type CleanupHoursLookup, type HoursModerationSink, - type VolunteerHoursRepository, type VolunteerHoursService, } from "../services/volunteer-hours-service.js" +import type { VolunteerHoursRepository } from "../services/volunteer-hours-repository.js" import { toHoursAnomalyModerationItem } from "../services/volunteer-hours-anomaly.js" import { makeModerationService } from "../services/admin/moderation-service.js" import { makeDrizzleModerationRepository } from "../services/admin/moderation-repository.drizzle.js" diff --git a/services/api/src/server.ts b/services/api/src/server.ts index c56d1471..5f164b1c 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -14,7 +14,7 @@ import { registerVersionGate } from "./versioning/version-gate.js" import { registerAuthContext } from "./auth/context.js" import { registerAccountStatusGuard } from "./auth/account-status.js" import { makeAuthServicesFromContainer, type AuthServices } from "./auth/auth-services.js" -import type { MediaRepository } from "./services/media-intake-service.js" +import type { MediaRepository } from "./services/media-repository.js" import type { ReportServiceOverrides } from "./routes/reports.routes.js" import type { AnonServiceOverride } from "./routes/anon.routes.js" import type { HomeTurfOverrides } from "./routes/forms.routes.js" diff --git a/services/api/src/services/admin/activity-repository.drizzle.ts b/services/api/src/services/admin/activity-repository.drizzle.ts index 20bb49f1..8ee4fbaf 100644 --- a/services/api/src/services/admin/activity-repository.drizzle.ts +++ b/services/api/src/services/admin/activity-repository.drizzle.ts @@ -38,12 +38,14 @@ import { MAIL_BOUNCE_EVENT_TYPES, auditRulesForKind, sourcesForKind, - type ActivityRepository, type AuditActionRule, - type ActivitySource, - type ActivitySourceRecord, - type ListActivityArgs, } from "./activity-service.js" +import type { + ActivityRepository, + ActivitySource, + ActivitySourceRecord, + ListActivityArgs, +} from "./activity-repository.js" interface ActivityRowSelect { source: ActivitySource diff --git a/services/api/src/services/admin/activity-repository.memory.ts b/services/api/src/services/admin/activity-repository.memory.ts index c139233e..d8ae1099 100644 --- a/services/api/src/services/admin/activity-repository.memory.ts +++ b/services/api/src/services/admin/activity-repository.memory.ts @@ -9,12 +9,12 @@ import { AUDIT_READ_ACTIONS } from "./audit.js" import { pageInMemoryById } from "./pagination.js" -import { - classifyActivity, - type ActivityRepository, - type ActivitySourceRecord, - type ListActivityArgs, -} from "./activity-service.js" +import { classifyActivity } from "./activity-service.js" +import type { + ActivityRepository, + ActivitySourceRecord, + ListActivityArgs, +} from "./activity-repository.js" const READ_ACTIONS: ReadonlySet = new Set(AUDIT_READ_ACTIONS) diff --git a/services/api/src/services/admin/activity-repository.ts b/services/api/src/services/admin/activity-repository.ts new file mode 100644 index 00000000..8ccc7f55 --- /dev/null +++ b/services/api/src/services/admin/activity-repository.ts @@ -0,0 +1,32 @@ +import type { ActivityKind } from "@civfix/shared" + +export type ActivitySource = "audit" | "report" | "cleanup" | "mail_event" + +export interface ActivitySourceRecord { + source: ActivitySource + id: string + ts: Date + who: string + where: string + action?: string | null + eventType?: string | null + subject?: string | null +} + +export type ActivityFilter = "all" | ActivityKind + +export type ActivitySort = "newest" | "oldest" + +export interface ListActivityArgs { + q: string | null + filter: ActivityFilter + sort: ActivitySort + cursor: string | null + limit: number +} + +export interface ActivityRepository { + list( + args: ListActivityArgs, + ): Promise<{ records: ActivitySourceRecord[]; nextCursor: string | null }> +} diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index f4243c1c..b19fda29 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -13,37 +13,14 @@ import type { ActivityListResponse, } from "@civfix/shared" import { clampLimit } from "./pagination.js" - -export type ActivitySource = "audit" | "report" | "cleanup" | "mail_event" - -export interface ActivitySourceRecord { - source: ActivitySource - id: string - ts: Date - who: string - where: string - action?: string | null - eventType?: string | null - subject?: string | null -} - -export type ActivityFilter = "all" | ActivityKind - -export type ActivitySort = "newest" | "oldest" - -export interface ListActivityArgs { - q: string | null - filter: ActivityFilter - sort: ActivitySort - cursor: string | null - limit: number -} - -export interface ActivityRepository { - list( - args: ListActivityArgs, - ): Promise<{ records: ActivitySourceRecord[]; nextCursor: string | null }> -} +import type { + ActivityFilter, + ActivityRepository, + ActivitySort, + ActivitySource, + ActivitySourceRecord, + ListActivityArgs, +} from "./activity-repository.js" /** * `filter` / `sort` are free-form strings on the wire (ActivityListQuerySchema), so an unrecognized value diff --git a/services/api/src/services/admin/admin-event-repository.drizzle.ts b/services/api/src/services/admin/admin-event-repository.drizzle.ts index 533f22cb..9ae9eed6 100644 --- a/services/api/src/services/admin/admin-event-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-event-repository.drizzle.ts @@ -28,7 +28,7 @@ import type { AdminEventTimelineRecord, AdminOrganizerRecord, ListEventsArgs, -} from "./admin-event-service.js" +} from "./admin-event-repository.js" import type { LinkedReportView } from "../cleanup-service.js" import type { AdminEventCounts, EventKind, ReportCategory, ReportStatus } from "@civfix/shared" diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/src/services/admin/admin-event-repository.memory.ts index 0fdd5b55..2ef35abc 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -21,15 +21,15 @@ import { toEventStatus } from "./event-status.js" import { CIVFIX_OFFICIAL_DISPLAY_NAME } from "../../auth/official-account.js" import { DEFAULT_EVENT_DURATION_MS, deriveCleanupStatus } from "../cleanup-rules.js" import { MS_PER_HOUR } from "../../lib/time.js" +import type { EventMemberRef } from "./admin-event-service.js" import type { AdminEventMessageRecord, AdminEventRecord, AdminEventRepository, AdminEventTimelineRecord, AdminOrganizerRecord, - EventMemberRef, ListEventsArgs, -} from "./admin-event-service.js" +} from "./admin-event-repository.js" import type { LinkedReportView } from "../cleanup-service.js" import type { AdminEventCounts, EventKind, EventStatus, ReportCategory } from "@civfix/shared" diff --git a/services/api/src/services/admin/admin-event-repository.ts b/services/api/src/services/admin/admin-event-repository.ts new file mode 100644 index 00000000..3707ae56 --- /dev/null +++ b/services/api/src/services/admin/admin-event-repository.ts @@ -0,0 +1,87 @@ +import type { AdminEventCounts, EventKind, EventStatus } from "@civfix/shared" +import type { LinkedReportView } from "../cleanup-service.js" +import type { AdminPersonRecord } from "./admin-person.js" + +/** Field-identical to a report's reporter; the alias keeps the events domain's name for this person. */ +export type AdminOrganizerRecord = AdminPersonRecord + +export interface AdminEventTimelineRecord { + kind: string + note: string | null + who: string + createdAt: Date +} + +export interface AdminEventMessageRecord { + who: string + text: string + createdAt: Date +} + +export interface AdminEventRecord { + id: string + status: EventStatus + // Only 'cleanup' events may link reports or show the linked-report gallery. + eventKind: EventKind + flagged: boolean + title: string + place: string + attendees: number + capacity: number | null + bags: number + organizer: AdminOrganizerRecord | null + desc: string + address: string + lat: number + lng: number + scheduledAt: Date +} + +// A null `status` matches any status. +export interface ListEventsArgs { + q: string | null + status: EventStatus | null + flaggedOnly: boolean + cursor: string | null + limit: number + /** Restrict to events linked to this organization (adminListOrgEvents). */ + organizationId?: string + /** Time facet relative to `ref`: upcoming = scheduled_at >= ref, past = scheduled_at < ref. */ + when?: { kind: "upcoming" | "past"; ref: Date } +} + +export interface AdminEventRepository { + // Totals over the whole searched set rather than the first keyset page, so the filter chips stay + // accurate as the operator switches facets. + countByBucket(args: { q: string | null }): Promise + listEvents( + args: ListEventsArgs, + ): Promise<{ records: AdminEventRecord[]; nextCursor: string | null }> + getEvent(id: string): Promise + listTimeline(id: string): Promise + listMessages(id: string): Promise + // The only write path for cleanups.bags. Returns false when the cleanup is absent. + setBags(id: string, input: { bags: number; actorId: string | null }): Promise + // Returns the resulting flagged state, or null when the cleanup is absent. + toggleFlag( + id: string, + input: { reason: string | null; actorId: string | null }, + ): Promise + cancel(id: string, input: { note: string; actorId: string | null }): Promise + // The chat row, the 'message' timeline row and the per-member notification fan-out commit in one + // transaction. Returns the number of members notified, or null when the cleanup does not exist. + postMessage( + id: string, + input: { body: string; actorId: string }, + ): Promise<{ notified: number } | null> + // Only publicly visible reports appear in the gallery. + loadLinkedReports(id: string): Promise + // Returns the newly-linked ids, or null when the event is missing. Skips non-visible (held/hidden) ids. + linkReports( + id: string, + reportIds: string[], + actorId: string | null, + ): Promise<{ linked: string[] } | null> + // Returns true when a link existed (removed), false when there was none, null when the event is missing. + unlinkReport(id: string, reportId: string, actorId: string | null): Promise +} diff --git a/services/api/src/services/admin/admin-event-service.ts b/services/api/src/services/admin/admin-event-service.ts index faf60677..149f22b5 100644 --- a/services/api/src/services/admin/admin-event-service.ts +++ b/services/api/src/services/admin/admin-event-service.ts @@ -12,7 +12,6 @@ import type { AdminEventListItemDTO, AdminEventListQuery, AdminEventListResponse, - EventKind, EventMessage, EventStatus, EventTimelineItem, @@ -20,7 +19,7 @@ import type { } from "@civfix/shared" import { toLinkedReportRef, type LinkedReportView } from "../cleanup-service.js" import { toRelAbs } from "./admin-format.js" -import { toPersonDTO, type AdminPersonRecord } from "./admin-person.js" +import { toPersonDTO } from "./admin-person.js" import { mapWithLimit } from "../../lib/concurrency.js" import { PRESIGN_CONCURRENCY } from "../media-presign.js" import { clampLimit } from "./pagination.js" @@ -30,6 +29,13 @@ import { resolveEventFilter, timelineDefaultNote, } from "./admin-event-helpers.js" +import type { + AdminEventMessageRecord, + AdminEventRecord, + AdminEventRepository, + AdminEventTimelineRecord, + ListEventsArgs, +} from "./admin-event-repository.js" // Re-exported so tests that import the helpers from their old home keep their import path. export { @@ -54,94 +60,10 @@ const ZERO_EVENT_COUNTS: AdminEventCounts = { flagged: 0, } -/** Field-identical to a report's reporter; the alias keeps the events domain's name for this person. */ -export type AdminOrganizerRecord = AdminPersonRecord - -export interface AdminEventTimelineRecord { - kind: string - note: string | null - who: string - createdAt: Date -} - -export interface AdminEventMessageRecord { - who: string - text: string - createdAt: Date -} - -export interface AdminEventRecord { - id: string - status: EventStatus - // Only 'cleanup' events may link reports or show the linked-report gallery. - eventKind: EventKind - flagged: boolean - title: string - place: string - attendees: number - capacity: number | null - bags: number - organizer: AdminOrganizerRecord | null - desc: string - address: string - lat: number - lng: number - scheduledAt: Date -} - -// A null `status` matches any status. -export interface ListEventsArgs { - q: string | null - status: EventStatus | null - flaggedOnly: boolean - cursor: string | null - limit: number - /** Restrict to events linked to this organization (adminListOrgEvents). */ - organizationId?: string - /** Time facet relative to `ref`: upcoming = scheduled_at >= ref, past = scheduled_at < ref. */ - when?: { kind: "upcoming" | "past"; ref: Date } -} - export interface EventMemberRef { userId: string } -export interface AdminEventRepository { - // Totals over the whole searched set rather than the first keyset page, so the filter chips stay - // accurate as the operator switches facets. - countByBucket(args: { q: string | null }): Promise - listEvents( - args: ListEventsArgs, - ): Promise<{ records: AdminEventRecord[]; nextCursor: string | null }> - getEvent(id: string): Promise - listTimeline(id: string): Promise - listMessages(id: string): Promise - // The only write path for cleanups.bags. Returns false when the cleanup is absent. - setBags(id: string, input: { bags: number; actorId: string | null }): Promise - // Returns the resulting flagged state, or null when the cleanup is absent. - toggleFlag( - id: string, - input: { reason: string | null; actorId: string | null }, - ): Promise - cancel(id: string, input: { note: string; actorId: string | null }): Promise - // The chat row, the 'message' timeline row and the per-member notification fan-out commit in one - // transaction. Returns the number of members notified, or null when the cleanup does not exist. - postMessage( - id: string, - input: { body: string; actorId: string }, - ): Promise<{ notified: number } | null> - // Only publicly visible reports appear in the gallery. - loadLinkedReports(id: string): Promise - // Returns the newly-linked ids, or null when the event is missing. Skips non-visible (held/hidden) ids. - linkReports( - id: string, - reportIds: string[], - actorId: string | null, - ): Promise<{ linked: string[] } | null> - // Returns true when a link existed (removed), false when there was none, null when the event is missing. - unlinkReport(id: string, reportId: string, actorId: string | null): Promise -} - export interface AdminEventServiceDeps { repo: AdminEventRepository // Defaults to returning the raw key, so offline tests see a thumb without a storage SDK. diff --git a/services/api/src/services/admin/admin-report-repository.drizzle.ts b/services/api/src/services/admin/admin-report-repository.drizzle.ts index 5be5cfdc..9c1885c6 100644 --- a/services/api/src/services/admin/admin-report-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-repository.drizzle.ts @@ -18,17 +18,17 @@ import { import { personSelect } from "./admin-person-sql.js" import { toPersonRecord } from "./admin-person.js" import { STATUS_BUCKETS, toTimelineKind } from "./admin-report-status.js" -import { - REPORT_VERIFIED_THRESHOLD, - type AdminReporterRecord, - type AdminReportMediaRecord, - type AdminReportRecord, - type AdminReportRepository, - type AdminReportRoutingRecord, - type AdminReportTimelineRecord, - type ListReportsArgs, - type ReportOutreachState, -} from "./admin-report-types.js" +import { REPORT_VERIFIED_THRESHOLD } from "./admin-report-types.js" +import type { + AdminReportMediaRecord, + AdminReportRecord, + AdminReportRepository, + AdminReportRoutingRecord, + AdminReportTimelineRecord, + AdminReporterRecord, + ListReportsArgs, + ReportOutreachState, +} from "./admin-report-repository.js" import type { AdminReportCounts, AdminReportStatus, diff --git a/services/api/src/services/admin/admin-report-repository.memory.ts b/services/api/src/services/admin/admin-report-repository.memory.ts index 1a09c51a..2e10a59f 100644 --- a/services/api/src/services/admin/admin-report-repository.memory.ts +++ b/services/api/src/services/admin/admin-report-repository.memory.ts @@ -2,15 +2,15 @@ import { randomUUID } from "node:crypto" import { isUuid } from "../../db/cursor-helpers.js" import { pageInMemoryById } from "./pagination.js" import type { - AdminReporterRecord, AdminReportMediaRecord, AdminReportRecord, AdminReportRepository, AdminReportRoutingRecord, AdminReportTimelineRecord, + AdminReporterRecord, ListReportsArgs, ReportOutreachState, -} from "./admin-report-types.js" +} from "./admin-report-repository.js" import type { AdminReportCounts, AdminReportStatus, diff --git a/services/api/src/services/admin/admin-report-repository.ts b/services/api/src/services/admin/admin-report-repository.ts new file mode 100644 index 00000000..ccae43af --- /dev/null +++ b/services/api/src/services/admin/admin-report-repository.ts @@ -0,0 +1,134 @@ +import type { + AdminReportCounts, + AdminReportStatus, + ReportCategory, + ReportOutreachStatus, + ReportTimelineItem, + ReportVisibility, +} from "@civfix/shared" +import type { AdminPersonRecord } from "./admin-person.js" + +export type AdminReporterRecord = AdminPersonRecord + +export interface AdminReportMediaRecord { + id: string + kind: "image" | "video" + r2Key: string + thumbKey: string | null + contentType?: string | null +} + +export interface AdminReportTimelineRecord { + status: AdminReportStatus + note: string | null + kind?: ReportTimelineItem["kind"] | null + who: string + createdAt: Date +} + +export interface AdminReportRoutingRecord { + geoid: string | null + dept: string + place: string + contact: string | null + routed: boolean + forwardSubjectTemplate?: string | null + forwardBodyTemplate?: string | null +} + +export interface ReportOutreachState { + status: ReportOutreachStatus + threadId: string | null + routedTo: string | null + routedAt: string | null + packetSent: boolean + sendFailed?: boolean + sendInFlight?: boolean +} + +export interface AdminReportRecord { + id: string + category: ReportCategory + status: AdminReportStatus + visibility: ReportVisibility + flagged: boolean + title: string + place: string + reporter: AdminReporterRecord | null + confirmations: number + address: string + desc: string + lat: number + lng: number + hasPhoto: boolean + previewMedia: AdminReportMediaRecord | null + createdAt: Date + referenceCode: string | null + verificationVerdict: "approved" | "rejected" | null + verifiedAt: Date | null + reporterReportVerified: boolean | null +} + +export interface ListReportsArgs { + q: string | null + statuses: AdminReportStatus[] | null + flaggedOnly: boolean + needsVerificationOnly: boolean + cursor: string | null + limit: number +} + +export interface AdminReportRepository { + listReports( + args: ListReportsArgs, + ): Promise<{ records: AdminReportRecord[]; nextCursor: string | null }> + countByBucket(args: { q: string | null }): Promise + getReport(id: string): Promise + listTimeline(id: string): Promise + getRouting(id: string): Promise + getOutreach(id: string): Promise + advanceStatusIfIn( + id: string, + input: { + from: readonly AdminReportStatus[] + to: AdminReportStatus + note: string + actorId: string | null + kind?: ReportTimelineItem["kind"] + }, + ): Promise + appendSystemTimeline( + id: string, + input: { note: string; kind: ReportTimelineItem["kind"]; body?: string | null }, + ): Promise + listMedia(id: string): Promise + setStatus( + id: string, + input: { + status: AdminReportStatus + note: string + actorId: string | null + kind?: ReportTimelineItem["kind"] + body?: string | null + }, + ): Promise + toggleFlag( + id: string, + input: { reason: string | null; actorId: string | null }, + ): Promise + remove(id: string, input: { note: string; actorId: string | null }): Promise + appendFollowup( + id: string, + input: { + note: string + actorId: string | null + to: "reporter" | "city" + destination: string + }, + ): Promise + setReportVerdict( + id: string, + input: { verdict: "approved" | "rejected"; actorId: string | null; note: string }, + ): Promise + withRouteLock(id: string, fn: () => Promise): Promise +} diff --git a/services/api/src/services/admin/admin-report-service.ts b/services/api/src/services/admin/admin-report-service.ts index 28fa3ced..624e8dda 100644 --- a/services/api/src/services/admin/admin-report-service.ts +++ b/services/api/src/services/admin/admin-report-service.ts @@ -42,16 +42,18 @@ import { } from "./mail-format.js" import { pickPreviewMedia, previewThumbnailUrl } from "./admin-report-types.js" import type { - AdminReportMediaRecord, - AdminReportRecord, AdminReportService, AdminReportServiceDeps, - AdminReportTimelineRecord, FollowupResult, - ListReportsArgs, - ReportOutreachState, RouteToJurisdictionResult, } from "./admin-report-types.js" +import type { + AdminReportMediaRecord, + AdminReportRecord, + AdminReportTimelineRecord, + ListReportsArgs, + ReportOutreachState, +} from "./admin-report-repository.js" export * from "./admin-report-types.js" export * from "./admin-report-status.js" diff --git a/services/api/src/services/admin/admin-report-types.ts b/services/api/src/services/admin/admin-report-types.ts index 46fb205d..2421288b 100644 --- a/services/api/src/services/admin/admin-report-types.ts +++ b/services/api/src/services/admin/admin-report-types.ts @@ -1,151 +1,21 @@ import type { - AdminReportCounts, AdminReportDTO, AdminReportListQuery, AdminReportListResponse, AdminReportStatus, - ReportCategory, ReportMedia, - ReportOutreachStatus, - ReportTimelineItem, - ReportVisibility, } from "@civfix/shared" import type { OutboundMailService } from "./outbound-mail-service.js" import type { FastifyBaseLogger } from "fastify" import type { NotificationService } from "../notification-service.js" import type { LinkedEventView } from "../cleanup-service.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" -import type { AdminPersonRecord } from "./admin-person.js" import type { ForwardTemplateReader } from "./forward-template-repository.js" import type { PresignPacketMedia } from "../media-presign.js" - -export type AdminReporterRecord = AdminPersonRecord - -export interface AdminReportMediaRecord { - id: string - kind: "image" | "video" - r2Key: string - thumbKey: string | null - contentType?: string | null -} - -export interface AdminReportTimelineRecord { - status: AdminReportStatus - note: string | null - kind?: ReportTimelineItem["kind"] | null - who: string - createdAt: Date -} - -export interface AdminReportRoutingRecord { - geoid: string | null - dept: string - place: string - contact: string | null - routed: boolean - forwardSubjectTemplate?: string | null - forwardBodyTemplate?: string | null -} - -export interface ReportOutreachState { - status: ReportOutreachStatus - threadId: string | null - routedTo: string | null - routedAt: string | null - packetSent: boolean - sendFailed?: boolean - sendInFlight?: boolean -} - -export interface AdminReportRecord { - id: string - category: ReportCategory - status: AdminReportStatus - visibility: ReportVisibility - flagged: boolean - title: string - place: string - reporter: AdminReporterRecord | null - confirmations: number - address: string - desc: string - lat: number - lng: number - hasPhoto: boolean - previewMedia: AdminReportMediaRecord | null - createdAt: Date - referenceCode: string | null - verificationVerdict: "approved" | "rejected" | null - verifiedAt: Date | null - reporterReportVerified: boolean | null -} - -export interface ListReportsArgs { - q: string | null - statuses: AdminReportStatus[] | null - flaggedOnly: boolean - needsVerificationOnly: boolean - cursor: string | null - limit: number -} +import type { AdminReportRepository } from "./admin-report-repository.js" export type ReporterNotifier = Pick -export interface AdminReportRepository { - listReports( - args: ListReportsArgs, - ): Promise<{ records: AdminReportRecord[]; nextCursor: string | null }> - countByBucket(args: { q: string | null }): Promise - getReport(id: string): Promise - listTimeline(id: string): Promise - getRouting(id: string): Promise - getOutreach(id: string): Promise - advanceStatusIfIn( - id: string, - input: { - from: readonly AdminReportStatus[] - to: AdminReportStatus - note: string - actorId: string | null - kind?: ReportTimelineItem["kind"] - }, - ): Promise - appendSystemTimeline( - id: string, - input: { note: string; kind: ReportTimelineItem["kind"]; body?: string | null }, - ): Promise - listMedia(id: string): Promise - setStatus( - id: string, - input: { - status: AdminReportStatus - note: string - actorId: string | null - kind?: ReportTimelineItem["kind"] - body?: string | null - }, - ): Promise - toggleFlag( - id: string, - input: { reason: string | null; actorId: string | null }, - ): Promise - remove(id: string, input: { note: string; actorId: string | null }): Promise - appendFollowup( - id: string, - input: { - note: string - actorId: string | null - to: "reporter" | "city" - destination: string - }, - ): Promise - setReportVerdict( - id: string, - input: { verdict: "approved" | "rejected"; actorId: string | null; note: string }, - ): Promise - withRouteLock(id: string, fn: () => Promise): Promise -} - export const REPORT_VERIFIED_THRESHOLD = 2 export function pickPreviewMedia( diff --git a/services/api/src/services/admin/admin-user-repository.drizzle.ts b/services/api/src/services/admin/admin-user-repository.drizzle.ts index ff523a9b..bec1ba6c 100644 --- a/services/api/src/services/admin/admin-user-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-user-repository.drizzle.ts @@ -17,7 +17,7 @@ import type { UserEventRecord, UserMessageRecord, UserReportRecord, -} from "./admin-user-service.js" +} from "./admin-user-repository.js" import type { AdminReportStatus, AdminUserCounts, diff --git a/services/api/src/services/admin/admin-user-repository.memory.ts b/services/api/src/services/admin/admin-user-repository.memory.ts index 8420c50c..883c00a3 100644 --- a/services/api/src/services/admin/admin-user-repository.memory.ts +++ b/services/api/src/services/admin/admin-user-repository.memory.ts @@ -8,7 +8,7 @@ import type { UserEventRecord, UserMessageRecord, UserReportRecord, -} from "./admin-user-service.js" +} from "./admin-user-repository.js" import type { AdminUserCounts, Role, UserStatus } from "@civfix/shared" export interface RecordedUserAudit { diff --git a/services/api/src/services/admin/admin-user-repository.ts b/services/api/src/services/admin/admin-user-repository.ts new file mode 100644 index 00000000..99f23bf0 --- /dev/null +++ b/services/api/src/services/admin/admin-user-repository.ts @@ -0,0 +1,116 @@ +import type { + AdminReportStatus, + AdminUserCounts, + CleanupMemberRole, + OrganizationMemberRole, + ReportCategory, + Risk, + Role, + UserStatus, +} from "@civfix/shared" + +export interface AdminUserRecord { + id: string + name: string + handle: string | null + emailVerified?: boolean + hasOauth?: boolean + city: string + role: Role + joinedAt: Date | null + lastActiveAt: Date | null + accountStatus: UserStatus + reports: number + cleanups: number + messages: number + removals: number + strikes: number + risk: Risk + flagged: boolean + flagReason: string | null + reportVerified: boolean + avatarUrl: string | null + deletedAt: Date | null +} + +export interface AdminUserOrganizationRecord { + id: string + slug: string + name: string + role: OrganizationMemberRole +} + +export interface UserReportRecord { + id: string + category: ReportCategory + title: string + place: string + status: AdminReportStatus + createdAt: Date +} + +export interface UserEventRecord { + id: string + title: string + place: string + role: CleanupMemberRole + attendees: number + whenAt: Date +} + +export interface UserMessageRecord { + id: string + text: string + thread: string + createdAt: Date + deletedAt: Date | null + source: "chat" | "group" | "dm" | "report" + sourceId: string | null +} + +export interface ListUsersArgs { + q: string | null + status: UserStatus | null + flaggedOnly: boolean + deletedOnly: boolean + cursor: string | null + limit: number +} + +export interface AdminUserRepository { + listUsers(args: ListUsersArgs): Promise<{ records: AdminUserRecord[]; nextCursor: string | null }> + countByFacet(args: { q: string | null }): Promise + userExists(id: string): Promise + getUser(id: string): Promise + listUserReports( + id: string, + cursor: string | null, + limit: number, + ): Promise<{ records: UserReportRecord[]; nextCursor: string | null }> + listUserEvents( + id: string, + cursor: string | null, + limit: number, + ): Promise<{ records: UserEventRecord[]; nextCursor: string | null }> + listUserMessages( + id: string, + cursor: string | null, + limit: number, + ): Promise<{ records: UserMessageRecord[]; nextCursor: string | null }> + toggleFlag( + id: string, + input: { reason: string | null; actorId: string | null }, + ): Promise + setStatus( + id: string, + input: { status: UserStatus; reason: string | null; actorId: string | null }, + ): Promise + applyRole(id: string, input: { role: Role; actorId: string | null }): Promise + listUserOrganizations(id: string): Promise + setReportVerified(id: string, input: { value: boolean; actorId: string | null }): Promise + removeUserMessage( + userId: string, + messageId: string, + input: { reason: string | null; actorId: string | null }, + ): Promise +} diff --git a/services/api/src/services/admin/admin-user-service.ts b/services/api/src/services/admin/admin-user-service.ts index 55a2c10d..f7266bad 100644 --- a/services/api/src/services/admin/admin-user-service.ts +++ b/services/api/src/services/admin/admin-user-service.ts @@ -5,8 +5,6 @@ import type { AdminUserListItemDTO, AdminUserListQuery, AdminUserListResponse, - OrganizationMemberRole, - Risk, Role, UserEventItemDTO, UserEventsResponse, @@ -16,9 +14,6 @@ import type { UserReportsResponse, UserStatus, UserSubListQuery, - AdminReportStatus, - ReportCategory, - CleanupMemberRole, } from "@civfix/shared" import { toRelAbs } from "./admin-format.js" import { clampLimit } from "./pagination.js" @@ -28,112 +23,11 @@ import { assertTargetIsNotOfficialAccount, assertTargetIsNotOperatorRole, } from "../../auth/operator-target.js" - -export interface AdminUserRecord { - id: string - name: string - handle: string | null - emailVerified?: boolean - hasOauth?: boolean - city: string - role: Role - joinedAt: Date | null - lastActiveAt: Date | null - accountStatus: UserStatus - reports: number - cleanups: number - messages: number - removals: number - strikes: number - risk: Risk - flagged: boolean - flagReason: string | null - reportVerified: boolean - avatarUrl: string | null - deletedAt: Date | null -} - -export interface AdminUserOrganizationRecord { - id: string - slug: string - name: string - role: OrganizationMemberRole -} - -export interface UserReportRecord { - id: string - category: ReportCategory - title: string - place: string - status: AdminReportStatus - createdAt: Date -} - -export interface UserEventRecord { - id: string - title: string - place: string - role: CleanupMemberRole - attendees: number - whenAt: Date -} - -export interface UserMessageRecord { - id: string - text: string - thread: string - createdAt: Date - deletedAt: Date | null - source: "chat" | "group" | "dm" | "report" - sourceId: string | null -} - -export interface ListUsersArgs { - q: string | null - status: UserStatus | null - flaggedOnly: boolean - deletedOnly: boolean - cursor: string | null - limit: number -} - -export interface AdminUserRepository { - listUsers(args: ListUsersArgs): Promise<{ records: AdminUserRecord[]; nextCursor: string | null }> - countByFacet(args: { q: string | null }): Promise - userExists(id: string): Promise - getUser(id: string): Promise - listUserReports( - id: string, - cursor: string | null, - limit: number, - ): Promise<{ records: UserReportRecord[]; nextCursor: string | null }> - listUserEvents( - id: string, - cursor: string | null, - limit: number, - ): Promise<{ records: UserEventRecord[]; nextCursor: string | null }> - listUserMessages( - id: string, - cursor: string | null, - limit: number, - ): Promise<{ records: UserMessageRecord[]; nextCursor: string | null }> - toggleFlag( - id: string, - input: { reason: string | null; actorId: string | null }, - ): Promise - setStatus( - id: string, - input: { status: UserStatus; reason: string | null; actorId: string | null }, - ): Promise - applyRole(id: string, input: { role: Role; actorId: string | null }): Promise - listUserOrganizations(id: string): Promise - setReportVerified(id: string, input: { value: boolean; actorId: string | null }): Promise - removeUserMessage( - userId: string, - messageId: string, - input: { reason: string | null; actorId: string | null }, - ): Promise -} +import type { + AdminUserRecord, + AdminUserRepository, + ListUsersArgs, +} from "./admin-user-repository.js" export interface SessionControl { applyStatus(userId: string, status: UserStatus): Promise diff --git a/services/api/src/services/admin/analytics-repository.drizzle.ts b/services/api/src/services/admin/analytics-repository.drizzle.ts index f83433a3..3ed5243e 100644 --- a/services/api/src/services/admin/analytics-repository.drizzle.ts +++ b/services/api/src/services/admin/analytics-repository.drizzle.ts @@ -13,7 +13,7 @@ import type { TopContributorRow, TopJurisdictionRow, WeekBucket, -} from "./analytics-types.js" +} from "./analytics-repository.js" import type { ReportCategory } from "@civfix/shared" import { jurisdictionHasAnyContactExpr } from "./sql-fragments.js" diff --git a/services/api/src/services/admin/analytics-repository.memory.ts b/services/api/src/services/admin/analytics-repository.memory.ts index c1e209d7..f96b2036 100644 --- a/services/api/src/services/admin/analytics-repository.memory.ts +++ b/services/api/src/services/admin/analytics-repository.memory.ts @@ -17,7 +17,7 @@ import type { TopContributorRow, TopJurisdictionRow, WeekBucket, -} from "./analytics-types.js" +} from "./analytics-repository.js" /** All zero, so a test only sets what it asserts. */ function zeroKpis(): KpiAggregates { diff --git a/services/api/src/services/admin/analytics-repository.ts b/services/api/src/services/admin/analytics-repository.ts new file mode 100644 index 00000000..86aadc89 --- /dev/null +++ b/services/api/src/services/admin/analytics-repository.ts @@ -0,0 +1,103 @@ +import type { ReportCategory } from "@civfix/shared" + +/** Two-period counter (this period vs the prior period) used to compute a KPI delta. */ +export interface PeriodCount { + current: number + previous: number +} + +export interface KpiAggregates { + pins: PeriodCount + resolvedRatio: { current: number; previous: number } + cleanupsPlanned: PeriodCount + events: PeriodCount + newUsers: PeriodCount +} + +export interface WeekBucket { + weekStart: Date + count: number +} + +export interface MonthBucket { + year: number + month: number + count: number +} + +export interface CategoryCount { + category: ReportCategory + count: number +} + +export interface CategoryMedian { + category: ReportCategory + medianHours: number +} + +export interface FunnelCounts { + dropped: number + routed: number + acknowledged: number + resolved: number +} + +export interface CoverageCounts { + mapped: number + needsMapping: number +} + +export interface EventAggregates { + thisMonth: number + volunteers: number + bags: number + byMonth: MonthBucket[] +} + +export interface TopJurisdictionRow { + org: string + pins: number + resolved: number +} + +export interface TopContributorRow { + name: string + city: string + reports: number + cleanups: number +} + +export interface HeatmapCellRow { + geoid: string + name: string + density: number + lat: number + lng: number +} + +/** + * One cohort (signup year/month) with its size and, per trailing period index, the number of cohort + * members active in that period (period 0 = the signup month). The service converts the active counts to + * retention ratios. + */ +export interface RetentionRow { + year: number + month: number + size: number + /** activeByPeriod[i] = members active in period i (0-based; 0 is the signup month). */ + activeByPeriod: number[] +} + +export interface AnalyticsRepository { + kpis(): Promise + pinsByWeek(weeks: number): Promise + byCategory(): Promise + funnel(): Promise + coverage(): Promise + resolutionByCategory(): Promise + events(months: number): Promise + topJurisdictions(limit: number): Promise + topContributors(limit: number): Promise + heatmap(limit: number): Promise + retention(cohorts: number): Promise +} diff --git a/services/api/src/services/admin/analytics-shaping.ts b/services/api/src/services/admin/analytics-shaping.ts index 4811b255..d19a0c53 100644 --- a/services/api/src/services/admin/analytics-shaping.ts +++ b/services/api/src/services/admin/analytics-shaping.ts @@ -9,18 +9,18 @@ import type { AnalyticsRetentionResponse, ReportCategory, } from "@civfix/shared" -import { - ANALYTICS_CATEGORIES, - type CategoryCount, - type CategoryMedian, - type CoverageCounts, - type EventAggregates, - type FunnelCounts, - type KpiAggregates, - type MonthBucket, - type RetentionRow, - type WeekBucket, -} from "./analytics-types.js" +import { ANALYTICS_CATEGORIES } from "./analytics-types.js" +import type { + CategoryCount, + CategoryMedian, + CoverageCounts, + EventAggregates, + FunnelCounts, + KpiAggregates, + MonthBucket, + RetentionRow, + WeekBucket, +} from "./analytics-repository.js" import { MS_PER_DAY, MS_PER_WEEK } from "../../lib/time.js" const MONTH_ABBR = [ diff --git a/services/api/src/services/admin/analytics-types.ts b/services/api/src/services/admin/analytics-types.ts index 03984827..93af37ed 100644 --- a/services/api/src/services/admin/analytics-types.ts +++ b/services/api/src/services/admin/analytics-types.ts @@ -13,6 +13,7 @@ import type { AnalyticsTopJurisdictionsResponse, ReportCategory, } from "@civfix/shared" +import type { AnalyticsRepository } from "./analytics-repository.js" // The dashboard's fixed bar series, so a category with no reports still renders a 0 bar. Derived from // ADMIN_CATEGORIES (from the contract enum): a hand-listed copy drifted from REPORT_CATEGORY_VALUES, and a @@ -23,108 +24,6 @@ export const PINS_BY_WEEK_WEEKS = 8 export const EVENTS_BY_MONTH_MONTHS = 8 export const RETENTION_COHORTS = 6 -/** Two-period counter (this period vs the prior period) used to compute a KPI delta. */ -export interface PeriodCount { - current: number - previous: number -} - -export interface KpiAggregates { - pins: PeriodCount - resolvedRatio: { current: number; previous: number } - cleanupsPlanned: PeriodCount - events: PeriodCount - newUsers: PeriodCount -} - -export interface WeekBucket { - weekStart: Date - count: number -} - -export interface MonthBucket { - year: number - month: number - count: number -} - -export interface CategoryCount { - category: ReportCategory - count: number -} - -export interface CategoryMedian { - category: ReportCategory - medianHours: number -} - -export interface FunnelCounts { - dropped: number - routed: number - acknowledged: number - resolved: number -} - -export interface CoverageCounts { - mapped: number - needsMapping: number -} - -export interface EventAggregates { - thisMonth: number - volunteers: number - bags: number - byMonth: MonthBucket[] -} - -export interface TopJurisdictionRow { - org: string - pins: number - resolved: number -} - -export interface TopContributorRow { - name: string - city: string - reports: number - cleanups: number -} - -export interface HeatmapCellRow { - geoid: string - name: string - density: number - lat: number - lng: number -} - -/** - * One cohort (signup year/month) with its size and, per trailing period index, the number of cohort - * members active in that period (period 0 = the signup month). The service converts the active counts to - * retention ratios. - */ -export interface RetentionRow { - year: number - month: number - size: number - /** activeByPeriod[i] = members active in period i (0-based; 0 is the signup month). */ - activeByPeriod: number[] -} - -export interface AnalyticsRepository { - kpis(): Promise - pinsByWeek(weeks: number): Promise - byCategory(): Promise - funnel(): Promise - coverage(): Promise - resolutionByCategory(): Promise - events(months: number): Promise - topJurisdictions(limit: number): Promise - topContributors(limit: number): Promise - heatmap(limit: number): Promise - retention(cohorts: number): Promise -} - /** Default row cap for the table-style analytics (top jurisdictions / contributors). */ export const ANALYTICS_TABLE_LIMIT = 10 /** Heatmap cell cap (a denser list than the small tables). */ diff --git a/services/api/src/services/admin/audit-repository.drizzle.ts b/services/api/src/services/admin/audit-repository.drizzle.ts index 44d45ccb..3e1383fd 100644 --- a/services/api/src/services/admin/audit-repository.drizzle.ts +++ b/services/api/src/services/admin/audit-repository.drizzle.ts @@ -7,7 +7,7 @@ import { paginateKeyset, parseKeysetCursor, } from "../../db/cursor-helpers.js" -import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" +import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-repository.js" import type { WriteAuditInput } from "./audit.js" import { likeContains } from "../../db/like.js" diff --git a/services/api/src/services/admin/audit-repository.memory.ts b/services/api/src/services/admin/audit-repository.memory.ts index afa97a54..e6111e97 100644 --- a/services/api/src/services/admin/audit-repository.memory.ts +++ b/services/api/src/services/admin/audit-repository.memory.ts @@ -1,4 +1,4 @@ -import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" +import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-repository.js" import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" export interface SeedAuditInput { diff --git a/services/api/src/services/admin/audit-repository.ts b/services/api/src/services/admin/audit-repository.ts new file mode 100644 index 00000000..bdef468d --- /dev/null +++ b/services/api/src/services/admin/audit-repository.ts @@ -0,0 +1,22 @@ +export interface ListAuditArgs { + actor: string | null + action: string | null + target: string | null + cursor: string | null + limit: number +} + +export interface AuditRecord { + id: string + actorId: string | null + /** Null for a system action or an unknown actor. */ + actorName: string | null + action: string + target: string | null + meta: Record | null + createdAt: Date +} + +export interface AuditRepository { + list(args: ListAuditArgs): Promise<{ records: AuditRecord[]; nextCursor: string | null }> +} diff --git a/services/api/src/services/admin/audit-service.ts b/services/api/src/services/admin/audit-service.ts index afacde79..dd50243f 100644 --- a/services/api/src/services/admin/audit-service.ts +++ b/services/api/src/services/admin/audit-service.ts @@ -1,28 +1,6 @@ import type { AuditListQuery, AuditListResponse, AuditLogEntryDTO } from "@civfix/shared" import { clampLimit } from "./pagination.js" - -export interface ListAuditArgs { - actor: string | null - action: string | null - target: string | null - cursor: string | null - limit: number -} - -export interface AuditRecord { - id: string - actorId: string | null - /** Null for a system action or an unknown actor. */ - actorName: string | null - action: string - target: string | null - meta: Record | null - createdAt: Date -} - -export interface AuditRepository { - list(args: ListAuditArgs): Promise<{ records: AuditRecord[]; nextCursor: string | null }> -} +import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-repository.js" // `created_at` is `DEFAULT now()` but nullable in the DDL: a hand-inserted NULL row would otherwise throw // on `.toISOString()` and 500 the whole list. diff --git a/services/api/src/services/admin/discovery-repository.drizzle.ts b/services/api/src/services/admin/discovery-repository.drizzle.ts index 19834d2b..bc413541 100644 --- a/services/api/src/services/admin/discovery-repository.drizzle.ts +++ b/services/api/src/services/admin/discovery-repository.drizzle.ts @@ -11,16 +11,16 @@ import { type CategoryCountRow, } from "./category-counts.js" import { categoryCountsFragment, categoryCountsProjection } from "./category-counts-sql.js" -import { - type DiscoveryContactRecord, - type DiscoveryContactSuggestionRecord, - type DiscoveryDetailRecord, - type DiscoveryNoteRecord, - type DiscoveryRepository, - type DiscoverySamplePinRecord, - type DiscoveryTaskRecord, - type ListDiscoveryArgs, -} from "./discovery-service.js" +import type { + DiscoveryContactRecord, + DiscoveryContactSuggestionRecord, + DiscoveryDetailRecord, + DiscoveryNoteRecord, + DiscoveryRepository, + DiscoverySamplePinRecord, + DiscoveryTaskRecord, + ListDiscoveryArgs, +} from "./discovery-repository.js" import { invalidateDirectoryFacetCache, upsertJurisdictionContacts, diff --git a/services/api/src/services/admin/discovery-repository.memory.ts b/services/api/src/services/admin/discovery-repository.memory.ts index a5d9f1f4..8fb36a76 100644 --- a/services/api/src/services/admin/discovery-repository.memory.ts +++ b/services/api/src/services/admin/discovery-repository.memory.ts @@ -3,17 +3,17 @@ import { randomUUID } from "node:crypto" import { pageInMemoryById } from "./pagination.js" -import { - computeContactState, - type DiscoveryContactRecord, - type DiscoveryContactSuggestionRecord, - type DiscoveryDetailRecord, - type DiscoveryNoteRecord, - type DiscoveryRepository, - type DiscoverySamplePinRecord, - type DiscoveryTaskRecord, - type ListDiscoveryArgs, -} from "./discovery-service.js" +import { computeContactState } from "./discovery-service.js" +import type { + DiscoveryContactRecord, + DiscoveryContactSuggestionRecord, + DiscoveryDetailRecord, + DiscoveryNoteRecord, + DiscoveryRepository, + DiscoverySamplePinRecord, + DiscoveryTaskRecord, + ListDiscoveryArgs, +} from "./discovery-repository.js" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" export interface SeededDiscoveryTask { diff --git a/services/api/src/services/admin/discovery-repository.ts b/services/api/src/services/admin/discovery-repository.ts new file mode 100644 index 00000000..c5fedbc0 --- /dev/null +++ b/services/api/src/services/admin/discovery-repository.ts @@ -0,0 +1,103 @@ +import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" + +export interface DiscoveryTaskRecord { + id: string + geoid: string + place: string + layer: JurisdictionLayer + population: number | null + status: string + perCategory: Partial> + total: number + oldestWaitingAt: Date | null + newestWaitingAt: Date | null + contactCategories: ReportCategory[] + hasDefaultContact: boolean +} + +/** A null email means the contact row is on file but blank. */ +export interface DiscoveryContactRecord { + category: ReportCategory + email: string | null +} + +export interface DiscoverySamplePinRecord { + category: ReportCategory + lat: number + lng: number +} + +export interface DiscoveryNoteRecord { + text: string + who: string + createdAt: Date +} + +/** Stored as an audit_log `discovery.contact_suggested` row by the public suggest-contact endpoint. */ +export interface DiscoveryContactSuggestionRecord { + email: string | null + formUrl: string | null + note: string | null + createdAt: Date +} + +export interface DiscoveryDetailRecord { + task: DiscoveryTaskRecord + contacts: DiscoveryContactRecord[] + placeGeojson: unknown | null + samplePins: DiscoverySamplePinRecord[] + center: [number, number] | null + zoom: number | null +} + +export type DiscoveryFilter = "all" | "attention" | "clear" + +export type DiscoverySort = "pop" | "reports" + +export interface ListDiscoveryArgs { + q: string | null + filter: DiscoveryFilter + sort: DiscoverySort + cursor: string | null + limit: number +} + +export interface DiscoveryRepository { + /** The repository owns the filter and sort semantics so the service stays a pure projector. */ + listTasks( + args: ListDiscoveryArgs, + ): Promise<{ records: DiscoveryTaskRecord[]; nextCursor: string | null }> + getDetail(id: string): Promise + /** Oldest first. */ + listNotes(id: string): Promise + /** Oldest first. */ + listContactSuggestions(geoid: string): Promise + getTask(id: string): Promise + addNote( + id: string, + input: { text: string; actorId: string | null; who: string }, + ): Promise + /** + * Opens an abuse_flag against the task's sample report when one is on file and marks the task + * in_progress. False when the task does not exist. + */ + flagTask(id: string, input: { reason: string | null; actorId: string | null }): Promise + /** + * Upserts contacts and the form URL without routing: contact_updated_at, pending pins and outreach are + * left alone. False when the task does not exist. + */ + saveDraft( + id: string, + input: { + contacts: Partial> + defaultEmails: string[] + formUrl: string | null + actorId: string | null + }, + ): Promise + /** + * Idempotent: at most one open task per geoid (ON CONFLICT (geoid) WHERE status <> 'done' DO NOTHING). + * False when an open task already existed or the jurisdiction is unknown. + */ + materializeDiscoveryTask(input: { geoid: string; population?: number | null }): Promise +} diff --git a/services/api/src/services/admin/discovery-service.ts b/services/api/src/services/admin/discovery-service.ts index f733b64c..2892bce0 100644 --- a/services/api/src/services/admin/discovery-service.ts +++ b/services/api/src/services/admin/discovery-service.ts @@ -21,11 +21,17 @@ import type { DiscoverySamplePin, DiscoveryTaskDTO, DiscoveryTaskDetailDTO, - JurisdictionLayer, PerCategoryCounts, Priority, ReportCategory, } from "@civfix/shared" +import type { + DiscoveryContactSuggestionRecord, + DiscoveryNoteRecord, + DiscoveryRepository, + DiscoveryTaskRecord, + ListDiscoveryArgs, +} from "./discovery-repository.js" /** * An alias, never a hand-copied list: a second list is how a new category silently drops out of the @@ -42,107 +48,6 @@ const SUGGESTION_NOTE_AUTHOR = "Reporter" const NO_WAITING_REPORT_LABEL = "-" -export interface DiscoveryTaskRecord { - id: string - geoid: string - place: string - layer: JurisdictionLayer - population: number | null - status: string - perCategory: Partial> - total: number - oldestWaitingAt: Date | null - newestWaitingAt: Date | null - contactCategories: ReportCategory[] - hasDefaultContact: boolean -} - -/** A null email means the contact row is on file but blank. */ -export interface DiscoveryContactRecord { - category: ReportCategory - email: string | null -} - -export interface DiscoverySamplePinRecord { - category: ReportCategory - lat: number - lng: number -} - -export interface DiscoveryNoteRecord { - text: string - who: string - createdAt: Date -} - -/** Stored as an audit_log `discovery.contact_suggested` row by the public suggest-contact endpoint. */ -export interface DiscoveryContactSuggestionRecord { - email: string | null - formUrl: string | null - note: string | null - createdAt: Date -} - -export interface DiscoveryDetailRecord { - task: DiscoveryTaskRecord - contacts: DiscoveryContactRecord[] - placeGeojson: unknown | null - samplePins: DiscoverySamplePinRecord[] - center: [number, number] | null - zoom: number | null -} - -export type DiscoveryFilter = "all" | "attention" | "clear" -export type DiscoverySort = "pop" | "reports" - -export interface ListDiscoveryArgs { - q: string | null - filter: DiscoveryFilter - sort: DiscoverySort - cursor: string | null - limit: number -} - -export interface DiscoveryRepository { - /** The repository owns the filter and sort semantics so the service stays a pure projector. */ - listTasks( - args: ListDiscoveryArgs, - ): Promise<{ records: DiscoveryTaskRecord[]; nextCursor: string | null }> - getDetail(id: string): Promise - /** Oldest first. */ - listNotes(id: string): Promise - /** Oldest first. */ - listContactSuggestions(geoid: string): Promise - getTask(id: string): Promise - addNote( - id: string, - input: { text: string; actorId: string | null; who: string }, - ): Promise - /** - * Opens an abuse_flag against the task's sample report when one is on file and marks the task - * in_progress. False when the task does not exist. - */ - flagTask(id: string, input: { reason: string | null; actorId: string | null }): Promise - /** - * Upserts contacts and the form URL without routing: contact_updated_at, pending pins and outreach are - * left alone. False when the task does not exist. - */ - saveDraft( - id: string, - input: { - contacts: Partial> - defaultEmails: string[] - formUrl: string | null - actorId: string | null - }, - ): Promise - /** - * Idempotent: at most one open task per geoid (ON CONFLICT (geoid) WHERE status <> 'done' DO NOTHING). - * False when an open task already existed or the jurisdiction is unknown. - */ - materializeDiscoveryTask(input: { geoid: string; population?: number | null }): Promise -} - export function fullPerCategoryCounts( partial: Partial>, ): PerCategoryCounts { diff --git a/services/api/src/services/admin/forward-template-service.ts b/services/api/src/services/admin/forward-template-service.ts index 9af6af56..235cdd13 100644 --- a/services/api/src/services/admin/forward-template-service.ts +++ b/services/api/src/services/admin/forward-template-service.ts @@ -7,7 +7,7 @@ import { type SetForwardTemplateDefaultRequest, } from "@civfix/shared" import { buildReportPacket, type PacketMediaLink } from "./mail-format.js" -import type { AdminReportRecord, AdminReportRoutingRecord } from "./admin-report-types.js" +import type { AdminReportRecord, AdminReportRoutingRecord } from "./admin-report-repository.js" import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, diff --git a/services/api/src/services/admin/gov-claims-repository.drizzle.ts b/services/api/src/services/admin/gov-claims-repository.drizzle.ts index 27b37af2..7a9b5b48 100644 --- a/services/api/src/services/admin/gov-claims-repository.drizzle.ts +++ b/services/api/src/services/admin/gov-claims-repository.drizzle.ts @@ -13,13 +13,13 @@ import { parseKeysetCursor, } from "../../db/cursor-helpers.js" import { ilikeAnyOf } from "./sql-fragments.js" -import { - GOV_CHECKS, - type GovCheckRecord, - type GovClaimRecord, - type GovClaimsRepository, - type ListGovClaimsArgs, -} from "./gov-claims-service.js" +import { GOV_CHECKS } from "./gov-claims-service.js" +import type { + GovCheckRecord, + GovClaimRecord, + GovClaimsRepository, + ListGovClaimsArgs, +} from "./gov-claims-repository.js" import type { GovCheckStatus, GovMethod, GovVerificationCheck } from "@civfix/shared" interface GovClaimRow { diff --git a/services/api/src/services/admin/gov-claims-repository.memory.ts b/services/api/src/services/admin/gov-claims-repository.memory.ts index c2100c40..b9d7488b 100644 --- a/services/api/src/services/admin/gov-claims-repository.memory.ts +++ b/services/api/src/services/admin/gov-claims-repository.memory.ts @@ -2,14 +2,13 @@ import { randomUUID } from "node:crypto" import { pageInMemoryById } from "./pagination.js" -import { - type GovCheckRecord, - type GovClaimRecord, - type GovClaimsRepository, - type ListGovClaimsArgs, - type ProvisionedUser, - type UserProvisioner, -} from "./gov-claims-service.js" +import { type ProvisionedUser, type UserProvisioner } from "./gov-claims-service.js" +import type { + GovCheckRecord, + GovClaimRecord, + GovClaimsRepository, + ListGovClaimsArgs, +} from "./gov-claims-repository.js" import type { GovCheckStatus, GovVerificationCheck, Role } from "@civfix/shared" export class InMemoryGovClaimsRepository implements GovClaimsRepository { diff --git a/services/api/src/services/admin/gov-claims-repository.ts b/services/api/src/services/admin/gov-claims-repository.ts new file mode 100644 index 00000000..d40d648a --- /dev/null +++ b/services/api/src/services/admin/gov-claims-repository.ts @@ -0,0 +1,68 @@ +import type { + GovCheckStatus, + GovClaimStatus, + GovMethod, + GovVerificationCheck, +} from "@civfix/shared" + +export interface GovCheckRecord { + status: GovCheckStatus + evidence: string | null + note: string | null +} + +export interface GovClaimRecord { + id: string + userId: string | null + name: string + title: string | null + org: string | null + jurisdictionGeoid: string | null + method: GovMethod + contactEmail: string | null + status: GovClaimStatus + checks: Partial> + rejectReason: string | null + createdAt: Date +} + +export type GovClaimFilter = "all" | GovClaimStatus + +export type GovClaimSort = "newest" | "oldest" + +export interface ListGovClaimsArgs { + q: string | null + filter: GovClaimFilter + sort: GovClaimSort + cursor: string | null + limit: number +} + +export interface GovClaimsRepository { + list(args: ListGovClaimsArgs): Promise<{ records: GovClaimRecord[]; nextCursor: string | null }> + getClaim(id: string): Promise + /** Null when the claim does not exist or is no longer pending. */ + setCheck( + id: string, + input: { + check: GovVerificationCheck + status: GovCheckStatus + evidence: string | null + note: string | null + actorId: string | null + }, + ): Promise + /** + * Persists only the claim transition and the user link; provisioning and the role grant belong to the + * service. Null when the claim does not exist or is not pending. + */ + approve( + id: string, + input: { userId: string; actorId: string | null; note: string | null }, + ): Promise + /** Null when the claim does not exist or is not pending. */ + reject( + id: string, + input: { reason: string; actorId: string | null }, + ): Promise +} diff --git a/services/api/src/services/admin/gov-claims-service.ts b/services/api/src/services/admin/gov-claims-service.ts index 564ebe74..52a4e70e 100644 --- a/services/api/src/services/admin/gov-claims-service.ts +++ b/services/api/src/services/admin/gov-claims-service.ts @@ -12,12 +12,17 @@ import type { GovClaimDTO, GovClaimListQuery, GovClaimListResponse, - GovClaimStatus, - GovMethod, GovVerificationCheck, Role, } from "@civfix/shared" import { clampLimit } from "./pagination.js" +import type { + GovCheckRecord, + GovClaimRecord, + GovClaimSort, + GovClaimsRepository, + ListGovClaimsArgs, +} from "./gov-claims-repository.js" /** Display order. */ export const GOV_CHECKS: readonly GovVerificationCheck[] = ["linkedin", "directory", "callback"] @@ -30,43 +35,10 @@ const OPERATOR_ROLE: Role = "operator" const GOV_CLAIM_NOT_FOUND = "Gov claim not found" const GOV_CLAIM_NOT_PENDING = "Gov claim is not pending" -export interface GovCheckRecord { - status: GovCheckStatus - evidence: string | null - note: string | null -} - -export interface GovClaimRecord { - id: string - userId: string | null - name: string - title: string | null - org: string | null - jurisdictionGeoid: string | null - method: GovMethod - contactEmail: string | null - status: GovClaimStatus - checks: Partial> - rejectReason: string | null - createdAt: Date -} - -export type GovClaimFilter = "all" | GovClaimStatus - -export type GovClaimSort = "newest" | "oldest" - function parseGovClaimSort(sort: string | undefined): GovClaimSort { return sort === "oldest" ? "oldest" : "newest" } -export interface ListGovClaimsArgs { - q: string | null - filter: GovClaimFilter - sort: GovClaimSort - cursor: string | null - limit: number -} - export interface ProvisionedUser { id: string email: string | null @@ -81,35 +53,6 @@ export interface UserProvisioner { setRole(id: string, role: Role): Promise } -export interface GovClaimsRepository { - list(args: ListGovClaimsArgs): Promise<{ records: GovClaimRecord[]; nextCursor: string | null }> - getClaim(id: string): Promise - /** Null when the claim does not exist or is no longer pending. */ - setCheck( - id: string, - input: { - check: GovVerificationCheck - status: GovCheckStatus - evidence: string | null - note: string | null - actorId: string | null - }, - ): Promise - /** - * Persists only the claim transition and the user link; provisioning and the role grant belong to the - * service. Null when the claim does not exist or is not pending. - */ - approve( - id: string, - input: { userId: string; actorId: string | null; note: string | null }, - ): Promise - /** Null when the claim does not exist or is not pending. */ - reject( - id: string, - input: { reason: string; actorId: string | null }, - ): Promise -} - export function toChecksDTO( checks: Partial>, ): GovChecks { diff --git a/services/api/src/services/admin/home-service.ts b/services/api/src/services/admin/home-service.ts index bb4d16cb..f8401da2 100644 --- a/services/api/src/services/admin/home-service.ts +++ b/services/api/src/services/admin/home-service.ts @@ -6,7 +6,7 @@ import type { HomeSummaryResponse, ReportStatus, } from "@civfix/shared" -import type { AnalyticsRepository } from "./analytics-types.js" +import type { AnalyticsRepository } from "./analytics-repository.js" import { buildCoverage, buildPinsByWeek, round1 } from "./analytics-shaping.js" import { PINS_BY_WEEK_WEEKS } from "./analytics-types.js" import type { diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index 17a3406a..2e8bb756 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -11,7 +11,8 @@ import { makeDrizzleInboundRepository, type InboundRepository, } from "./inbound-repository.drizzle.js" -import type { AdminReportRepository, ReporterNotifier } from "./admin-report-service.js" +import type { ReporterNotifier } from "./admin-report-service.js" +import type { AdminReportRepository } from "./admin-report-repository.js" import { detectBounce, handleBounce } from "./inbound-bounce.js" import { applyInboundEffects, diff --git a/services/api/src/services/admin/inbound-thread-correlation.ts b/services/api/src/services/admin/inbound-thread-correlation.ts index 94172f86..6d21e986 100644 --- a/services/api/src/services/admin/inbound-thread-correlation.ts +++ b/services/api/src/services/admin/inbound-thread-correlation.ts @@ -6,7 +6,8 @@ import type { MailThreadRecord, } from "./mail-repository.drizzle.js" import { makeDrizzleAdminReportRepository } from "./admin-report-repository.drizzle.js" -import type { AdminReportRepository, ReporterNotifier } from "./admin-report-service.js" +import type { ReporterNotifier } from "./admin-report-service.js" +import type { AdminReportRepository } from "./admin-report-repository.js" import { JURISDICTION_REPLY_NOTE } from "./admin-report-status.js" import { makeDrizzleCleanupRepository } from "../cleanup-repository.drizzle.js" import type { CleanupRepository } from "../cleanup-service.js" diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts index a89f9980..7e5410c8 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts @@ -20,7 +20,7 @@ import type { ListDirectoryResult, PatchContactsInput, SaveContactsInput, -} from "./jurisdiction-contacts-types.js" +} from "./jurisdiction-contacts-repository.js" import { AppError } from "@civfix/shared" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" import { ilikeAnyOf } from "./sql-fragments.js" diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts index f5e6c945..f4c9cf83 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts @@ -15,7 +15,7 @@ import type { ListDirectoryResult, PatchContactsInput, SaveContactsInput, -} from "./jurisdiction-contacts-types.js" +} from "./jurisdiction-contacts-repository.js" import { AppError } from "@civfix/shared" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.ts new file mode 100644 index 00000000..a21918c0 --- /dev/null +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.ts @@ -0,0 +1,120 @@ +import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" + +export interface JurisdictionDirectoryRecord { + geoid: string + name: string + layer: JurisdictionLayer + population: number | null + /** Legacy contact_emails[]. */ + defaultEmails: string[] + categoryContacts: { category: ReportCategory; email: string | null }[] + hasDefaultContact: boolean + reportFormUrl: string | null + /** Open reports still waiting on a routing contact (un-routed, un-closed). */ + reportsWaiting: number + perCategoryCounts: Partial> + /** Shares reportsWaiting's predicate. */ + oldestReportAt: Date | null + lastRoutedAt: Date | null + /** Takes precedence over verified/pending. */ + bounced: boolean + contactUpdatedAt: Date | null + flaggedAt: Date | null + handle: string | null + /** Null means the built-in default packet. */ + forwardSubjectTemplate: string | null + forwardBodyTemplate: string | null +} + +/** + * "routed" = any contact on file (email or form); "none" = neither; "needs_mapping" = waiting reports and + * no routing contact. + */ +export type DirectoryFilter = "all" | "email" | "form" | "none" | "routed" | "needs_mapping" + +/** "oldest" ranks by each jurisdiction's oldest still-waiting report: the longest-unrouted backlog. */ +export type DirectorySort = "population" | "reports" | "name" | "oldest" + +export interface ListDirectoryArgs { + q: string | null + filter: DirectoryFilter + layer: JurisdictionLayer | null + sort: DirectorySort + cursor: string | null + limit: number +} + +export interface ListDirectoryResult { + records: JurisdictionDirectoryRecord[] + nextCursor: string | null + /** First page only. */ + total: number | null + /** First page only. */ + facets: { routed: number; unrouted: number } | null +} + +export interface JurisdictionGeometryRecord { + geoid: string + name: string + layer: JurisdictionLayer + bbox: [number, number, number, number] + centroid: [number, number] + geometry: { type: string; coordinates: unknown[] } +} + +export interface SaveContactsInput { + contacts: Partial> + defaultEmails: string[] + formUrl: string | null + forwardSubjectTemplate?: string | null + forwardBodyTemplate?: string | null +} + +/** + * Per-category contacts, the @handle and the forward templates clear on null/"". The legacy + * `defaultEmails` and `formUrl` do not: the shared contacts upsert only writes a non-empty array and + * COALESCEs the form URL, so `[]` / null mean "leave alone". Making them clearable is a wire-semantics + * decision, since an operator form that always posts the field would otherwise wipe it. + */ +export interface PatchContactsInput { + contacts?: Partial> + /** An empty array is "unchanged", not a clear. */ + defaultEmails?: string[] + /** null/"" is "unchanged", not a clear. */ + formUrl?: string | null + notes?: string | null + flagged?: boolean + flagReason?: string | null + handle?: string | null + forwardSubjectTemplate?: string | null + forwardBodyTemplate?: string | null +} + +export interface JurisdictionContactsRepository { + jurisdictionExists(geoid: string): Promise + /** + * One short transaction commits the contacts, the discovery task's resolution and the + * discovery.contacts_saved audit row, so an audit failure rolls the save back. Saving a contact mails + * nobody, so the geoid's waiting reports are left alone: flipping them to `acknowledged` would claim a + * routing that never happened. Outreach is enqueued by the service after the commit. + */ + saveAndRoute( + geoid: string, + input: SaveContactsInput, + audit: { actorId: string | null }, + ): Promise<{ taskResolved: boolean }> + /** Writes the jurisdiction.patched audit row in the same transaction. False for an unknown geoid. */ + patch( + geoid: string, + input: PatchContactsInput, + audit: { actorId: string | null }, + ): Promise + getOutreachState( + geoid: string, + ): Promise<{ lastOutreachAt: Date | null; suppressed: boolean } | null> + listDirectory(args: ListDirectoryArgs): Promise + getGeometry(geoid: string): Promise + // Bounce stamping is deliberately not on this seam: the module-level markBouncedContact in + // jurisdiction-contacts-repository.drizzle.ts owns it, since it runs from the mail path with only a raw + // Sql handle and also needs geoidForContact to re-open discovery. +} diff --git a/services/api/src/services/admin/jurisdiction-contacts-service.ts b/services/api/src/services/admin/jurisdiction-contacts-service.ts index 5bf78819..34a2feb9 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-service.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-service.ts @@ -4,16 +4,19 @@ import { AppError } from "@civfix/shared" import type { JurisdictionListQuery } from "@civfix/shared" import { toDirectoryDTO, hasAnyContact } from "./jurisdiction-directory-projection.js" -import type { DirectoryFilter, DirectorySort } from "./jurisdiction-contacts-types.js" +import type { + DirectoryFilter, + DirectorySort, + PatchContactsInput, + SaveContactsInput, +} from "./jurisdiction-contacts-repository.js" import { isReservedHandle } from "../../auth/reserved-handles.js" import { clampLimit } from "./pagination.js" import { isThrottled } from "./outreach-service.js" import type { JurisdictionContactsService, JurisdictionContactsServiceDeps, - PatchContactsInput, SaveAndRouteResult, - SaveContactsInput, } from "./jurisdiction-contacts-types.js" import { OUTREACH_DIGEST_JOB } from "../../lib/queue-names.js" diff --git a/services/api/src/services/admin/jurisdiction-contacts-types.ts b/services/api/src/services/admin/jurisdiction-contacts-types.ts index a49eb463..51e192d2 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-types.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-types.ts @@ -1,10 +1,13 @@ import type { JurisdictionDirectoryResponse, JurisdictionGeometryResponse, - JurisdictionLayer, JurisdictionListQuery, - ReportCategory, } from "@civfix/shared" +import type { + JurisdictionContactsRepository, + PatchContactsInput, + SaveContactsInput, +} from "./jurisdiction-contacts-repository.js" /** * Sentinel geoid for the synthetic directory row that aggregates waiting reports with no jurisdiction or @@ -15,131 +18,12 @@ import type { export const UNMAPPED_GEOID = "__unmapped__" export const UNMAPPED_NAME = "Unmapped / Unknown jurisdiction" -export interface JurisdictionDirectoryRecord { - geoid: string - name: string - layer: JurisdictionLayer - population: number | null - /** Legacy contact_emails[]. */ - defaultEmails: string[] - categoryContacts: { category: ReportCategory; email: string | null }[] - hasDefaultContact: boolean - reportFormUrl: string | null - /** Open reports still waiting on a routing contact (un-routed, un-closed). */ - reportsWaiting: number - perCategoryCounts: Partial> - /** Shares reportsWaiting's predicate. */ - oldestReportAt: Date | null - lastRoutedAt: Date | null - /** Takes precedence over verified/pending. */ - bounced: boolean - contactUpdatedAt: Date | null - flaggedAt: Date | null - handle: string | null - /** Null means the built-in default packet. */ - forwardSubjectTemplate: string | null - forwardBodyTemplate: string | null -} - -/** - * "routed" = any contact on file (email or form); "none" = neither; "needs_mapping" = waiting reports and - * no routing contact. - */ -export type DirectoryFilter = "all" | "email" | "form" | "none" | "routed" | "needs_mapping" - -/** "oldest" ranks by each jurisdiction's oldest still-waiting report: the longest-unrouted backlog. */ -export type DirectorySort = "population" | "reports" | "name" | "oldest" - -export interface ListDirectoryArgs { - q: string | null - filter: DirectoryFilter - layer: JurisdictionLayer | null - sort: DirectorySort - cursor: string | null - limit: number -} - -export interface ListDirectoryResult { - records: JurisdictionDirectoryRecord[] - nextCursor: string | null - /** First page only. */ - total: number | null - /** First page only. */ - facets: { routed: number; unrouted: number } | null -} - -export interface JurisdictionGeometryRecord { - geoid: string - name: string - layer: JurisdictionLayer - bbox: [number, number, number, number] - centroid: [number, number] - geometry: { type: string; coordinates: unknown[] } -} - export interface SaveAndRouteResult { geoid: string taskResolved: boolean outreachEnqueued: boolean } -export interface SaveContactsInput { - contacts: Partial> - defaultEmails: string[] - formUrl: string | null - forwardSubjectTemplate?: string | null - forwardBodyTemplate?: string | null -} - -/** - * Per-category contacts, the @handle and the forward templates clear on null/"". The legacy - * `defaultEmails` and `formUrl` do not: the shared contacts upsert only writes a non-empty array and - * COALESCEs the form URL, so `[]` / null mean "leave alone". Making them clearable is a wire-semantics - * decision, since an operator form that always posts the field would otherwise wipe it. - */ -export interface PatchContactsInput { - contacts?: Partial> - /** An empty array is "unchanged", not a clear. */ - defaultEmails?: string[] - /** null/"" is "unchanged", not a clear. */ - formUrl?: string | null - notes?: string | null - flagged?: boolean - flagReason?: string | null - handle?: string | null - forwardSubjectTemplate?: string | null - forwardBodyTemplate?: string | null -} - -export interface JurisdictionContactsRepository { - jurisdictionExists(geoid: string): Promise - /** - * One short transaction commits the contacts, the discovery task's resolution and the - * discovery.contacts_saved audit row, so an audit failure rolls the save back. Saving a contact mails - * nobody, so the geoid's waiting reports are left alone: flipping them to `acknowledged` would claim a - * routing that never happened. Outreach is enqueued by the service after the commit. - */ - saveAndRoute( - geoid: string, - input: SaveContactsInput, - audit: { actorId: string | null }, - ): Promise<{ taskResolved: boolean }> - /** Writes the jurisdiction.patched audit row in the same transaction. False for an unknown geoid. */ - patch( - geoid: string, - input: PatchContactsInput, - audit: { actorId: string | null }, - ): Promise - getOutreachState( - geoid: string, - ): Promise<{ lastOutreachAt: Date | null; suppressed: boolean } | null> - listDirectory(args: ListDirectoryArgs): Promise - getGeometry(geoid: string): Promise - // Bounce stamping is deliberately not on this seam: the module-level markBouncedContact in - // jurisdiction-contacts-repository.drizzle.ts owns it, since it runs from the mail path with only a raw - // Sql handle and also needs geoidForContact to re-open discovery. -} - export interface OutreachEnqueuer { enqueue(name: string, data: unknown, opts?: { singletonKey?: string }): Promise } diff --git a/services/api/src/services/admin/jurisdiction-directory-projection.ts b/services/api/src/services/admin/jurisdiction-directory-projection.ts index bd937aec..98421b1c 100644 --- a/services/api/src/services/admin/jurisdiction-directory-projection.ts +++ b/services/api/src/services/admin/jurisdiction-directory-projection.ts @@ -1,13 +1,12 @@ import { REPORT_CATEGORY_LABELS } from "@civfix/shared" import type { JurisdictionDirectoryDTO, ReportCategory } from "@civfix/shared" import { ADMIN_CATEGORIES } from "./category-counts.js" -import { - UNMAPPED_GEOID, - UNMAPPED_NAME, - type JurisdictionDirectoryRecord, - type ListDirectoryArgs, - type SaveContactsInput, -} from "./jurisdiction-contacts-types.js" +import { UNMAPPED_GEOID, UNMAPPED_NAME } from "./jurisdiction-contacts-types.js" +import type { + JurisdictionDirectoryRecord, + ListDirectoryArgs, + SaveContactsInput, +} from "./jurisdiction-contacts-repository.js" export function coverageLabel(record: JurisdictionDirectoryRecord): string { const covered = new Set( diff --git a/services/api/src/services/admin/mail-format.ts b/services/api/src/services/admin/mail-format.ts index 90874498..ad9ed525 100644 --- a/services/api/src/services/admin/mail-format.ts +++ b/services/api/src/services/admin/mail-format.ts @@ -23,7 +23,7 @@ import type { AdminReportMediaRecord, AdminReportRecord, AdminReportRoutingRecord, -} from "./admin-report-types.js" +} from "./admin-report-repository.js" import type { MarkdownInline } from "@civfix/shared/markdown" import { ATTACHMENT_FILENAME_MAX_CHARS, safeFilenameChars } from "../../lib/filename.js" diff --git a/services/api/src/services/admin/moderation-repository.drizzle.ts b/services/api/src/services/admin/moderation-repository.drizzle.ts index 9b768879..89d75a5b 100644 --- a/services/api/src/services/admin/moderation-repository.drizzle.ts +++ b/services/api/src/services/admin/moderation-repository.drizzle.ts @@ -21,13 +21,15 @@ import { isUserSubject, MODERATION_APPROVED_NOTE, MODERATION_REMOVED_NOTE, - type CreateModerationItemInput, - type ListModerationArgs, - type ModerationItemRecord, - type ModerationMediaRecord, - type ModerationRepository, - type ModerationUserSnapshot, } from "./moderation-service.js" +import type { + CreateModerationItemInput, + ListModerationArgs, + ModerationItemRecord, + ModerationMediaRecord, + ModerationRepository, + ModerationUserSnapshot, +} from "./moderation-repository.js" type SubjectType = ModerationItemRecord["subjectType"] diff --git a/services/api/src/services/admin/moderation-repository.memory.ts b/services/api/src/services/admin/moderation-repository.memory.ts index 387dd1aa..90609f54 100644 --- a/services/api/src/services/admin/moderation-repository.memory.ts +++ b/services/api/src/services/admin/moderation-repository.memory.ts @@ -5,14 +5,13 @@ import { assertTargetIsNotOfficialAccount, assertTargetIsNotOperatorRole, } from "../../auth/operator-target.js" -import { - isMessageSubject, - isUserSubject, - type CreateModerationItemInput, - type ListModerationArgs, - type ModerationItemRecord, - type ModerationRepository, -} from "./moderation-service.js" +import { isMessageSubject, isUserSubject } from "./moderation-service.js" +import type { + CreateModerationItemInput, + ListModerationArgs, + ModerationItemRecord, + ModerationRepository, +} from "./moderation-repository.js" export interface SeededHeldReport { id: string diff --git a/services/api/src/services/admin/moderation-repository.ts b/services/api/src/services/admin/moderation-repository.ts new file mode 100644 index 00000000..5a635901 --- /dev/null +++ b/services/api/src/services/admin/moderation-repository.ts @@ -0,0 +1,107 @@ +import type { + ModerationDestinationKind, + ModerationKind, + ModerationSignal, + ModerationSimilar, + ModerationSubjectType, + Priority, + ReportCategory, +} from "@civfix/shared" + +export type ModerationFilter = "all" | ModerationKind | "high" + +export interface ListModerationArgs { + q: string | null + filter: ModerationFilter + cursor: string | null + limit: number +} + +export interface ModerationUserSnapshot { + id: string | null + handle: string + name: string + joined: string + priorReports: number + priorRemovals: number + strikes: number + device: string +} + +export interface ModerationMediaRecord { + id: string + kind: "image" | "video" + r2Key: string + thumbKey: string | null +} + +export interface ModerationItemRecord { + id: string + kind: ModerationKind + subjectType: ModerationSubjectType + subjectId: string + destinationKind: ModerationDestinationKind | null + destinationId: string | null + flag: string | null + reason: string | null + category: ReportCategory | null + place: string | null + priority: Priority + autoAction: string | null + reporter: string | null + reporterId: string | null + desc: string | null + status: "open" | "approved" | "removed" | "held" + signals: ModerationSignal[] + similar: ModerationSimilar[] + user: ModerationUserSnapshot | null + media: ModerationMediaRecord[] + createdAt: Date + reportTimelineStatus?: "published" | "rejected" + restoredUserId?: string + suspendedUserId?: string +} + +export interface CreateModerationItemInput { + kind: ModerationKind + subjectType: ModerationSubjectType + subjectId: string + flag?: string | null + reason?: string | null + category?: ReportCategory | null + place?: string | null + priority?: Priority + autoAction?: string | null + reporter?: string | null + reporterUserId?: string | null + desc?: string | null + signals?: ModerationSignal[] + similar?: ModerationSimilar[] + user?: ModerationUserSnapshot | null + dedupeOpen?: boolean +} + +export interface ModerationRepository { + listOpen( + args: ListModerationArgs, + ): Promise<{ records: ModerationItemRecord[]; nextCursor: string | null }> + getItem(id: string): Promise + approve( + id: string, + input: { actorId: string | null; note: string | null }, + ): Promise + remove( + id: string, + input: { actorId: string | null; reason: string | null }, + ): Promise + hold( + id: string, + input: { actorId: string | null; note: string | null }, + ): Promise + decideAppeal( + id: string, + input: { decision: "uphold" | "overturn"; actorId: string | null; note: string | null }, + ): Promise + createItem(input: CreateModerationItemInput): Promise + backfillFromHeldReports(): Promise +} diff --git a/services/api/src/services/admin/moderation-service.ts b/services/api/src/services/admin/moderation-service.ts index 25f7f208..cc3c1e77 100644 --- a/services/api/src/services/admin/moderation-service.ts +++ b/services/api/src/services/admin/moderation-service.ts @@ -1,8 +1,6 @@ import { AppError, relativeAgo } from "@civfix/shared" import type { ModerationItemDTO, - ModerationDestinationKind, - ModerationKind, ModerationSubjectType, ModerationListQuery, ModerationListResponse, @@ -10,8 +8,6 @@ import type { ModerationSignal, ModerationSimilar, ModerationUser, - Priority, - ReportCategory, UserStatus, } from "@civfix/shared" import { clampLimit } from "./pagination.js" @@ -20,8 +16,13 @@ import { mapWithLimit } from "../../lib/concurrency.js" import { PRESIGN_CONCURRENCY, type PresignMedia } from "../media-presign.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" import type { MessageUpdateAnnouncer } from "./admin-report-chat-service.js" - -export type ModerationFilter = "all" | ModerationKind | "high" +import type { + CreateModerationItemInput, + ListModerationArgs, + ModerationItemRecord, + ModerationRepository, + ModerationUserSnapshot, +} from "./moderation-repository.js" /** Shared by the service's chat mirror and the repository's report_timeline row, which must agree. */ export const MODERATION_APPROVED_NOTE = "Approved in moderation" @@ -29,102 +30,6 @@ export const MODERATION_REMOVED_NOTE = "Removed in moderation" const MODERATION_ITEM_NOT_FOUND = "Moderation item not found" -export interface ListModerationArgs { - q: string | null - filter: ModerationFilter - cursor: string | null - limit: number -} - -export interface ModerationUserSnapshot { - id: string | null - handle: string - name: string - joined: string - priorReports: number - priorRemovals: number - strikes: number - device: string -} - -export interface ModerationMediaRecord { - id: string - kind: "image" | "video" - r2Key: string - thumbKey: string | null -} - -export interface ModerationItemRecord { - id: string - kind: ModerationKind - subjectType: ModerationSubjectType - subjectId: string - destinationKind: ModerationDestinationKind | null - destinationId: string | null - flag: string | null - reason: string | null - category: ReportCategory | null - place: string | null - priority: Priority - autoAction: string | null - reporter: string | null - reporterId: string | null - desc: string | null - status: "open" | "approved" | "removed" | "held" - signals: ModerationSignal[] - similar: ModerationSimilar[] - user: ModerationUserSnapshot | null - media: ModerationMediaRecord[] - createdAt: Date - reportTimelineStatus?: "published" | "rejected" - restoredUserId?: string - suspendedUserId?: string -} - -export interface CreateModerationItemInput { - kind: ModerationKind - subjectType: ModerationSubjectType - subjectId: string - flag?: string | null - reason?: string | null - category?: ReportCategory | null - place?: string | null - priority?: Priority - autoAction?: string | null - reporter?: string | null - reporterUserId?: string | null - desc?: string | null - signals?: ModerationSignal[] - similar?: ModerationSimilar[] - user?: ModerationUserSnapshot | null - dedupeOpen?: boolean -} - -export interface ModerationRepository { - listOpen( - args: ListModerationArgs, - ): Promise<{ records: ModerationItemRecord[]; nextCursor: string | null }> - getItem(id: string): Promise - approve( - id: string, - input: { actorId: string | null; note: string | null }, - ): Promise - remove( - id: string, - input: { actorId: string | null; reason: string | null }, - ): Promise - hold( - id: string, - input: { actorId: string | null; note: string | null }, - ): Promise - decideAppeal( - id: string, - input: { decision: "uphold" | "overturn"; actorId: string | null; note: string | null }, - ): Promise - createItem(input: CreateModerationItemInput): Promise - backfillFromHeldReports(): Promise -} - const NEUTRAL_USER_SNAPSHOT: ModerationUserSnapshot = { id: null, handle: "", diff --git a/services/api/src/services/admin/outreach-repository.drizzle.ts b/services/api/src/services/admin/outreach-repository.drizzle.ts index 39c48168..87df1abc 100644 --- a/services/api/src/services/admin/outreach-repository.drizzle.ts +++ b/services/api/src/services/admin/outreach-repository.drizzle.ts @@ -1,9 +1,6 @@ import type { Sql } from "../../db/client.js" -import { - OUTREACH_CATEGORIES, - type OutreachDigest, - type OutreachRepository, -} from "./outreach-service.js" +import { OUTREACH_CATEGORIES } from "./outreach-service.js" +import type { OutreachDigest, OutreachRepository } from "./outreach-repository.js" import { parseCategoryCounts, parseCount, type CategoryCountRow } from "./category-counts.js" import { categoryCountsFragment, categoryCountsProjection } from "./category-counts-sql.js" import { legacyContactEmailUsable } from "./sql-fragments.js" diff --git a/services/api/src/services/admin/outreach-repository.memory.ts b/services/api/src/services/admin/outreach-repository.memory.ts index a79421c0..f71105bd 100644 --- a/services/api/src/services/admin/outreach-repository.memory.ts +++ b/services/api/src/services/admin/outreach-repository.memory.ts @@ -1,9 +1,6 @@ import { randomUUID } from "node:crypto" -import { - OUTREACH_CATEGORIES, - type OutreachDigest, - type OutreachRepository, -} from "./outreach-service.js" +import { OUTREACH_CATEGORIES } from "./outreach-service.js" +import type { OutreachDigest, OutreachRepository } from "./outreach-repository.js" import type { OutreachStateRecord } from "./mail-repository.js" import type { ReportCategory } from "@civfix/shared" diff --git a/services/api/src/services/admin/outreach-repository.ts b/services/api/src/services/admin/outreach-repository.ts new file mode 100644 index 00000000..26efcd64 --- /dev/null +++ b/services/api/src/services/admin/outreach-repository.ts @@ -0,0 +1,16 @@ +import type { ReportCategory } from "@civfix/shared" + +export interface OutreachDigest { + geoid: string + org: string | null + toAddr: string + perCategory: Partial> + total: number + oldestWaitingAt: Date | null +} + +export interface OutreachRepository { + loadDigest(geoid: string): Promise + listCandidateGeoids(limit?: number): Promise + claimOutreachWindow?(geoid: string, window: { at: Date; windowStart: Date }): Promise +} diff --git a/services/api/src/services/admin/outreach-service.ts b/services/api/src/services/admin/outreach-service.ts index 056d01a2..dd35f732 100644 --- a/services/api/src/services/admin/outreach-service.ts +++ b/services/api/src/services/admin/outreach-service.ts @@ -8,21 +8,7 @@ import { type OutboundMailLogger, type OutboundMailService, } from "./outbound-mail-service.js" - -export interface OutreachDigest { - geoid: string - org: string | null - toAddr: string - perCategory: Partial> - total: number - oldestWaitingAt: Date | null -} - -export interface OutreachRepository { - loadDigest(geoid: string): Promise - listCandidateGeoids(limit?: number): Promise - claimOutreachWindow?(geoid: string, window: { at: Date; windowStart: Date }): Promise -} +import type { OutreachDigest, OutreachRepository } from "./outreach-repository.js" const OUTREACH_SWEEP_BATCH_SIZE = 200 diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 61559a50..46370234 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -24,13 +24,15 @@ import type { Sql, TransactionSql } from "../db/client.js" import { generateToken, sha256Hex } from "../auth/crypto.js" +import type { AnonAbuseReason } from "./anon-service.js" import type { - AnonAbuseReason, AnonReportRepository, AnonReportStatusRow, + ClaimRepository, CreateAnonReportTxArgs, CreateAnonReportTxResult, -} from "./anon-service.js" + PendingAnonReport, +} from "./anon-repository.js" import { ANON_REPORT_CREATE_SCOPE } from "./anon-service.js" import { allocateReportReferenceCode } from "../db/reference-code.js" import { @@ -38,7 +40,6 @@ import { type AnonTokenRecord, type AnonTokenStore, } from "../abuse/anon-token.js" -import type { ClaimRepository, PendingAnonReport } from "./claim-service.js" import { AppError } from "@civfix/shared" import type { AnonReportResponse, ReportStatus } from "@civfix/shared" import { insertModerationItem } from "./admin/moderation-repository.drizzle.js" diff --git a/services/api/src/services/anon-repository.ts b/services/api/src/services/anon-repository.ts new file mode 100644 index 00000000..85bdfb67 --- /dev/null +++ b/services/api/src/services/anon-repository.ts @@ -0,0 +1,64 @@ +import type { + AddressPrecision, + AnonReportResponse, + GeomSource, + ReportAddressSource, + ReportCategory, + ReportStatus, + ReportType, +} from "@civfix/shared" +import type { AnonTokenStore } from "../abuse/anon-token.js" + +export interface CreateAnonReportTxArgs { + reportId: string + anonSessionId: string + idempotencyKey: string + lat: number + lng: number + geomSource: GeomSource + jurisdictionGeoid: string | null + jurCode: number + category: ReportCategory + type: ReportType + title: string | null + description: string | null + addr: string | null + addrSource: ReportAddressSource | null + addrPrecision: AddressPrecision | null + h3Cell: string + mediaUploadIds: string[] + mediaUploaders: readonly string[] + claimCodeHash: string + reportCap: number + responseSnapshot: AnonReportResponse +} + +export type CreateAnonReportTxResult = + | { kind: "created"; snapshot: AnonReportResponse } + | { kind: "replayed"; snapshot: AnonReportResponse } + +export interface AnonReportStatusRow { + reportId: string + status: ReportStatus + publishedAt: Date | null + claimCodeHash: string | null +} + +export interface AnonReportRepository extends AnonTokenStore { + findIdempotentSnapshot( + key: string, + scope: string, + userOrAnon: string | null, + ): Promise + createAnonReportTx(args: CreateAnonReportTxArgs): Promise + findAnonReportStatus(reportId: string): Promise +} + +export interface PendingAnonReport { + reportId: string +} + +export interface ClaimRepository extends AnonTokenStore { + rotatePendingClaimCode(tokenId: string, claimCodeHash: string): Promise + claimByCode(claimCodeHash: string, userId: string): Promise<{ reportId: string } | null> +} diff --git a/services/api/src/services/anon-service.ts b/services/api/src/services/anon-service.ts index cdf3f05c..8cbf4b8b 100644 --- a/services/api/src/services/anon-service.ts +++ b/services/api/src/services/anon-service.ts @@ -4,11 +4,7 @@ import type { AnonReportRequest, AnonReportResponse, AnonReportStatusResponse, - GeomSource, LatLng, - ReportCategory, - ReportType, - ReportStatus, } from "@civfix/shared" import { assertNoSlur } from "../abuse/slur-filter.js" import type { AbuseChecks } from "@civfix/shared/interfaces" @@ -24,7 +20,6 @@ import { verifyAnonTokenSignature, ANON_TOKEN_REPORT_CAP, type AnonTokenDeps, - type AnonTokenStore, } from "../abuse/anon-token.js" import { generateToken, constantTimeStringEqual, sha256Hex } from "../auth/crypto.js" import { UNKNOWN_JURCODE } from "../db/reference-code.js" @@ -35,7 +30,7 @@ import { type AddressResolver, type ReportAddressWrite, } from "./address-resolver.js" -import type { AddressPrecision, ReportAddressSource } from "@civfix/shared" +import type { AnonReportRepository } from "./anon-repository.js" export const ANON_REPORT_CREATE_SCOPE = "anon_report_create" @@ -51,51 +46,6 @@ export type AnonLog = (line: string, extra?: Record) => void export type AnonAbuseReason = "honeypot" | "gps" -export interface CreateAnonReportTxArgs { - reportId: string - anonSessionId: string - idempotencyKey: string - lat: number - lng: number - geomSource: GeomSource - jurisdictionGeoid: string | null - jurCode: number - category: ReportCategory - type: ReportType - title: string | null - description: string | null - addr: string | null - addrSource: ReportAddressSource | null - addrPrecision: AddressPrecision | null - h3Cell: string - mediaUploadIds: string[] - mediaUploaders: readonly string[] - claimCodeHash: string - reportCap: number - responseSnapshot: AnonReportResponse -} - -export type CreateAnonReportTxResult = - | { kind: "created"; snapshot: AnonReportResponse } - | { kind: "replayed"; snapshot: AnonReportResponse } - -export interface AnonReportStatusRow { - reportId: string - status: ReportStatus - publishedAt: Date | null - claimCodeHash: string | null -} - -export interface AnonReportRepository extends AnonTokenStore { - findIdempotentSnapshot( - key: string, - scope: string, - userOrAnon: string | null, - ): Promise - createAnonReportTx(args: CreateAnonReportTxArgs): Promise - findAnonReportStatus(reportId: string): Promise -} - export interface AnonSubmitContext { ip: string | null cfGeo?: HeaderBag | undefined diff --git a/services/api/src/services/certificate-repository.drizzle.ts b/services/api/src/services/certificate-repository.drizzle.ts index cb5042c0..4b120eb3 100644 --- a/services/api/src/services/certificate-repository.drizzle.ts +++ b/services/api/src/services/certificate-repository.drizzle.ts @@ -16,15 +16,14 @@ */ import type { Sql } from "../db/client.js" -import { - CertificateConflictError, - type CertificateConflictKind, - type CertificateHolder, - type CertificateInsert, - type CertificateRepository, - type CertificateRow, - type CertificateVerifyRow, -} from "./certificate-service.js" +import { CertificateConflictError, type CertificateConflictKind } from "./certificate-service.js" +import type { + CertificateHolder, + CertificateInsert, + CertificateRepository, + CertificateRow, + CertificateVerifyRow, +} from "./certificate-repository.js" import { PG_UNIQUE_VIOLATION } from "../db/pg-errors.js" /** The partial `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL` index (0064). */ diff --git a/services/api/src/services/certificate-repository.memory.ts b/services/api/src/services/certificate-repository.memory.ts index 48a64364..7655d59d 100644 --- a/services/api/src/services/certificate-repository.memory.ts +++ b/services/api/src/services/certificate-repository.memory.ts @@ -18,14 +18,14 @@ * `snapshot` is stored but never read back, exactly as in production. */ -import { - CertificateConflictError, - type CertificateHolder, - type CertificateInsert, - type CertificateRepository, - type CertificateRow, - type CertificateVerifyRow, -} from "./certificate-service.js" +import { CertificateConflictError } from "./certificate-service.js" +import type { + CertificateHolder, + CertificateInsert, + CertificateRepository, + CertificateRow, + CertificateVerifyRow, +} from "./certificate-repository.js" import type { TranscriptModel } from "./certificate-model.js" /** Mirrors the `users.locale` column default. */ diff --git a/services/api/src/services/certificate-repository.ts b/services/api/src/services/certificate-repository.ts new file mode 100644 index 00000000..5e2e8836 --- /dev/null +++ b/services/api/src/services/certificate-repository.ts @@ -0,0 +1,95 @@ +import type { TranscriptModel } from "./certificate-model.js" + +/** The holder identity frozen onto the document, read from `users`. */ +export interface CertificateHolder { + userId: string + displayName: string + handle: string | null + /** `users.locale`; the default when the request does not pin one. */ + locale: string +} + +/** + * `snapshot` is DELIBERATELY ABSENT. At 1000 entries the jsonb is ~200 KB and lives out of line in TOAST; + * a read path that selects it detoasts on every list and every public verification. Every repo query + * therefore names its columns explicitly (never `SELECT *`) and none of them names `snapshot`; + * `service-hours-certificates-pg.test.ts` greps the repo source to keep it that way. + */ +export interface CertificateRow { + id: string + userId: string + code: string + locale: string + holderName: string + holderHandle: string | null + holderVerified: boolean + totalHours: number + entryCount: number + periodStart: Date | null + periodEnd: Date | null + ledgerFingerprint: string + r2Key: string + documentSha256: string + byteSize: number + issuedAt: Date + regeneratedAt: Date | null + revokedAt: Date | null + revokedReason: string | null +} + +/** + * The verification read. `holderDeleted` mirrors `users.deleted_at IS NOT NULL` from the join: the + * projection cannot simply filter tombstoned holders out, because "no such code" and "that account was + * closed" are materially different answers for the person holding the paper. + */ +export interface CertificateVerifyRow extends CertificateRow { + holderDeleted: boolean +} + +export interface CertificateInsert { + id: string + userId: string + code: string + locale: string + holderName: string + holderHandle: string | null + holderVerified: boolean + totalHours: number + entryCount: number + periodStart: Date | null + periodEnd: Date | null + ledgerFingerprint: string + /** The exact rendered model, stored so the object is re-renderable. Written once, never read back. */ + snapshot: TranscriptModel + r2Key: string + documentSha256: string + byteSize: number + issuedAt: Date +} + +export interface CertificateRepository { + /** + * @throws CertificateConflictError("code") on `service_hours_certificates_code_uidx` + * @throws CertificateConflictError("fingerprint") on the partial + * `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL` index + */ + insert(row: CertificateInsert): Promise + findLiveByFingerprint(userId: string, fingerprint: string): Promise + listFor(userId: string): Promise + /** Joins `users` so a tombstoned holder is DISTINGUISHABLE from an unknown code. */ + findByCode(code: string): Promise + /** + * Idempotent: returns the row whether or not it was already revoked, and null only when the code does + * not exist OR does not belong to `userId`: the service turns that null into a 404, never a 403, so + * the endpoint is not an existence oracle over someone else's codes. + */ + revoke(userId: string, code: string, reason: string, at: Date): Promise + /** The operator-error path: the row survived, its object did not, and it was re-rendered. */ + markRegenerated(args: { + id: string + documentSha256: string + byteSize: number + at: Date + }): Promise + findHolder(userId: string): Promise +} diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index 9e68e93d..ccb9e74a 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -49,7 +49,12 @@ import { buildServiceHoursPdf } from "./certificate-pdf.js" import type { VolunteerHoursEntryView, VolunteerHoursRepository, -} from "./volunteer-hours-service.js" +} from "./volunteer-hours-repository.js" +import type { + CertificateHolder, + CertificateRepository, + CertificateRow, +} from "./certificate-repository.js" const CERTIFICATE_KEY_PREFIX = "certificates/service-hours" const PDF_CONTENT_TYPE = "application/pdf" @@ -82,73 +87,6 @@ export interface CertificateStorage { delete(key: string): Promise } -/** The holder identity frozen onto the document, read from `users`. */ -export interface CertificateHolder { - userId: string - displayName: string - handle: string | null - /** `users.locale`; the default when the request does not pin one. */ - locale: string -} - -/** - * `snapshot` is DELIBERATELY ABSENT. At 1000 entries the jsonb is ~200 KB and lives out of line in TOAST; - * a read path that selects it detoasts on every list and every public verification. Every repo query - * therefore names its columns explicitly (never `SELECT *`) and none of them names `snapshot`; - * `service-hours-certificates-pg.test.ts` greps the repo source to keep it that way. - */ -export interface CertificateRow { - id: string - userId: string - code: string - locale: string - holderName: string - holderHandle: string | null - holderVerified: boolean - totalHours: number - entryCount: number - periodStart: Date | null - periodEnd: Date | null - ledgerFingerprint: string - r2Key: string - documentSha256: string - byteSize: number - issuedAt: Date - regeneratedAt: Date | null - revokedAt: Date | null - revokedReason: string | null -} - -/** - * The verification read. `holderDeleted` mirrors `users.deleted_at IS NOT NULL` from the join: the - * projection cannot simply filter tombstoned holders out, because "no such code" and "that account was - * closed" are materially different answers for the person holding the paper. - */ -export interface CertificateVerifyRow extends CertificateRow { - holderDeleted: boolean -} - -export interface CertificateInsert { - id: string - userId: string - code: string - locale: string - holderName: string - holderHandle: string | null - holderVerified: boolean - totalHours: number - entryCount: number - periodStart: Date | null - periodEnd: Date | null - ledgerFingerprint: string - /** The exact rendered model, stored so the object is re-renderable. Written once, never read back. */ - snapshot: TranscriptModel - r2Key: string - documentSha256: string - byteSize: number - issuedAt: Date -} - /** Which unique index an `insert` lost to. The two have completely different recoveries. */ export type CertificateConflictKind = "code" | "fingerprint" @@ -163,33 +101,6 @@ export class CertificateConflictError extends Error { } } -export interface CertificateRepository { - /** - * @throws CertificateConflictError("code") on `service_hours_certificates_code_uidx` - * @throws CertificateConflictError("fingerprint") on the partial - * `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL` index - */ - insert(row: CertificateInsert): Promise - findLiveByFingerprint(userId: string, fingerprint: string): Promise - listFor(userId: string): Promise - /** Joins `users` so a tombstoned holder is DISTINGUISHABLE from an unknown code. */ - findByCode(code: string): Promise - /** - * Idempotent: returns the row whether or not it was already revoked, and null only when the code does - * not exist OR does not belong to `userId`: the service turns that null into a 404, never a 403, so - * the endpoint is not an existence oracle over someone else's codes. - */ - revoke(userId: string, code: string, reason: string, at: Date): Promise - /** The operator-error path: the row survived, its object did not, and it was re-rendered. */ - markRegenerated(args: { - id: string - documentSha256: string - byteSize: number - at: Date - }): Promise - findHolder(userId: string): Promise -} - export interface CertificateServiceDeps { repo: CertificateRepository /** The ledger read lives on the volunteer-hours repo; this service owns only the certificate rows. */ diff --git a/services/api/src/services/claim-service.ts b/services/api/src/services/claim-service.ts index b0134c6c..b236b73b 100644 --- a/services/api/src/services/claim-service.ts +++ b/services/api/src/services/claim-service.ts @@ -1,17 +1,9 @@ import { AppError } from "@civfix/shared" import type { ClaimNudgeResponse, ClaimReportResponse, ReportDTO } from "@civfix/shared" -import { resolveAnonToken, type AnonTokenDeps, type AnonTokenStore } from "../abuse/anon-token.js" +import { resolveAnonToken, type AnonTokenDeps } from "../abuse/anon-token.js" import type { ReportOwner } from "./report-service.js" import { generateToken, sha256Hex } from "../auth/crypto.js" - -export interface PendingAnonReport { - reportId: string -} - -export interface ClaimRepository extends AnonTokenStore { - rotatePendingClaimCode(tokenId: string, claimCodeHash: string): Promise - claimByCode(claimCodeHash: string, userId: string): Promise<{ reportId: string } | null> -} +import type { ClaimRepository } from "./anon-repository.js" export interface ClaimServiceDeps { repo: ClaimRepository diff --git a/services/api/src/services/guest-notify.ts b/services/api/src/services/guest-notify.ts index 37314315..c1271051 100644 --- a/services/api/src/services/guest-notify.ts +++ b/services/api/src/services/guest-notify.ts @@ -1,7 +1,7 @@ import type { Mailer } from "@civfix/shared/interfaces" import { formatEventWhen } from "./host/broadcast-render.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" -import type { GuestRsvpRepository } from "./guest-rsvp-service.js" +import type { GuestRsvpRepository } from "./guest-rsvp-repository.js" export function guestEventLink(linkBase: string, cleanupId: string): string { return `${linkBase}/cleanups/${cleanupId}` diff --git a/services/api/src/services/guest-rsvp-repository.drizzle.ts b/services/api/src/services/guest-rsvp-repository.drizzle.ts index fdfd76b2..d974a4fd 100644 --- a/services/api/src/services/guest-rsvp-repository.drizzle.ts +++ b/services/api/src/services/guest-rsvp-repository.drizzle.ts @@ -15,7 +15,7 @@ import type { GuestRsvpRepository, InsertGuestOtpArgs, UpsertGuestArgs, -} from "./guest-rsvp-service.js" +} from "./guest-rsvp-repository.js" interface GuestRowSelect { id: string diff --git a/services/api/src/services/guest-rsvp-repository.ts b/services/api/src/services/guest-rsvp-repository.ts new file mode 100644 index 00000000..9b4942ae --- /dev/null +++ b/services/api/src/services/guest-rsvp-repository.ts @@ -0,0 +1,99 @@ +import type { CleanupStatus, EventVisibility, GuestContactChannel } from "@civfix/shared" +import type { KeysetCursor } from "../db/cursor-helpers.js" + +export interface GuestOtpRecord { + id: string + cleanupId: string + channel: GuestContactChannel + contact: string + name: string + codeHash: string +} + +export interface GuestRosterRow { + id: string + name: string + channel: GuestContactChannel + email: string | null + phone: string | null + verifiedAt: Date + cancelledAt: Date | null +} + +export interface GuestRecipient { + id: string + name: string + channel: GuestContactChannel + email: string | null + phone: string | null +} + +export interface GuestNoticeTarget { + id: string + cleanupId: string + name: string + email: string | null + cancelledAt: Date | null + contactScrubbedAt: Date | null +} + +export interface GuestEventView { + id: string + title: string + status: CleanupStatus + visibility: EventVisibility + scheduledAt: Date + endsAt: Date | null + address: string | null + timezone: string | null + lat: number + lng: number +} + +export interface InsertGuestOtpArgs { + cleanupId: string + channel: GuestContactChannel + contact: string + name: string + codeHash: string + expiresAt: Date +} + +export interface UpsertGuestArgs { + cleanupId: string + name: string + channel: GuestContactChannel + contactKey: string + email: string | null + phone: string | null + manageTokenHash: string + now: Date +} + +export interface GuestRsvpRepository { + findEvent(cleanupId: string): Promise + countActiveGuests(cleanupId: string): Promise + goingCount(cleanupId: string): Promise + isPhoneOptedOut(phone: string): Promise + recordPhoneOptOut(phone: string): Promise + invalidateActiveOtps(cleanupId: string, contact: string, now: Date): Promise + insertOtp(args: InsertGuestOtpArgs): Promise + findLatestActiveOtp(cleanupId: string, contact: string, now: Date): Promise + incrementOtpAttempts(otpId: string): Promise + markOtpConsumed(otpId: string, now: Date): Promise + /** `created` is true when this call inserted the row rather than re-verifying an active one. */ + upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string; created: boolean }> + findGuestByManageTokenHash( + hash: string, + ): Promise<{ id: string; cleanupId: string; cancelledAt: Date | null } | null> + findGuestForNotice(guestId: string): Promise + cancelGuest(guestId: string, now: Date): Promise + listGuests(args: { + cleanupId: string + cursor: KeysetCursor | null + limit: number + }): Promise<{ rows: GuestRosterRow[]; nextCursor: string | null }> + listContactableGuests(cleanupId: string, limit: number): Promise + scrubExpiredGuestContacts(args: { cutoff: Date; now: Date; batchSize: number }): Promise + deleteStaleOtps(args: { cutoff: Date; batchSize: number }): Promise +} diff --git a/services/api/src/services/guest-rsvp-service.ts b/services/api/src/services/guest-rsvp-service.ts index 6872617a..8c6689d2 100644 --- a/services/api/src/services/guest-rsvp-service.ts +++ b/services/api/src/services/guest-rsvp-service.ts @@ -5,8 +5,6 @@ import { GuestOtpErrorReason, MAX_GUEST_NAME, type CleanupGuestDTO, - type CleanupStatus, - type EventVisibility, type GetCleanupGuestsRequest, type GetCleanupGuestsResponse, type GuestContactChannel, @@ -49,12 +47,19 @@ import { smsFailureKind } from "../errors/sms-failure.js" import { mapWithLimit } from "../lib/concurrency.js" import { MS_PER_HOUR, MS_PER_SECOND, SECONDS_PER_DAY, SECONDS_PER_MINUTE } from "../lib/time.js" import { renderMessage } from "../i18n/renderMessage.js" -import { parseKeysetCursor, type KeysetCursor } from "../db/cursor-helpers.js" +import { parseKeysetCursor } from "../db/cursor-helpers.js" import { eventEndedError, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" import { isEventPubliclyVisible } from "./host/authz.js" import { enqueueWaitlistPromotion } from "./host/waitlist-promotion.js" import { formatEventWhen } from "./host/broadcast-render.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" +import type { + GuestEventView, + GuestOtpRecord, + GuestRecipient, + GuestRosterRow, + GuestRsvpRepository, +} from "./guest-rsvp-repository.js" export const MAX_GUESTS_PER_EVENT = 500 @@ -138,108 +143,11 @@ export interface GuestRegistrationBridge { registrationGate?(cleanupId: string): Promise } -export interface GuestOtpRecord { - id: string - cleanupId: string - channel: GuestContactChannel - contact: string - name: string - codeHash: string -} - -export interface GuestRosterRow { - id: string - name: string - channel: GuestContactChannel - email: string | null - phone: string | null - verifiedAt: Date - cancelledAt: Date | null -} - -export interface GuestRecipient { - id: string - name: string - channel: GuestContactChannel - email: string | null - phone: string | null -} - -export interface GuestNoticeTarget { - id: string - cleanupId: string - name: string - email: string | null - cancelledAt: Date | null - contactScrubbedAt: Date | null -} - -export interface GuestEventView { - id: string - title: string - status: CleanupStatus - visibility: EventVisibility - scheduledAt: Date - endsAt: Date | null - address: string | null - timezone: string | null - lat: number - lng: number -} - -export interface InsertGuestOtpArgs { - cleanupId: string - channel: GuestContactChannel - contact: string - name: string - codeHash: string - expiresAt: Date -} - -export interface UpsertGuestArgs { - cleanupId: string - name: string - channel: GuestContactChannel - contactKey: string - email: string | null - phone: string | null - manageTokenHash: string - now: Date -} - export interface GuestRetentionResult { scrubbedGuests: number deletedOtps: number } -export interface GuestRsvpRepository { - findEvent(cleanupId: string): Promise - countActiveGuests(cleanupId: string): Promise - goingCount(cleanupId: string): Promise - isPhoneOptedOut(phone: string): Promise - recordPhoneOptOut(phone: string): Promise - invalidateActiveOtps(cleanupId: string, contact: string, now: Date): Promise - insertOtp(args: InsertGuestOtpArgs): Promise - findLatestActiveOtp(cleanupId: string, contact: string, now: Date): Promise - incrementOtpAttempts(otpId: string): Promise - markOtpConsumed(otpId: string, now: Date): Promise - /** `created` is true when this call inserted the row rather than re-verifying an active one. */ - upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string; created: boolean }> - findGuestByManageTokenHash( - hash: string, - ): Promise<{ id: string; cleanupId: string; cancelledAt: Date | null } | null> - findGuestForNotice(guestId: string): Promise - cancelGuest(guestId: string, now: Date): Promise - listGuests(args: { - cleanupId: string - cursor: KeysetCursor | null - limit: number - }): Promise<{ rows: GuestRosterRow[]; nextCursor: string | null }> - listContactableGuests(cleanupId: string, limit: number): Promise - scrubExpiredGuestContacts(args: { cutoff: Date; now: Date; batchSize: number }): Promise - deleteStaleOtps(args: { cutoff: Date; batchSize: number }): Promise -} - export interface GuestRsvpLogger { warn(obj: unknown, msg?: string): void info(obj: unknown, msg?: string): void diff --git a/services/api/src/services/host/broadcast-inapp-sender.ts b/services/api/src/services/host/broadcast-inapp-sender.ts index 14621fff..36a5fe28 100644 --- a/services/api/src/services/host/broadcast-inapp-sender.ts +++ b/services/api/src/services/host/broadcast-inapp-sender.ts @@ -2,7 +2,8 @@ import type { BroadcastKind, NotificationType } from "@civfix/shared" import type { BroadcastVarValues } from "@civfix/shared/host" import type { FastifyBaseLogger } from "fastify" import { isWithinQuietHours, pushGateAllows, type PushGateMode } from "../notification-helpers.js" -import type { NotificationPrefsRecord, NotificationService } from "../notification-service.js" +import type { NotificationService } from "../notification-service.js" +import type { NotificationPrefsRecord } from "../notification-repository.js" import type { BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastRecord, diff --git a/services/api/src/services/host/broadcast-repository.drizzle.ts b/services/api/src/services/host/broadcast-repository.drizzle.ts index 8b7f8b7b..b67a561c 100644 --- a/services/api/src/services/host/broadcast-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-repository.drizzle.ts @@ -23,7 +23,7 @@ import type { DeliveryListRow, KeysetRow, } from "./broadcast-repository.js" -import type { NotificationPrefsRecord } from "../notification-service.js" +import type { NotificationPrefsRecord } from "../notification-repository.js" import type { AdminBroadcastRow, AdminHostListParams, diff --git a/services/api/src/services/host/broadcast-repository.memory.ts b/services/api/src/services/host/broadcast-repository.memory.ts index c6cc0bb1..cc8d5ddb 100644 --- a/services/api/src/services/host/broadcast-repository.memory.ts +++ b/services/api/src/services/host/broadcast-repository.memory.ts @@ -13,7 +13,7 @@ import type { KeysetRow, } from "./broadcast-repository.js" import { DEFAULT_PREFS } from "../notification-helpers.js" -import type { NotificationPrefsRecord } from "../notification-service.js" +import type { NotificationPrefsRecord } from "../notification-repository.js" import type { WriteAuditInput } from "../admin/audit.js" import type { KeysetCursor } from "../../db/cursor-helpers.js" import type { diff --git a/services/api/src/services/host/broadcast-repository.ts b/services/api/src/services/host/broadcast-repository.ts index 4fad9853..03f4e16c 100644 --- a/services/api/src/services/host/broadcast-repository.ts +++ b/services/api/src/services/host/broadcast-repository.ts @@ -5,7 +5,7 @@ import type { DeliveryStatus, } from "@civfix/shared" import type { KeysetCursor } from "../../db/cursor-helpers.js" -import type { NotificationPrefsRecord } from "../notification-service.js" +import type { NotificationPrefsRecord } from "../notification-repository.js" import type { WriteAuditInput } from "../admin/audit.js" import type { AdminBroadcastRow, diff --git a/services/api/src/services/media-authorization.ts b/services/api/src/services/media-authorization.ts index 1f5a196a..4f18ce9c 100644 --- a/services/api/src/services/media-authorization.ts +++ b/services/api/src/services/media-authorization.ts @@ -1,5 +1,6 @@ import type { Sql } from "../db/client.js" -import type { MediaAssetView, MediaOwner } from "./media-intake-service.js" +import type { MediaOwner } from "./media-intake-service.js" +import type { MediaAssetView } from "./media-repository.js" import { isPubliclyVisibleStatus } from "./report-visibility.js" import { makeDrizzleMediaAuthorizationRepository, diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index 24b6ccc7..da717829 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -7,10 +7,8 @@ import type { FinalizeMediaResponse, MediaDTO, MediaKind, - MediaStatus, } from "@civfix/shared" import { MAX_IMAGE_BYTES, MAX_VIDEO_BYTES } from "@civfix/shared" -import type { MEDIA_PURPOSE_VALUES } from "../db/schema/types-host.js" import type { Jobs, Storage } from "@civfix/shared/interfaces" import { readEtag } from "./media-etag.js" import { uploaderOf, uploadersOf } from "./media-uploader.js" @@ -22,8 +20,7 @@ import { } from "./media-authorization.js" import { MS_PER_SECOND } from "../lib/time.js" import { MEDIA_CHECKS_JOB } from "../lib/queue-names.js" - -type MediaPurpose = (typeof MEDIA_PURPOSE_VALUES)[number] +import type { MediaAssetView, MediaRepository } from "./media-repository.js" interface IntakeLogger { warn(obj: unknown, msg?: string): void @@ -63,44 +60,6 @@ export interface MediaOwner { ipKey?: string | undefined } -export interface MediaAssetView { - id: string - uploadId: string - kind: MediaKind - codec: string | null - r2Key: string - servedKey: string | null - thumbKey: string | null - status: MediaStatus - width: number | null - height: number | null - byteSize: number | null - purpose?: MediaPurpose - reportId?: string | null - chatMessageId?: string | null - postId?: string | null - createdAt?: Date | null - finalizedAt?: Date | null - uploader?: string | null -} - -export interface NewMediaAsset { - id: string - uploadId: string - kind: MediaKind - r2Key: string - status: MediaStatus - byteSize: number - uploader: string -} - -export interface MediaRepository { - insert(row: NewMediaAsset): Promise - findByUploadId(uploadId: string): Promise - findById(id: string): Promise - markFinalized(uploadId: string, uploadEtag: string | null): Promise -} - export interface MediaIntakeDeps { repo: MediaRepository storage: Storage diff --git a/services/api/src/services/media-repository.drizzle.ts b/services/api/src/services/media-repository.drizzle.ts index 93368e14..cfe47791 100644 --- a/services/api/src/services/media-repository.drizzle.ts +++ b/services/api/src/services/media-repository.drizzle.ts @@ -1,7 +1,7 @@ import { and, eq, isNull, sql } from "drizzle-orm" import { mediaAssets } from "../db/schema/media.js" import type { Db } from "../db/client.js" -import type { MediaAssetView, MediaRepository, NewMediaAsset } from "./media-intake-service.js" +import type { MediaAssetView, MediaRepository, NewMediaAsset } from "./media-repository.js" function toView(row: typeof mediaAssets.$inferSelect): MediaAssetView { return { diff --git a/services/api/src/services/media-repository.ts b/services/api/src/services/media-repository.ts new file mode 100644 index 00000000..d5a82ed4 --- /dev/null +++ b/services/api/src/services/media-repository.ts @@ -0,0 +1,42 @@ +import type { MediaKind, MediaStatus } from "@civfix/shared" +import type { MEDIA_PURPOSE_VALUES } from "../db/schema/types-host.js" + +export type MediaPurpose = (typeof MEDIA_PURPOSE_VALUES)[number] + +export interface MediaAssetView { + id: string + uploadId: string + kind: MediaKind + codec: string | null + r2Key: string + servedKey: string | null + thumbKey: string | null + status: MediaStatus + width: number | null + height: number | null + byteSize: number | null + purpose?: MediaPurpose + reportId?: string | null + chatMessageId?: string | null + postId?: string | null + createdAt?: Date | null + finalizedAt?: Date | null + uploader?: string | null +} + +export interface NewMediaAsset { + id: string + uploadId: string + kind: MediaKind + r2Key: string + status: MediaStatus + byteSize: number + uploader: string +} + +export interface MediaRepository { + insert(row: NewMediaAsset): Promise + findByUploadId(uploadId: string): Promise + findById(id: string): Promise + markFinalized(uploadId: string, uploadEtag: string | null): Promise +} diff --git a/services/api/src/services/notification-helpers.ts b/services/api/src/services/notification-helpers.ts index a5afed27..bed1070c 100644 --- a/services/api/src/services/notification-helpers.ts +++ b/services/api/src/services/notification-helpers.ts @@ -1,5 +1,5 @@ import type { NotificationDTO, NotificationPrefsDTO, NotificationType } from "@civfix/shared" -import type { NotificationPrefsRecord, NotificationRecord } from "./notification-service.js" +import type { NotificationPrefsRecord, NotificationRecord } from "./notification-repository.js" import { MINUTES_PER_HOUR } from "../lib/time.js" export const DEFAULT_PREFS: NotificationPrefsRecord = { diff --git a/services/api/src/services/notification-repository.drizzle.ts b/services/api/src/services/notification-repository.drizzle.ts index 5f03c1f6..1e796c34 100644 --- a/services/api/src/services/notification-repository.drizzle.ts +++ b/services/api/src/services/notification-repository.drizzle.ts @@ -13,7 +13,7 @@ import type { NotificationRecord, NotificationRepository, PushTokenUpsertOutcome, -} from "./notification-service.js" +} from "./notification-repository.js" import { DEFAULT_PREFS, FEED_HIDDEN_NOTIFICATION_TYPES } from "./notification-service.js" import { AppError } from "@civfix/shared" import type { NotificationType, PushPlatform } from "@civfix/shared" diff --git a/services/api/src/services/notification-repository.ts b/services/api/src/services/notification-repository.ts new file mode 100644 index 00000000..a6e37fda --- /dev/null +++ b/services/api/src/services/notification-repository.ts @@ -0,0 +1,104 @@ +import type { NotificationType, PushPlatform, QuietHours } from "@civfix/shared" + +export interface NotificationRecord { + id: string + userId: string + type: NotificationType + title: string + body: string | null + link: string | null + readAt: Date | null + createdAt: Date +} + +export interface NewNotificationArgs { + userId: string + type: NotificationType + title: string + body: string | null + link: string | null +} + +export interface NotificationPrefsRecord { + push: boolean + cleanupChat: boolean + reportUpdates: boolean + follows: boolean + mentions: boolean + postInteractions: boolean + hostBroadcasts: boolean + quietStart: string | null + quietEnd: string | null + tz: string | null +} + +export interface NotificationPrefsPatch { + push?: boolean + cleanupChat?: boolean + reportUpdates?: boolean + follows?: boolean + mentions?: boolean + postInteractions?: boolean + hostBroadcasts?: boolean + quietHours?: QuietHours | null +} + +export interface CoalescedNotificationArgs { + userId: string + type: NotificationType + link: string + title: string + body: string | null + since: Date +} + +export interface NotificationRepository { + insertNotification(args: NewNotificationArgs): Promise + + listNotifications( + userId: string, + cursor: string | null, + limit: number, + ): Promise<{ records: NotificationRecord[]; nextCursor: string | null }> + + markRead(userId: string, ids: string[]): Promise + + clearByTypeAndLink(userId: string, type: NotificationType, link: string): Promise + + insertUnlessRecentDuplicate( + args: NewNotificationArgs & { since: Date }, + ): Promise<{ record: NotificationRecord; deduped: boolean }> + + refreshUnreadNotification(args: CoalescedNotificationArgs): Promise + + upsertCoalescedNotification( + args: CoalescedNotificationArgs, + ): Promise<{ record: NotificationRecord; coalesced: boolean }> + + deleteAllNotificationsForUser(userId: string): Promise + + findPrefs(userId: string): Promise + + findPrefsMany?(userIds: string[]): Promise> + + createDefaultPrefs(userId: string): Promise + + upsertPrefs(userId: string, patch: NotificationPrefsPatch): Promise + + upsertPushToken(args: { + userId: string + platform: PushPlatform + token: string + deviceId: string | null + }): Promise + + revokeToken(userId: string, platform: PushPlatform, token: string): Promise + + deletePushTokensForUser(userId: string): Promise + + findUserLocale(userId: string): Promise + + findUserLocaleMany?(userIds: string[]): Promise> +} + +export type PushTokenUpsertOutcome = "stored" | "conflict" diff --git a/services/api/src/services/notification-service.ts b/services/api/src/services/notification-service.ts index 2a8a1413..966b79ae 100644 --- a/services/api/src/services/notification-service.ts +++ b/services/api/src/services/notification-service.ts @@ -5,15 +5,14 @@ import type { NotificationPrefsDTO, NotificationType, PaginationQuery, - PushPlatform, - QuietHours, RegisterPushTokenRequest, UnregisterPushTokenRequest, UpdateNotificationPrefsRequest, } from "@civfix/shared" import type { PushPayload, PushSender, UserChannel } from "@civfix/shared/interfaces" import type { FastifyBaseLogger } from "fastify" -import type { PersonView, SocialNotifier } from "./social-service.js" +import type { SocialNotifier } from "./social-service.js" +import type { PersonView } from "./social-repository.js" import type { PushGateMode } from "./notification-helpers.js" import { DEFAULT_PREFS, @@ -27,6 +26,12 @@ import { classifyPushToken, isRegistrablePushEndpoint } from "./push-token-polic import { renderMessage, type MessageKey, type MessageVars } from "../i18n/renderMessage.js" import { DEFAULT_LOCALE } from "../i18n/locales.js" import { actorDisplayName } from "./public-author.js" +import type { + NotificationPrefsPatch, + NotificationPrefsRecord, + NotificationRecord, + NotificationRepository, +} from "./notification-repository.js" export { DEFAULT_PREFS, @@ -61,109 +66,6 @@ function personLink(userId: string): string { return `/people/${userId}` } -export interface NotificationRecord { - id: string - userId: string - type: NotificationType - title: string - body: string | null - link: string | null - readAt: Date | null - createdAt: Date -} - -export interface NewNotificationArgs { - userId: string - type: NotificationType - title: string - body: string | null - link: string | null -} - -export interface NotificationPrefsRecord { - push: boolean - cleanupChat: boolean - reportUpdates: boolean - follows: boolean - mentions: boolean - postInteractions: boolean - hostBroadcasts: boolean - quietStart: string | null - quietEnd: string | null - tz: string | null -} - -export interface NotificationPrefsPatch { - push?: boolean - cleanupChat?: boolean - reportUpdates?: boolean - follows?: boolean - mentions?: boolean - postInteractions?: boolean - hostBroadcasts?: boolean - quietHours?: QuietHours | null -} - -export interface CoalescedNotificationArgs { - userId: string - type: NotificationType - link: string - title: string - body: string | null - since: Date -} - -export interface NotificationRepository { - insertNotification(args: NewNotificationArgs): Promise - - listNotifications( - userId: string, - cursor: string | null, - limit: number, - ): Promise<{ records: NotificationRecord[]; nextCursor: string | null }> - - markRead(userId: string, ids: string[]): Promise - - clearByTypeAndLink(userId: string, type: NotificationType, link: string): Promise - - insertUnlessRecentDuplicate( - args: NewNotificationArgs & { since: Date }, - ): Promise<{ record: NotificationRecord; deduped: boolean }> - - refreshUnreadNotification(args: CoalescedNotificationArgs): Promise - - upsertCoalescedNotification( - args: CoalescedNotificationArgs, - ): Promise<{ record: NotificationRecord; coalesced: boolean }> - - deleteAllNotificationsForUser(userId: string): Promise - - findPrefs(userId: string): Promise - - findPrefsMany?(userIds: string[]): Promise> - - createDefaultPrefs(userId: string): Promise - - upsertPrefs(userId: string, patch: NotificationPrefsPatch): Promise - - upsertPushToken(args: { - userId: string - platform: PushPlatform - token: string - deviceId: string | null - }): Promise - - revokeToken(userId: string, platform: PushPlatform, token: string): Promise - - deletePushTokensForUser(userId: string): Promise - - findUserLocale(userId: string): Promise - - findUserLocaleMany?(userIds: string[]): Promise> -} - -export type PushTokenUpsertOutcome = "stored" | "conflict" - export interface CreateNotificationInput { type: NotificationType title?: string diff --git a/services/api/src/services/report-clustering.ts b/services/api/src/services/report-clustering.ts index 1cfa39f0..b0ac11d8 100644 --- a/services/api/src/services/report-clustering.ts +++ b/services/api/src/services/report-clustering.ts @@ -1,6 +1,7 @@ import { latLngToCell } from "h3-js" import type { ReportCategory, ReportClusterDTO, ReportStatus, ReportType } from "@civfix/shared" -import { REPORT_H3_RESOLUTION, type ReportMapPoint } from "./report-types.js" +import { REPORT_H3_RESOLUTION } from "./report-types.js" +import type { ReportMapPoint } from "./report-repository.js" // Bounds the payload and per-request work for a wide bbox; clustering then collapses them to far fewer pins. export const MAP_REPORTS_CANDIDATE_CAP = 2000 diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index fd0023b8..b397516b 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -17,6 +17,7 @@ import { import { isPubliclyVisibleStatus, ownerStatusTransition } from "./report-visibility.js" import { allocateReportReferenceCode } from "../db/reference-code.js" import { likeContains } from "../db/like.js" +import type { ReportVisibilityTimelineKind } from "./report-types.js" import type { BBox, CreateReportTxArgs, @@ -27,8 +28,7 @@ import type { ReportRecord, ReportRepository, ReportTimelineView, - ReportVisibilityTimelineKind, -} from "./report-types.js" +} from "./report-repository.js" import { servedKeyExpr, servableMediaFilter } from "./media-served-key.js" import { claimableAsReportMedia } from "./media-bindings.js" import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" diff --git a/services/api/src/services/report-repository.ts b/services/api/src/services/report-repository.ts new file mode 100644 index 00000000..63a3465b --- /dev/null +++ b/services/api/src/services/report-repository.ts @@ -0,0 +1,159 @@ +import type { + AddressPrecision, + GeomSource, + ReportAddressSource, + ReportCategory, + ReportDTO, + ReportStatus, + ReportType, + ReportVisibility, +} from "@civfix/shared" +import type { ReportVisibilityTimelineKind } from "./report-types.js" + +export interface ReportMediaView { + id: string + kind: "image" | "video" + codec: string | null + r2Key: string + thumbKey: string | null + status: "validating" | "ready" | "rejected" | "held" + width: number | null + height: number | null +} + +export interface ReportTimelineView { + status: ReportStatus + note: string | null + kind: string | null + body: string | null + createdAt: Date +} + +export interface ReportRecord { + id: string + reporterUserId: string | null + anonSessionId: string | null + category: ReportCategory + type: ReportType + title: string | null + description: string | null + addr: string | null + addrSource: ReportAddressSource | null + addrPrecision: AddressPrecision | null + status: ReportStatus + visibility: ReportVisibility + lat: number + lng: number + geomSource: GeomSource + jurisdictionGeoid: string | null + referenceCode: string | null + createdAt: Date + publishedAt: Date | null + deletedAt: Date | null +} + +export interface ReportMapPoint { + id: string + lat: number + lng: number + category: ReportCategory + type: ReportType + status: ReportStatus + title: string | null + description: string | null + addr: string | null + referenceCode: string | null + thumbKey: string | null + r2Key: string | null +} + +export interface CreateReportTxArgs { + reportId: string + reporterUserId: string + guestAnonSessionId?: string | undefined + idempotencyKey: string + lat: number + lng: number + geomSource: GeomSource + jurisdictionGeoid: string | null + jurCode: number + category: ReportCategory + type: ReportType + title: string | null + description: string | null + addr: string | null + addrSource: ReportAddressSource | null + addrPrecision: AddressPrecision | null + status: ReportStatus + visibility: ReportVisibility + h3Cell: string + publishedAt: Date | null + mediaUploadIds: string[] + timelineNote: string | null + idempotency: { key: string; scope: string; userOrAnon: string | null } + buildSnapshot: ( + record: ReportRecord, + media: ReportMediaView[], + timeline: ReportTimelineView[], + ) => Promise +} + +export type CreateReportTxResult = + | { kind: "created"; snapshot: ReportDTO } + | { kind: "replayed"; snapshot: ReportDTO } + +export interface ReportRepository { + findIdempotentSnapshot( + key: string, + scope: string, + userOrAnon: string | null, + ): Promise + createReportTx(args: CreateReportTxArgs): Promise + findReportById(id: string): Promise + findReportByReferenceCode(code: string): Promise + findMediaForReport(reportId: string, ownerView?: boolean): Promise + countValidatingMediaForReport(reportId: string): Promise + findMediaForReports( + reportIds: string[], + ownerView?: boolean, + ): Promise> + findTimelineForReport(reportId: string): Promise + findTimelineForReports(reportIds: string[]): Promise> + listMyReports( + userId: string, + cursor: string | null, + limit: number, + ): Promise<{ records: ReportRecord[]; nextCursor: string | null }> + findMapCandidates( + bbox: BBox, + categories: ReportCategory[] | null, + types: ReportType[] | null, + cap: number, + ): Promise + searchReports(args: { + q: string | null + categories: ReportCategory[] | null + types: ReportType[] | null + cursor: string | null + limit: number + }): Promise<{ points: ReportMapPoint[]; nextCursor: string | null }> + resolveByOwner( + reportId: string, + userId: string, + input: { status: OwnerToggleStatus; note: string }, + ): Promise<"updated" | "unchanged" | "not_found" | "forbidden" | "invalid_state"> + setVisibilityByOwner( + reportId: string, + userId: string, + input: { visibility: ReportVisibility; note: string; kind: ReportVisibilityTimelineKind }, + ): Promise<"updated" | "unchanged" | "not_found" | "forbidden"> +} + +export type OwnerToggleStatus = Extract + +export interface BBox { + west: number + south: number + east: number + north: number +} diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index c16e93ce..8aee3bf7 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -42,20 +42,22 @@ import { REPORT_VISIBILITY_TIMELINE_KIND, REPORTS_DEFAULT_LIMIT, REPORTS_SEARCH_DEFAULT_LIMIT, - type BBox, - type OwnerToggleStatus, type ReportAutoForwardJob, type ReportChatMeta, type ReportDiscussionMeta, - type ReportMediaView, type ReportOwner, type SignedInReportOwner, - type ReportRecord, type ReportSearchInput, type ReportService, type ReportServiceDeps, - type ReportTimelineView, } from "./report-types.js" +import type { + BBox, + OwnerToggleStatus, + ReportMediaView, + ReportRecord, + ReportTimelineView, +} from "./report-repository.js" export * from "./report-types.js" export * from "./report-clustering.js" diff --git a/services/api/src/services/report-sql.ts b/services/api/src/services/report-sql.ts index 7edc15f0..d5432811 100644 --- a/services/api/src/services/report-sql.ts +++ b/services/api/src/services/report-sql.ts @@ -13,7 +13,7 @@ import type { ReportMediaView, ReportRecord, ReportTimelineView, -} from "./report-types.js" +} from "./report-repository.js" export interface ReportRowSelect { id: string diff --git a/services/api/src/services/report-types.ts b/services/api/src/services/report-types.ts index 074e9e3d..515d934f 100644 --- a/services/api/src/services/report-types.ts +++ b/services/api/src/services/report-types.ts @@ -1,15 +1,11 @@ import type { - AddressPrecision, CreateReportRequest, - GeomSource, ListMyReportsResponse, ListReportsSearchResponse, PaginationQuery, ReportCategory, ReportClusterResponse, - ReportAddressSource, ReportDTO, - ReportStatus, ReportType, ReportVisibility, } from "@civfix/shared" @@ -17,6 +13,7 @@ import type { Jobs } from "@civfix/shared/interfaces" import type { LinkedEventView } from "./cleanup-service.js" import type { ReportChatSystemEmitter } from "./report-timeline-event.js" import type { AddressResolver } from "./address-resolver.js" +import type { BBox, ReportRepository } from "./report-repository.js" export const REPORT_CREATE_SCOPE = "report_create" @@ -46,154 +43,6 @@ export interface SignedInReportOwner { guestAnonSessionId?: string | undefined } -export interface ReportMediaView { - id: string - kind: "image" | "video" - codec: string | null - r2Key: string - thumbKey: string | null - status: "validating" | "ready" | "rejected" | "held" - width: number | null - height: number | null -} - -export interface ReportTimelineView { - status: ReportStatus - note: string | null - kind: string | null - body: string | null - createdAt: Date -} - -export interface ReportRecord { - id: string - reporterUserId: string | null - anonSessionId: string | null - category: ReportCategory - type: ReportType - title: string | null - description: string | null - addr: string | null - addrSource: ReportAddressSource | null - addrPrecision: AddressPrecision | null - status: ReportStatus - visibility: ReportVisibility - lat: number - lng: number - geomSource: GeomSource - jurisdictionGeoid: string | null - referenceCode: string | null - createdAt: Date - publishedAt: Date | null - deletedAt: Date | null -} - -export interface ReportMapPoint { - id: string - lat: number - lng: number - category: ReportCategory - type: ReportType - status: ReportStatus - title: string | null - description: string | null - addr: string | null - referenceCode: string | null - thumbKey: string | null - r2Key: string | null -} - -export interface CreateReportTxArgs { - reportId: string - reporterUserId: string - guestAnonSessionId?: string | undefined - idempotencyKey: string - lat: number - lng: number - geomSource: GeomSource - jurisdictionGeoid: string | null - jurCode: number - category: ReportCategory - type: ReportType - title: string | null - description: string | null - addr: string | null - addrSource: ReportAddressSource | null - addrPrecision: AddressPrecision | null - status: ReportStatus - visibility: ReportVisibility - h3Cell: string - publishedAt: Date | null - mediaUploadIds: string[] - timelineNote: string | null - idempotency: { key: string; scope: string; userOrAnon: string | null } - buildSnapshot: ( - record: ReportRecord, - media: ReportMediaView[], - timeline: ReportTimelineView[], - ) => Promise -} - -export type CreateReportTxResult = - | { kind: "created"; snapshot: ReportDTO } - | { kind: "replayed"; snapshot: ReportDTO } - -export interface ReportRepository { - findIdempotentSnapshot( - key: string, - scope: string, - userOrAnon: string | null, - ): Promise - createReportTx(args: CreateReportTxArgs): Promise - findReportById(id: string): Promise - findReportByReferenceCode(code: string): Promise - findMediaForReport(reportId: string, ownerView?: boolean): Promise - countValidatingMediaForReport(reportId: string): Promise - findMediaForReports( - reportIds: string[], - ownerView?: boolean, - ): Promise> - findTimelineForReport(reportId: string): Promise - findTimelineForReports(reportIds: string[]): Promise> - listMyReports( - userId: string, - cursor: string | null, - limit: number, - ): Promise<{ records: ReportRecord[]; nextCursor: string | null }> - findMapCandidates( - bbox: BBox, - categories: ReportCategory[] | null, - types: ReportType[] | null, - cap: number, - ): Promise - searchReports(args: { - q: string | null - categories: ReportCategory[] | null - types: ReportType[] | null - cursor: string | null - limit: number - }): Promise<{ points: ReportMapPoint[]; nextCursor: string | null }> - resolveByOwner( - reportId: string, - userId: string, - input: { status: OwnerToggleStatus; note: string }, - ): Promise<"updated" | "unchanged" | "not_found" | "forbidden" | "invalid_state"> - setVisibilityByOwner( - reportId: string, - userId: string, - input: { visibility: ReportVisibility; note: string; kind: ReportVisibilityTimelineKind }, - ): Promise<"updated" | "unchanged" | "not_found" | "forbidden"> -} - -export type OwnerToggleStatus = Extract - -export interface BBox { - west: number - south: number - east: number - north: number -} - export interface ReportSearchInput { q?: string | undefined categories?: ReportCategory[] | undefined diff --git a/services/api/src/services/report-visibility.ts b/services/api/src/services/report-visibility.ts index 92cae007..422c1262 100644 --- a/services/api/src/services/report-visibility.ts +++ b/services/api/src/services/report-visibility.ts @@ -1,6 +1,6 @@ import type { ReportStatus } from "@civfix/shared" import type { DiscussionReportView } from "./discussion-repository.js" -import type { OwnerToggleStatus } from "./report-types.js" +import type { OwnerToggleStatus } from "./report-repository.js" /** * The statuses at which a report is PUBLICLY readable. diff --git a/services/api/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index 1f8dfc6d..0468a49f 100644 --- a/services/api/src/services/social-repository.drizzle.ts +++ b/services/api/src/services/social-repository.drizzle.ts @@ -7,7 +7,7 @@ import type { ProfileStats, SocialRepository, UpcomingEventsArgs, -} from "./social-service.js" +} from "./social-repository.js" import type { CleanupRecord, CleanupPersonView } from "./cleanup-service.js" import type { CleanupStatus, diff --git a/services/api/src/services/social-repository.ts b/services/api/src/services/social-repository.ts new file mode 100644 index 00000000..b715d721 --- /dev/null +++ b/services/api/src/services/social-repository.ts @@ -0,0 +1,85 @@ +import type { SocialLinks } from "@civfix/shared" +import type { CleanupRecord } from "./cleanup-service.js" + +export interface PersonView { + id: string + displayName: string + handle: string | null + bio: string | null + followers: number + following: number + avatarR2Key: string | null + avatarUrl: string | null + socialLinks: SocialLinks | null + donationUrl: string | null + showVolunteerHours: boolean | null +} + +export interface ProfileStats { + reports: number + fixed: number + cleanups: number +} + +export interface ProfileEventsPageArgs { + cursor: string | null + limit: number +} + +export interface ProfileEventsPage { + items: CleanupRecord[] + nextCursor: string | null +} + +export interface UpcomingEventsArgs { + includeAttending: boolean + limit: number +} + +export interface PeoplePage { + items: Array + nextCursor: string | null +} + +export interface ConnectionsArgs { + id: string + viewerId: string | null + cursor: string | null + limit: number +} + +export interface SocialRepository { + listPeople(args: { + viewerId: string | null + q: string | null + cursor: string | null + limit: number + }): Promise + + listFollowers(args: ConnectionsArgs): Promise + + listFollowing(args: ConnectionsArgs): Promise + + suggestFollows(args: { + viewerId: string + limit: number + }): Promise> + + findPersonById(id: string): Promise + + findPersonByHandle(handle: string): Promise + + isFollowing(followerId: string, followeeId: string): Promise + + addFollow(followerId: string, followeeId: string): Promise<{ exists: boolean; created: boolean }> + + removeFollow(followerId: string, followeeId: string): Promise<{ exists: boolean }> + + followerCount(userId: string): Promise + + pastEventsPageFor(userId: string, args: ProfileEventsPageArgs): Promise + + upcomingEventsFor(userId: string, args: UpcomingEventsArgs): Promise + + statsFor(userId: string): Promise +} diff --git a/services/api/src/services/social-service.ts b/services/api/src/services/social-service.ts index 635f4a5a..44bf17d8 100644 --- a/services/api/src/services/social-service.ts +++ b/services/api/src/services/social-service.ts @@ -7,11 +7,16 @@ import type { PaginationQuery, PersonDTO, ProfileEventsResponse, - SocialLinks, UserProfileDTO, } from "@civfix/shared" -import { toCleanupDTO, type CleanupRecord } from "./cleanup-service.js" +import { toCleanupDTO } from "./cleanup-service.js" import { attachAffiliations, type AffiliationLoader } from "./affiliation.js" +import type { + ConnectionsArgs, + PeoplePage, + PersonView, + SocialRepository, +} from "./social-repository.js" const PEOPLE_DEFAULT_LIMIT = 20 @@ -46,89 +51,6 @@ const PROFILE_UPCOMING_EVENTS_LIMIT = 20 export type ProfileWithBlock = UserProfileDTO & { blockedByMe?: boolean } -export interface PersonView { - id: string - displayName: string - handle: string | null - bio: string | null - followers: number - following: number - avatarR2Key: string | null - avatarUrl: string | null - socialLinks: SocialLinks | null - donationUrl: string | null - showVolunteerHours: boolean | null -} - -export interface ProfileStats { - reports: number - fixed: number - cleanups: number -} - -export interface ProfileEventsPageArgs { - cursor: string | null - limit: number -} - -export interface ProfileEventsPage { - items: CleanupRecord[] - nextCursor: string | null -} - -export interface UpcomingEventsArgs { - includeAttending: boolean - limit: number -} - -export interface PeoplePage { - items: Array - nextCursor: string | null -} - -export interface ConnectionsArgs { - id: string - viewerId: string | null - cursor: string | null - limit: number -} - -export interface SocialRepository { - listPeople(args: { - viewerId: string | null - q: string | null - cursor: string | null - limit: number - }): Promise - - listFollowers(args: ConnectionsArgs): Promise - - listFollowing(args: ConnectionsArgs): Promise - - suggestFollows(args: { - viewerId: string - limit: number - }): Promise> - - findPersonById(id: string): Promise - - findPersonByHandle(handle: string): Promise - - isFollowing(followerId: string, followeeId: string): Promise - - addFollow(followerId: string, followeeId: string): Promise<{ exists: boolean; created: boolean }> - - removeFollow(followerId: string, followeeId: string): Promise<{ exists: boolean }> - - followerCount(userId: string): Promise - - pastEventsPageFor(userId: string, args: ProfileEventsPageArgs): Promise - - upcomingEventsFor(userId: string, args: UpcomingEventsArgs): Promise - - statsFor(userId: string): Promise -} - export interface SocialNotifier { onNewFollower(args: { followeeId: string; follower: PersonView }): Promise } diff --git a/services/api/src/services/threads-repository.drizzle.ts b/services/api/src/services/threads-repository.drizzle.ts index 73acf397..b959f0e7 100644 --- a/services/api/src/services/threads-repository.drizzle.ts +++ b/services/api/src/services/threads-repository.drizzle.ts @@ -10,9 +10,8 @@ import { type GroupThreadsSource, type ReportThreadAggregateView, type ReportThreadsSource, - type ThreadAggregate, - type ThreadsRepository, } from "./threads-service.js" +import type { ThreadAggregate, ThreadsRepository } from "./threads-repository.js" function reportThreadTitle(category: ReportCategory | string, addr: string | null): string { const label = REPORT_CATEGORY_LABELS[category as ReportCategory] ?? category diff --git a/services/api/src/services/threads-repository.ts b/services/api/src/services/threads-repository.ts new file mode 100644 index 00000000..22411ef9 --- /dev/null +++ b/services/api/src/services/threads-repository.ts @@ -0,0 +1,23 @@ +import type { TimeCursor } from "../db/cursor-helpers.js" + +export interface ThreadAggregate { + cleanupId: string + title: string + joinedAt: Date + members: number + unread?: number + last: { + body: string | null + createdAt: Date + senderId: string + } | null +} + +export interface ThreadsRepository { + listThreadsFor( + userId: string, + limit: number, + cursor?: TimeCursor | null, + ): Promise + countUnread(cleanupId: string, userId: string, after: Date): Promise +} diff --git a/services/api/src/services/threads-service.ts b/services/api/src/services/threads-service.ts index 46dbd19c..8229b61a 100644 --- a/services/api/src/services/threads-service.ts +++ b/services/api/src/services/threads-service.ts @@ -7,6 +7,7 @@ import { parseTimeCursor, type TimeCursor, } from "../db/cursor-helpers.js" +import type { ThreadAggregate, ThreadsRepository } from "./threads-repository.js" export interface ChatReadState { markRead(cleanupId: string, userId: string, at: Date): Promise @@ -29,28 +30,6 @@ export class InMemoryChatReadState implements ChatReadState { } } -export interface ThreadAggregate { - cleanupId: string - title: string - joinedAt: Date - members: number - unread?: number - last: { - body: string | null - createdAt: Date - senderId: string - } | null -} - -export interface ThreadsRepository { - listThreadsFor( - userId: string, - limit: number, - cursor?: TimeCursor | null, - ): Promise - countUnread(cleanupId: string, userId: string, after: Date): Promise -} - export interface DmThreadAggregateView { threadId: string createdAt: Date diff --git a/services/api/src/services/volunteer-hours-anomaly.ts b/services/api/src/services/volunteer-hours-anomaly.ts index 7d843d3a..e319cb56 100644 --- a/services/api/src/services/volunteer-hours-anomaly.ts +++ b/services/api/src/services/volunteer-hours-anomaly.ts @@ -1,4 +1,4 @@ -import type { VolunteerHoursAnomalyKind } from "./volunteer-hours-service.js" +import type { VolunteerHoursAnomalyKind } from "./volunteer-hours-repository.js" const HOURS_ANOMALY_FLAG = "Volunteer hours anomaly" diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index 1a57f6fe..9a3cd131 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -33,7 +33,7 @@ import type { VolunteerHoursAnomaly, VolunteerHoursEntryView, VolunteerHoursRepository, -} from "./volunteer-hours-service.js" +} from "./volunteer-hours-repository.js" import { MS_PER_SECOND } from "../lib/time.js" const MORE_PAGES = "more" diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index a2c717de..2c824dcc 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -30,7 +30,7 @@ import type { VolunteerHoursAnomaly, VolunteerHoursEntryView, VolunteerHoursRepository, -} from "./volunteer-hours-service.js" +} from "./volunteer-hours-repository.js" import { MS_PER_WEEK } from "../lib/time.js" export interface MemoryLeaderboardUser { diff --git a/services/api/src/services/volunteer-hours-repository.ts b/services/api/src/services/volunteer-hours-repository.ts new file mode 100644 index 00000000..d3c6eb84 --- /dev/null +++ b/services/api/src/services/volunteer-hours-repository.ts @@ -0,0 +1,133 @@ +import type { + EventHoursEntry, + LeaderboardEntryDTO, + MyVolunteerHoursDTO, + OrganizationRefDTO, + VolunteerHoursSource, +} from "@civfix/shared" +import type { KeysetCursor } from "../db/cursor-helpers.js" + +export type VolunteerHoursAnomalyKind = "weekly_hours" | "reciprocal_credit" + +export interface VolunteerHoursAnomaly { + kind: VolunteerHoursAnomalyKind + userId: string + counterpartUserId: string | null + hours: number | null +} + +export interface LogEventHoursArgs { + actorId: string + cleanupId: string + geoid: string | null + entries: EventHoursEntry[] + dailyCapHours?: number + weeklyFlagHours?: number +} + +export interface LogEventHoursResult { + credited: number + changed: { userId: string; hours: number; previousHours: number | null }[] + anomalies: VolunteerHoursAnomaly[] +} + +export interface VolunteerHoursEntryView { + id: string + source: VolunteerHoursSource + hours: number + createdAt: Date + occurredAt: Date + cleanupId: string | null + cleanupTitle: string | null + cleanupReferenceCode: string | null + reportId: string | null + jurisdictionGeoid: string | null + jurisdictionName: string | null + creditedBy: { + id: string + name: string + handle: string | null + organization: OrganizationRefDTO | null + } | null +} + +export interface EventHoursLedgerEntry { + userId: string + hours: number + loggedAt: Date +} + +export interface EventHoursLedger { + entries: EventHoursLedgerEntry[] + anyLogged: boolean +} + +export interface HoursVisibility { + aggregate: boolean + items: boolean +} + +export interface LeaderboardPage { + jurisdictionName: string | null + entries: LeaderboardEntryDTO[] + nextOffset: number | null + participantCount: number | null + viewerRank: number | null + viewerHours: number | null +} + +export interface ListEntriesArgs { + userId: string + cursor: KeysetCursor | null + limit: number + sources?: VolunteerHoursSource[] +} + +export interface EntriesForCertificateArgs { + userId: string + geoid: string | null + from: Date | null + to: Date | null + limit: number +} + +export interface CertificateEntriesPage { + items: VolunteerHoursEntryView[] + totalHours: number + entryCount: number +} + +export interface OrgHoursView { + organizationId: string + slug: string + name: string + logoKey: string | null + verified: boolean + verifiedKind: OrganizationRefDTO["verifiedKind"] + hours: number +} + +export interface MyVolunteerHoursTotals { + totalHours: number + byJurisdiction: MyVolunteerHoursDTO["byJurisdiction"] + byOrganization: OrgHoursView[] +} + +export interface VolunteerHoursRepository { + logEventHours(args: LogEventHoursArgs): Promise + totalsFor(userId: string): Promise + totalHoursFor(userId: string): Promise + leaderboard( + geoid: string, + limit: number, + offset: number, + viewerId: string | null, + withExtras: boolean, + ): Promise + listEntries( + args: ListEntriesArgs, + ): Promise<{ items: VolunteerHoursEntryView[]; nextCursor: string | null }> + listEventHours(cleanupId: string, viewerId: string | null): Promise + hoursVisibilityFor(userId: string): Promise + entriesForCertificate(args: EntriesForCertificateArgs): Promise +} diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 6a3bdacd..692a7cd2 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -18,7 +18,6 @@ import type { MyVolunteerHoursDTO, MyVolunteerHoursEntriesQuery, MyVolunteerHoursEntriesResponse, - OrganizationRefDTO, OrgHoursDTO, PublicVolunteerHoursQuery, PublicVolunteerHoursResponse, @@ -26,7 +25,7 @@ import type { VolunteerHoursSource, } from "@civfix/shared" import { can, type HostStanding } from "@civfix/shared/host" -import { parseKeysetCursor, type KeysetCursor } from "../db/cursor-helpers.js" +import { parseKeysetCursor } from "../db/cursor-helpers.js" import { MIN_EVENT_DURATION_MS, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" import { mapWithLimit } from "../lib/concurrency.js" import { clampPageLimit } from "../lib/page-limit.js" @@ -37,6 +36,15 @@ import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" import type { TopVolunteerRow } from "./host/analytics-repository.drizzle.js" import type { InsightsInvalidator } from "./host/host-analytics-cache.js" import type { NotificationService } from "./notification-service.js" +import type { + EventHoursLedgerEntry, + LogEventHoursResult, + OrgHoursView, + VolunteerHoursAnomaly, + VolunteerHoursAnomalyKind, + VolunteerHoursEntryView, + VolunteerHoursRepository, +} from "./volunteer-hours-repository.js" const LEADERBOARD_DEFAULT_LIMIT = 20 export const LEADERBOARD_MAX_LIMIT = 50 @@ -62,15 +70,6 @@ export const WEEKLY_HOURS_FLAG_DEFAULT = 60 export const RECIPROCAL_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000 -export type VolunteerHoursAnomalyKind = "weekly_hours" | "reciprocal_credit" - -export interface VolunteerHoursAnomaly { - kind: VolunteerHoursAnomalyKind - userId: string - counterpartUserId: string | null - hours: number | null -} - export interface HoursModerationSink { flag(input: { userId: string @@ -99,105 +98,8 @@ function eventDurationMs(cleanup: EventHoursWindow): number { return end.getTime() - cleanup.scheduledAt.getTime() } -export interface LogEventHoursArgs { - actorId: string - cleanupId: string - geoid: string | null - entries: EventHoursEntry[] - dailyCapHours?: number - weeklyFlagHours?: number -} - -export interface LogEventHoursResult { - credited: number - changed: { userId: string; hours: number; previousHours: number | null }[] - anomalies: VolunteerHoursAnomaly[] -} - -export interface VolunteerHoursEntryView { - id: string - source: VolunteerHoursSource - hours: number - createdAt: Date - occurredAt: Date - cleanupId: string | null - cleanupTitle: string | null - cleanupReferenceCode: string | null - reportId: string | null - jurisdictionGeoid: string | null - jurisdictionName: string | null - creditedBy: { - id: string - name: string - handle: string | null - organization: OrganizationRefDTO | null - } | null -} - -export interface EventHoursLedgerEntry { - userId: string - hours: number - loggedAt: Date -} - -export interface EventHoursLedger { - entries: EventHoursLedgerEntry[] - anyLogged: boolean -} - -export interface HoursVisibility { - aggregate: boolean - items: boolean -} - -export interface LeaderboardPage { - jurisdictionName: string | null - entries: LeaderboardEntryDTO[] - nextOffset: number | null - participantCount: number | null - viewerRank: number | null - viewerHours: number | null -} - export const ITEMISED_SOURCES: readonly VolunteerHoursSource[] = ["event", "manual"] -export interface ListEntriesArgs { - userId: string - cursor: KeysetCursor | null - limit: number - sources?: VolunteerHoursSource[] -} - -export interface EntriesForCertificateArgs { - userId: string - geoid: string | null - from: Date | null - to: Date | null - limit: number -} - -export interface CertificateEntriesPage { - items: VolunteerHoursEntryView[] - totalHours: number - entryCount: number -} - -export interface OrgHoursView { - organizationId: string - slug: string - name: string - logoKey: string | null - verified: boolean - verifiedKind: OrganizationRefDTO["verifiedKind"] - hours: number -} - -export interface MyVolunteerHoursTotals { - totalHours: number - byJurisdiction: MyVolunteerHoursDTO["byJurisdiction"] - byOrganization: OrgHoursView[] -} - export type OrgLogoPresigner = (key: string) => Promise export function leaderboardEntryOf(row: TopVolunteerRow, rank: number): LeaderboardEntryDTO { @@ -212,25 +114,6 @@ export function leaderboardEntryOf(row: TopVolunteerRow, rank: number): Leaderbo } } -export interface VolunteerHoursRepository { - logEventHours(args: LogEventHoursArgs): Promise - totalsFor(userId: string): Promise - totalHoursFor(userId: string): Promise - leaderboard( - geoid: string, - limit: number, - offset: number, - viewerId: string | null, - withExtras: boolean, - ): Promise - listEntries( - args: ListEntriesArgs, - ): Promise<{ items: VolunteerHoursEntryView[]; nextCursor: string | null }> - listEventHours(cleanupId: string, viewerId: string | null): Promise - hoursVisibilityFor(userId: string): Promise - entriesForCertificate(args: EntriesForCertificateArgs): Promise -} - export interface CleanupHoursView { organizerUserId: string status: CleanupStatus diff --git a/services/api/test/helpers/anon.ts b/services/api/test/helpers/anon.ts index 5528ba8f..d743967f 100644 --- a/services/api/test/helpers/anon.ts +++ b/services/api/test/helpers/anon.ts @@ -25,15 +25,16 @@ import type { AnonTokenRecord } from "../../src/abuse/anon-token.js" import type { AnonReportRepository, AnonReportStatusRow, + ClaimRepository, CreateAnonReportTxArgs, CreateAnonReportTxResult, -} from "../../src/services/anon-service.js" + PendingAnonReport, +} from "../../src/services/anon-repository.js" import type { AnonHoldReleaseRepository, HeldReportView, ReleaseMediaView, } from "../../src/services/anon-hold-release.js" -import type { ClaimRepository, PendingAnonReport } from "../../src/services/claim-service.js" import { formatReferenceCode, reportScopeKey, typeCodeFor } from "../../src/db/reference-code.js" import type { ReportType } from "@civfix/shared" diff --git a/services/api/test/helpers/chat.ts b/services/api/test/helpers/chat.ts index 56890678..a2cd4f62 100644 --- a/services/api/test/helpers/chat.ts +++ b/services/api/test/helpers/chat.ts @@ -15,7 +15,7 @@ import { } from "../../src/services/chat-reply-hydration.js" import { aroundLimits } from "../../src/services/chat-history-window.js" import { toTombstoneDTO } from "../../src/services/chat-tombstone.js" -import type { ThreadAggregate, ThreadsRepository } from "../../src/services/threads-service.js" +import type { ThreadAggregate, ThreadsRepository } from "../../src/services/threads-repository.js" import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.drizzle.js" import { visibleAfterHides } from "../../src/services/conversation-hides-repository.memory.js" import type { TimeCursor } from "../../src/db/cursor-helpers.js" diff --git a/services/api/test/helpers/guest-rsvp.ts b/services/api/test/helpers/guest-rsvp.ts index 416bb682..bdc1f113 100644 --- a/services/api/test/helpers/guest-rsvp.ts +++ b/services/api/test/helpers/guest-rsvp.ts @@ -10,7 +10,7 @@ import type { GuestRsvpRepository, InsertGuestOtpArgs, UpsertGuestArgs, -} from "../../src/services/guest-rsvp-service.js" +} from "../../src/services/guest-rsvp-repository.js" import type { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" import type { GuestCountSource } from "./cleanups.js" diff --git a/services/api/test/helpers/media.ts b/services/api/test/helpers/media.ts index d0fbede5..21f28fab 100644 --- a/services/api/test/helpers/media.ts +++ b/services/api/test/helpers/media.ts @@ -2,7 +2,7 @@ import type { MediaAssetView, MediaRepository, NewMediaAsset, -} from "../../src/services/media-intake-service.js" +} from "../../src/services/media-repository.js" type StoredMedia = MediaAssetView & { uploadEtag: string | null } diff --git a/services/api/test/helpers/notifications.ts b/services/api/test/helpers/notifications.ts index 37657cce..24ab97f7 100644 --- a/services/api/test/helpers/notifications.ts +++ b/services/api/test/helpers/notifications.ts @@ -1,13 +1,13 @@ import { randomUUID } from "node:crypto" +import type { CreateNotificationInput } from "../../src/services/notification-service.js" import type { - CreateNotificationInput, NewNotificationArgs, NotificationPrefsPatch, NotificationPrefsRecord, NotificationRecord, NotificationRepository, PushTokenUpsertOutcome, -} from "../../src/services/notification-service.js" +} from "../../src/services/notification-repository.js" import { DEFAULT_PREFS, isFeedVisibleType } from "../../src/services/notification-service.js" import { MAX_ACTIVE_PUSH_TOKENS_PER_USER } from "../../src/services/notification-repository.drizzle.js" import { paginate, parseTimeCursor } from "../../src/db/cursor-helpers.js" diff --git a/services/api/test/helpers/reports.ts b/services/api/test/helpers/reports.ts index 505a6fb1..83ef968b 100644 --- a/services/api/test/helpers/reports.ts +++ b/services/api/test/helpers/reports.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto" +import type { ReportVisibilityTimelineKind } from "../../src/services/report-service.js" import type { BBox, CreateReportTxArgs, @@ -9,8 +10,7 @@ import type { ReportRecord, ReportRepository, ReportTimelineView, - ReportVisibilityTimelineKind, -} from "../../src/services/report-service.js" +} from "../../src/services/report-repository.js" import { formatReferenceCode, reportScopeKey, typeCodeFor } from "../../src/db/reference-code.js" import { isPubliclyVisibleStatus, diff --git a/services/api/test/helpers/social.ts b/services/api/test/helpers/social.ts index e1b18abe..4222b0e4 100644 --- a/services/api/test/helpers/social.ts +++ b/services/api/test/helpers/social.ts @@ -6,7 +6,7 @@ import type { ProfileStats, SocialRepository, UpcomingEventsArgs, -} from "../../src/services/social-service.js" +} from "../../src/services/social-repository.js" import type { CleanupRecord } from "../../src/services/cleanup-service.js" import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../src/db/cursor-helpers.js" import { diff --git a/services/api/test/integration/admin-activity.test.ts b/services/api/test/integration/admin-activity.test.ts index c0e1d68a..f9903616 100644 --- a/services/api/test/integration/admin-activity.test.ts +++ b/services/api/test/integration/admin-activity.test.ts @@ -20,7 +20,7 @@ import { makeDrizzleActivityRepository } from "../../src/services/admin/activity import type { ActivityRepository, ListActivityArgs, -} from "../../src/services/admin/activity-service.js" +} from "../../src/services/admin/activity-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-analytics.test.ts b/services/api/test/integration/admin-analytics.test.ts index d46fed3a..7e9822cb 100644 --- a/services/api/test/integration/admin-analytics.test.ts +++ b/services/api/test/integration/admin-analytics.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleAnalyticsRepository } from "../../src/services/admin/analytics-repository.drizzle.js" -import type { AnalyticsRepository } from "../../src/services/admin/analytics-service.js" +import type { AnalyticsRepository } from "../../src/services/admin/analytics-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" import type { CleanupStatus } from "@civfix/shared" diff --git a/services/api/test/integration/admin-audit.test.ts b/services/api/test/integration/admin-audit.test.ts index 538120f7..c17037b0 100644 --- a/services/api/test/integration/admin-audit.test.ts +++ b/services/api/test/integration/admin-audit.test.ts @@ -15,7 +15,7 @@ import { makeDrizzleAuditRepository, } from "../../src/services/admin/audit-repository.drizzle.js" import type { AdminAuditAction } from "../../src/services/admin/audit.js" -import type { AuditRepository } from "../../src/services/admin/audit-service.js" +import type { AuditRepository } from "../../src/services/admin/audit-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-discovery.test.ts b/services/api/test/integration/admin-discovery.test.ts index 0a4132f2..a9e65dc5 100644 --- a/services/api/test/integration/admin-discovery.test.ts +++ b/services/api/test/integration/admin-discovery.test.ts @@ -2,8 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleDiscoveryRepository } from "../../src/services/admin/discovery-repository.drizzle.js" import { makeDrizzleJurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.drizzle.js" -import type { DiscoveryRepository } from "../../src/services/admin/discovery-service.js" -import type { JurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-service.js" +import type { DiscoveryRepository } from "../../src/services/admin/discovery-repository.js" +import type { JurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-events.test.ts b/services/api/test/integration/admin-events.test.ts index be76a9c6..8362bf44 100644 --- a/services/api/test/integration/admin-events.test.ts +++ b/services/api/test/integration/admin-events.test.ts @@ -21,7 +21,7 @@ import { withPg, type PgHarness } from "../helpers/pg.js" import { seedOfficialAccount } from "../helpers/official-account.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" import { makeDrizzleAdminEventRepository } from "../../src/services/admin/admin-event-repository.drizzle.js" -import type { AdminEventRepository } from "../../src/services/admin/admin-event-service.js" +import type { AdminEventRepository } from "../../src/services/admin/admin-event-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-gov-claims.test.ts b/services/api/test/integration/admin-gov-claims.test.ts index bfcfce2d..8ec28fdd 100644 --- a/services/api/test/integration/admin-gov-claims.test.ts +++ b/services/api/test/integration/admin-gov-claims.test.ts @@ -22,11 +22,11 @@ import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleGovClaimsRepository } from "../../src/services/admin/gov-claims-repository.drizzle.js" import { makeGovClaimsService, - type GovClaimsRepository, type GovClaimsService, type ProvisionedUser, type UserProvisioner, } from "../../src/services/admin/gov-claims-service.js" +import type { GovClaimsRepository } from "../../src/services/admin/gov-claims-repository.js" import { PgAuthStores } from "../../src/auth/pg-stores.js" import type { UserStore } from "../../src/auth/stores.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" diff --git a/services/api/test/integration/admin-jurisdiction-directory.test.ts b/services/api/test/integration/admin-jurisdiction-directory.test.ts index e665c671..d42b8c01 100644 --- a/services/api/test/integration/admin-jurisdiction-directory.test.ts +++ b/services/api/test/integration/admin-jurisdiction-directory.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleJurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.drizzle.js" -import type { JurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-types.js" +import type { JurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-moderation.test.ts b/services/api/test/integration/admin-moderation.test.ts index 8b219b28..8cf6e230 100644 --- a/services/api/test/integration/admin-moderation.test.ts +++ b/services/api/test/integration/admin-moderation.test.ts @@ -6,7 +6,7 @@ import { insertModerationItem, makeDrizzleModerationRepository, } from "../../src/services/admin/moderation-repository.drizzle.js" -import type { ModerationRepository } from "../../src/services/admin/moderation-service.js" +import type { ModerationRepository } from "../../src/services/admin/moderation-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-reports.test.ts b/services/api/test/integration/admin-reports.test.ts index 48b9f36c..f51484a7 100644 --- a/services/api/test/integration/admin-reports.test.ts +++ b/services/api/test/integration/admin-reports.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { makeDrizzleAdminReportRepository } from "../../src/services/admin/admin-report-repository.drizzle.js" -import type { AdminReportRepository } from "../../src/services/admin/admin-report-service.js" +import type { AdminReportRepository } from "../../src/services/admin/admin-report-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() diff --git a/services/api/test/integration/admin-users.test.ts b/services/api/test/integration/admin-users.test.ts index 309c6539..f4d0a982 100644 --- a/services/api/test/integration/admin-users.test.ts +++ b/services/api/test/integration/admin-users.test.ts @@ -23,7 +23,7 @@ import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleAdminUserRepository } from "../../src/services/admin/admin-user-repository.drizzle.js" -import type { AdminUserRepository } from "../../src/services/admin/admin-user-service.js" +import type { AdminUserRepository } from "../../src/services/admin/admin-user-repository.js" import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() diff --git a/services/api/test/integration/guest-rsvp-pg.test.ts b/services/api/test/integration/guest-rsvp-pg.test.ts index 21c5a39e..c396c832 100644 --- a/services/api/test/integration/guest-rsvp-pg.test.ts +++ b/services/api/test/integration/guest-rsvp-pg.test.ts @@ -6,7 +6,7 @@ import { seedCleanup } from "../helpers/cleanups.js" import { parseKeysetCursor, type KeysetCursor } from "../../src/db/cursor-helpers.js" import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" import { makeDrizzleSocialRepository } from "../../src/services/social-repository.drizzle.js" -import type { GuestRsvpRepository } from "../../src/services/guest-rsvp-service.js" +import type { GuestRsvpRepository } from "../../src/services/guest-rsvp-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/guest-rsvp-security-pg.test.ts b/services/api/test/integration/guest-rsvp-security-pg.test.ts index 35e3e67c..3a39d99c 100644 --- a/services/api/test/integration/guest-rsvp-security-pg.test.ts +++ b/services/api/test/integration/guest-rsvp-security-pg.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" -import type { GuestRsvpRepository } from "../../src/services/guest-rsvp-service.js" +import type { GuestRsvpRepository } from "../../src/services/guest-rsvp-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts b/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts index a9c88b9e..83dc1552 100644 --- a/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts +++ b/services/api/test/integration/guest-rsvp-upsert-created-pg.test.ts @@ -2,7 +2,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" -import type { GuestRsvpRepository, UpsertGuestArgs } from "../../src/services/guest-rsvp-service.js" +import type { + GuestRsvpRepository, + UpsertGuestArgs, +} from "../../src/services/guest-rsvp-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/moderation-destination-refs-pg.test.ts b/services/api/test/integration/moderation-destination-refs-pg.test.ts index 25095683..8a98ae4f 100644 --- a/services/api/test/integration/moderation-destination-refs-pg.test.ts +++ b/services/api/test/integration/moderation-destination-refs-pg.test.ts @@ -10,7 +10,7 @@ import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleModerationRepository } from "../../src/services/admin/moderation-repository.drizzle.js" -import type { ModerationRepository } from "../../src/services/admin/moderation-service.js" +import type { ModerationRepository } from "../../src/services/admin/moderation-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/social-connections-pg.test.ts b/services/api/test/integration/social-connections-pg.test.ts index 76085c4b..481594d1 100644 --- a/services/api/test/integration/social-connections-pg.test.ts +++ b/services/api/test/integration/social-connections-pg.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, seedFollowEdge, testHandle, type PgHarness } from "../helpers/pg.js" import { makeDrizzleSocialRepository } from "../../src/services/social-repository.drizzle.js" -import type { SocialRepository } from "../../src/services/social-service.js" +import type { SocialRepository } from "../../src/services/social-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/suggest-follows-pg.test.ts b/services/api/test/integration/suggest-follows-pg.test.ts index 6bdbd1fe..0b5d04c1 100644 --- a/services/api/test/integration/suggest-follows-pg.test.ts +++ b/services/api/test/integration/suggest-follows-pg.test.ts @@ -9,7 +9,7 @@ import { } from "../../src/services/social-repository.drizzle.js" import { backfillUserActivity } from "../../src/db/backfill-user-activity-core.js" import { touchUserActivity } from "../../src/db/sql/user-activity.js" -import type { SocialRepository } from "../../src/services/social-service.js" +import type { SocialRepository } from "../../src/services/social-repository.js" const pg = await withPg() diff --git a/services/api/test/unit/admin-home.test.ts b/services/api/test/unit/admin-home.test.ts index a42908a8..e69fca55 100644 --- a/services/api/test/unit/admin-home.test.ts +++ b/services/api/test/unit/admin-home.test.ts @@ -9,10 +9,8 @@ import { type HomeRepository, type HomeService, } from "../../src/services/admin/home-service.js" -import { - PINS_BY_WEEK_WEEKS, - type AnalyticsRepository, -} from "../../src/services/admin/analytics-types.js" +import { PINS_BY_WEEK_WEEKS } from "../../src/services/admin/analytics-types.js" +import type { AnalyticsRepository } from "../../src/services/admin/analytics-repository.js" const NOW = new Date("2026-06-15T12:00:00.000Z") diff --git a/services/api/test/unit/admin-host-platform-routes.test.ts b/services/api/test/unit/admin-host-platform-routes.test.ts index ebea3179..4e867c52 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -12,7 +12,7 @@ import type { AdminEventPageRow } from "../../src/services/host/admin-pages-repo import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" -import type { MediaAssetView, MediaRepository } from "../../src/services/media-intake-service.js" +import type { MediaAssetView, MediaRepository } from "../../src/services/media-repository.js" const OPERATOR = "11111111-1111-1111-1111-111111111111" const CLEANUP = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/admin-jurisdiction-contacts.test.ts b/services/api/test/unit/admin-jurisdiction-contacts.test.ts index 47a8f6d3..2c1c4ec2 100644 --- a/services/api/test/unit/admin-jurisdiction-contacts.test.ts +++ b/services/api/test/unit/admin-jurisdiction-contacts.test.ts @@ -13,8 +13,8 @@ import { directoryStatus, hasAnyContact, type JurisdictionContactsService, - type JurisdictionDirectoryRecord, } from "../../src/services/admin/jurisdiction-contacts-service.js" +import type { JurisdictionDirectoryRecord } from "../../src/services/admin/jurisdiction-contacts-repository.js" import { OUTREACH_DIGEST_JOB } from "../../src/lib/queue-names.js" const NOW = new Date("2026-06-06T00:00:00.000Z") diff --git a/services/api/test/unit/anon-repository-security.test.ts b/services/api/test/unit/anon-repository-security.test.ts index 48904915..41a4adba 100644 --- a/services/api/test/unit/anon-repository-security.test.ts +++ b/services/api/test/unit/anon-repository-security.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest" import type { AnonReportResponse } from "@civfix/shared" import { makeDrizzleAnonReportRepository } from "../../src/services/anon-repository.drizzle.js" -import { - ANON_REPORT_CREATE_SCOPE, - type CreateAnonReportTxArgs, -} from "../../src/services/anon-service.js" +import { ANON_REPORT_CREATE_SCOPE } from "../../src/services/anon-service.js" +import type { CreateAnonReportTxArgs } from "../../src/services/anon-repository.js" import { sha256Hex } from "../../src/auth/crypto.js" import type { Queryable, Sql } from "../../src/db/client.js" import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" diff --git a/services/api/test/unit/certificate-verify-url.test.ts b/services/api/test/unit/certificate-verify-url.test.ts index e46dafe7..7110ff0b 100644 --- a/services/api/test/unit/certificate-verify-url.test.ts +++ b/services/api/test/unit/certificate-verify-url.test.ts @@ -3,7 +3,7 @@ import PDFDocument from "pdfkit" import { FakeStorage } from "@civfix/shared/fakes" import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" import { makeCertificateService } from "../../src/services/certificate-service.js" -import type { VolunteerHoursEntryView } from "../../src/services/volunteer-hours-service.js" +import type { VolunteerHoursEntryView } from "../../src/services/volunteer-hours-repository.js" const qrPayloads = vi.hoisted(() => [] as string[]) diff --git a/services/api/test/unit/directory-facet-cache.test.ts b/services/api/test/unit/directory-facet-cache.test.ts index cbd000c8..7471a50a 100644 --- a/services/api/test/unit/directory-facet-cache.test.ts +++ b/services/api/test/unit/directory-facet-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { makeFakeSql } from "../helpers/fake-sql.js" import { makeDrizzleJurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.drizzle.js" import { makeDrizzleDiscoveryRepository } from "../../src/services/admin/discovery-repository.drizzle.js" -import type { ListDirectoryArgs } from "../../src/services/admin/jurisdiction-contacts-types.js" +import type { ListDirectoryArgs } from "../../src/services/admin/jurisdiction-contacts-repository.js" import type { Sql } from "../../src/db/client.js" const DEFAULT_VIEW: ListDirectoryArgs = { diff --git a/services/api/test/unit/email-format.test.ts b/services/api/test/unit/email-format.test.ts index 480b8cca..df8d7c76 100644 --- a/services/api/test/unit/email-format.test.ts +++ b/services/api/test/unit/email-format.test.ts @@ -15,7 +15,7 @@ import { import { paragraph, kvTable, linkList } from "../../src/adapters/email-blocks.js" import { renderOtp, renderTemplate } from "../../src/adapters/mailer.oci.js" import { buildDataExportEmail } from "../../src/services/data-export-service.js" -import type { AdminReportRecord } from "../../src/services/admin/admin-report-types.js" +import type { AdminReportRecord } from "../../src/services/admin/admin-report-repository.js" function reportRecord(overrides: Partial = {}): AdminReportRecord { return { diff --git a/services/api/test/unit/guest-rsvp-refusal.test.ts b/services/api/test/unit/guest-rsvp-refusal.test.ts index 2de7cde9..dd39890d 100644 --- a/services/api/test/unit/guest-rsvp-refusal.test.ts +++ b/services/api/test/unit/guest-rsvp-refusal.test.ts @@ -13,8 +13,8 @@ import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" import { makeGuestRsvpService, type GuestRegistrationBridge, - type UpsertGuestArgs, } from "../../src/services/guest-rsvp-service.js" +import type { UpsertGuestArgs } from "../../src/services/guest-rsvp-repository.js" const EVENT_ID = "11111111-1111-1111-1111-111111111111" const CODE = "424242" diff --git a/services/api/test/unit/media-authorization.test.ts b/services/api/test/unit/media-authorization.test.ts index 81609157..b1912bd5 100644 --- a/services/api/test/unit/media-authorization.test.ts +++ b/services/api/test/unit/media-authorization.test.ts @@ -7,9 +7,9 @@ import { MEDIA_GET_URL_TTL_SEC, quotaSubjects, type MediaIntakeService, - type MediaAssetView, type MediaOwner, } from "../../src/services/media-intake-service.js" +import type { MediaAssetView } from "../../src/services/media-repository.js" import { makeUnboundOnlyMediaViewAuthorizer, UNBOUND_GRACE_MS, diff --git a/services/api/test/unit/media-claim-uploader.test.ts b/services/api/test/unit/media-claim-uploader.test.ts index eda83476..b138637a 100644 --- a/services/api/test/unit/media-claim-uploader.test.ts +++ b/services/api/test/unit/media-claim-uploader.test.ts @@ -4,15 +4,14 @@ import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" import type { ReportDTO } from "@civfix/shared" import type { Queryable, Sql } from "../../src/db/client.js" import { makeDrizzleAnonReportRepository } from "../../src/services/anon-repository.drizzle.js" -import type { CreateAnonReportTxArgs } from "../../src/services/anon-service.js" +import type { CreateAnonReportTxArgs } from "../../src/services/anon-repository.js" +import type { NewMediaAsset } from "../../src/services/media-repository.js" +import type { CreateReportTxArgs } from "../../src/services/report-repository.js" import { attachChatMedia } from "../../src/services/chat-attachments-repository.drizzle.js" import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" import { claimableAsReportMedia } from "../../src/services/media-bindings.js" import { avatarClaimQuery } from "../../src/services/media-claim-repository.drizzle.js" -import { - makeMediaIntakeService, - type NewMediaAsset, -} from "../../src/services/media-intake-service.js" +import { makeMediaIntakeService } from "../../src/services/media-intake-service.js" import { UNSESSIONED_UPLOADER, anonUploader, @@ -20,7 +19,6 @@ import { } from "../../src/services/media-uploader.js" import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" -import type { CreateReportTxArgs } from "../../src/services/report-types.js" import { makeFakeSql, type FakeSqlControl, type RecordedStatement } from "../helpers/fake-sql.js" import { InMemoryMediaRepository } from "../helpers/media.js" diff --git a/services/api/test/unit/notification-service.test.ts b/services/api/test/unit/notification-service.test.ts index 797801a4..c9829dfc 100644 --- a/services/api/test/unit/notification-service.test.ts +++ b/services/api/test/unit/notification-service.test.ts @@ -10,8 +10,8 @@ import { FEED_HIDDEN_NOTIFICATION_TYPES, NOTIFICATION_DEDUPE_WINDOW_MS, type NotificationService, - type NotificationPrefsRecord, } from "../../src/services/notification-service.js" +import type { NotificationPrefsRecord } from "../../src/services/notification-repository.js" import { MAX_ACTIVE_PUSH_TOKENS_PER_USER } from "../../src/services/notification-repository.drizzle.js" import { InMemoryNotificationRepository, diff --git a/services/api/test/unit/outreach-pipeline.test.ts b/services/api/test/unit/outreach-pipeline.test.ts index 1db2bd68..9243c6cc 100644 --- a/services/api/test/unit/outreach-pipeline.test.ts +++ b/services/api/test/unit/outreach-pipeline.test.ts @@ -9,8 +9,8 @@ import { digestSubject, digestBody, type OutreachService, - type OutreachRepository, } from "../../src/services/admin/outreach-service.js" +import type { OutreachRepository } from "../../src/services/admin/outreach-repository.js" const NOW = new Date("2026-06-06T00:00:00.000Z") const THROTTLE_DAYS = 7 diff --git a/services/api/test/unit/packet-media-links.test.ts b/services/api/test/unit/packet-media-links.test.ts index f5ff61e1..45e69b6d 100644 --- a/services/api/test/unit/packet-media-links.test.ts +++ b/services/api/test/unit/packet-media-links.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import type { AdminReportStatus, ReportVisibility } from "@civfix/shared" import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" -import type { AdminReportMediaRecord } from "../../src/services/admin/admin-report-types.js" +import type { AdminReportMediaRecord } from "../../src/services/admin/admin-report-repository.js" import { makeAdminReportService } from "../../src/services/admin/admin-report-service.js" import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" diff --git a/services/api/test/unit/report-chat-notifications.test.ts b/services/api/test/unit/report-chat-notifications.test.ts index 178d553a..dff252b5 100644 --- a/services/api/test/unit/report-chat-notifications.test.ts +++ b/services/api/test/unit/report-chat-notifications.test.ts @@ -14,7 +14,7 @@ import { InMemoryNotificationRepository, flushNotificationDispatch, } from "../helpers/notifications.js" -import type { NotificationPrefsRecord } from "../../src/services/notification-service.js" +import type { NotificationPrefsRecord } from "../../src/services/notification-repository.js" import { makeNotificationService, PUSH_FANOUT_BATCH_SIZE, diff --git a/services/api/test/unit/report-owner-resolve.test.ts b/services/api/test/unit/report-owner-resolve.test.ts index 4f97617c..ee181374 100644 --- a/services/api/test/unit/report-owner-resolve.test.ts +++ b/services/api/test/unit/report-owner-resolve.test.ts @@ -4,7 +4,7 @@ import { makeFakeSql } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" import { makeReportService } from "../../src/services/report-service.js" -import type { ReportRepository } from "../../src/services/report-types.js" +import type { ReportRepository } from "../../src/services/report-repository.js" import { InMemoryReportRepository } from "../helpers/reports.js" const REPORT_ID = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/report-repository-security.test.ts b/services/api/test/unit/report-repository-security.test.ts index d06ecdea..13b73d43 100644 --- a/services/api/test/unit/report-repository-security.test.ts +++ b/services/api/test/unit/report-repository-security.test.ts @@ -5,7 +5,7 @@ import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" import type { Queryable, Sql } from "../../src/db/client.js" import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" -import type { CreateReportTxArgs } from "../../src/services/report-types.js" +import type { CreateReportTxArgs } from "../../src/services/report-repository.js" const UNAVAILABLE = "One or more media uploads are unavailable." diff --git a/services/api/test/unit/report-service.test.ts b/services/api/test/unit/report-service.test.ts index c3be8de8..6b62a950 100644 --- a/services/api/test/unit/report-service.test.ts +++ b/services/api/test/unit/report-service.test.ts @@ -10,9 +10,9 @@ import { reportH3Cell, CLUSTER_ZOOM_THRESHOLD, REPORT_H3_RESOLUTION, - type ReportMapPoint, type ReportService, } from "../../src/services/report-service.js" +import type { ReportMapPoint } from "../../src/services/report-repository.js" import { InMemoryReportRepository } from "../helpers/reports.js" const VALID_UUID = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/social-routes.test.ts b/services/api/test/unit/social-routes.test.ts index 1dd4b5e0..3723225e 100644 --- a/services/api/test/unit/social-routes.test.ts +++ b/services/api/test/unit/social-routes.test.ts @@ -9,7 +9,8 @@ import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemorySocialRepository, makeCleanupRecord } from "../helpers/social.js" import type { SocialServiceOverrides } from "../../src/routes/social.routes.js" -import type { SocialNotifier, PersonView } from "../../src/services/social-service.js" +import type { SocialNotifier } from "../../src/services/social-service.js" +import type { PersonView } from "../../src/services/social-repository.js" class SpyNotifier implements SocialNotifier { readonly calls: Array<{ followeeId: string; follower: PersonView }> = [] diff --git a/services/api/test/unit/social-service.test.ts b/services/api/test/unit/social-service.test.ts index c92dbb9a..dcd5f33c 100644 --- a/services/api/test/unit/social-service.test.ts +++ b/services/api/test/unit/social-service.test.ts @@ -8,9 +8,8 @@ import { PROFILE_PAST_EVENTS_LIMIT, type SocialNotifier, type SocialService, - type SocialRepository, - type PersonView, } from "../../src/services/social-service.js" +import type { PersonView, SocialRepository } from "../../src/services/social-repository.js" import { toPersonView, SUGGEST_CANDIDATE_POOL, From 3bef075d231ca8b4655edecbbfbb4c19ba6a0c57 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 17:05:03 +0000 Subject: [PATCH 15/20] repository contracts move out of the pg implementations --- services/api/src/adapters/chat-service.ws.ts | 2 +- services/api/src/adapters/geocoder.tiger.ts | 6 +- services/api/src/auth/pg-stores.ts | 18 +- services/api/src/di.ts | 15 +- services/api/src/routes/admin/inbox.routes.ts | 6 +- services/api/src/routes/admin/pages.routes.ts | 10 +- .../src/routes/admin/report-chat.routes.ts | 12 +- .../api/src/routes/chat-gateway-wiring.ts | 24 +- services/api/src/routes/chat-groups.routes.ts | 18 +- services/api/src/routes/chat-powers-wiring.ts | 22 +- services/api/src/routes/chat-route-helpers.ts | 2 +- services/api/src/routes/chat.routes.ts | 18 +- services/api/src/routes/cleanups.routes.ts | 6 +- .../api/src/routes/conversations.routes.ts | 24 +- services/api/src/routes/dm.routes.ts | 10 +- services/api/src/routes/messages.routes.ts | 18 +- services/api/src/routes/report-chat.routes.ts | 12 +- services/api/src/routes/users.routes.ts | 2 +- .../admin-report-chat-repository.drizzle.ts | 18 +- .../admin-report-chat-repository.memory.ts | 2 +- .../admin/admin-report-chat-repository.ts | 13 + .../admin/admin-report-chat-service.ts | 2 +- .../src/services/admin/inbound-processor.ts | 6 +- .../admin/inbound-repository.drizzle.ts | 24 +- .../admin/inbound-repository.memory.ts | 7 +- .../src/services/admin/inbound-repository.ts | 30 +++ .../admin/system-health-repository.drizzle.ts | 12 +- .../admin/system-health-repository.ts | 11 + .../affiliation-repository.drizzle.ts | 16 +- .../src/services/affiliation-repository.ts | 15 ++ .../src/services/anon-repository.drizzle.ts | 11 +- services/api/src/services/anon-repository.ts | 9 + .../src/services/blocks-repository.drizzle.ts | 31 +-- .../api/src/services/blocks-repository.ts | 25 ++ .../api/src/services/chat-edit-service.ts | 4 +- .../services/chat-group-repository.drizzle.ts | 83 +------ .../api/src/services/chat-group-repository.ts | 74 ++++++ .../api/src/services/chat-group-service.ts | 2 +- .../services/chat-poll-repository.drizzle.ts | 36 +-- .../api/src/services/chat-poll-repository.ts | 34 +++ .../api/src/services/chat-poll-service.ts | 4 +- .../api/src/services/chat-reaction-service.ts | 4 +- .../src/services/chat-repository.drizzle.ts | 134 +---------- services/api/src/services/chat-repository.ts | 131 +++++++++++ .../cleanup-map-repository.drizzle.ts | 9 +- .../src/services/cleanup-map-repository.ts | 9 + .../src/services/content-report-subject.ts | 6 +- .../conversation-hides-repository.drizzle.ts | 15 +- .../conversation-hides-repository.memory.ts | 2 +- .../services/conversation-hides-repository.ts | 15 ++ .../conversation-mutes-repository.drizzle.ts | 26 +- .../services/conversation-mutes-repository.ts | 26 ++ .../data-export-repository.drizzle.ts | 222 ++---------------- .../src/services/data-export-repository.ts | 198 ++++++++++++++++ .../api/src/services/data-export-service.ts | 18 +- services/api/src/services/dm-peer.ts | 2 +- .../api/src/services/dm-repository.drizzle.ts | 98 +------- .../api/src/services/dm-repository.memory.ts | 8 +- services/api/src/services/dm-repository.ts | 94 ++++++++ services/api/src/services/dm-service.ts | 4 +- .../services/erasure-repository.drizzle.ts | 18 +- .../api/src/services/erasure-repository.ts | 17 ++ .../geocode-cache-repository.drizzle.ts | 16 +- .../src/services/geocode-cache-repository.ts | 15 ++ .../host/admin-pages-repository.drizzle.ts | 49 +--- .../services/host/admin-pages-repository.ts | 44 ++++ .../host/analytics-repository.drizzle.ts | 154 +----------- .../src/services/host/analytics-repository.ts | 146 ++++++++++++ .../src/services/host/analytics-service.ts | 4 +- ...nnouncement-identity-repository.drizzle.ts | 6 +- .../host/announcement-identity-repository.ts | 6 + .../src/services/host/announcement-service.ts | 2 +- services/api/src/services/host/authz.ts | 7 +- .../broadcast-audience-repository.drizzle.ts | 5 +- .../host/broadcast-audience-repository.ts | 5 + .../api/src/services/host/comms-wiring.ts | 6 +- .../event-analytics-repository.drizzle.ts | 34 +-- .../host/event-analytics-repository.ts | 30 +++ .../services/host/event-analytics-service.ts | 9 +- .../host/export-repository.drizzle.ts | 121 +--------- .../src/services/host/export-repository.ts | 114 +++++++++ .../api/src/services/host/export-service.ts | 2 +- .../services/host/host-analytics-shaping.ts | 4 +- .../src/services/host/host-export-builders.ts | 6 +- .../host/host-portfolio-repository.drizzle.ts | 52 +--- .../host/host-portfolio-repository.ts | 52 ++++ .../services/host/host-portfolio-service.ts | 3 +- .../host/host-standing-repository.drizzle.ts | 18 +- .../services/host/host-standing-repository.ts | 19 ++ .../src/services/host/host-team-service.ts | 2 +- .../api/src/services/host/insights-service.ts | 2 +- .../host/metrics-repository.drizzle.ts | 37 +-- .../src/services/host/metrics-repository.ts | 35 +++ .../api/src/services/host/metrics-service.ts | 2 +- ...anization-membership-repository.drizzle.ts | 5 +- .../organization-membership-repository.ts | 3 + ...gistration-retention-repository.drizzle.ts | 8 +- .../host/registration-retention-repository.ts | 6 + .../jurisdiction-repository.drizzle.ts | 14 +- .../src/services/jurisdiction-repository.ts | 13 + .../api/src/services/jurisdiction-service.ts | 6 +- .../media-authorization-repository.drizzle.ts | 68 +----- .../media-authorization-repository.ts | 57 +++++ .../api/src/services/media-authorization.ts | 6 +- .../message-attachments-repository.drizzle.ts | 11 +- .../message-attachments-repository.ts | 11 + .../message-mentions-repository.drizzle.ts | 8 +- .../services/message-mentions-repository.ts | 9 + .../message-reactions-repository.drizzle.ts | 9 +- .../services/message-reactions-repository.ts | 9 + .../src/services/post-repository.drizzle.ts | 140 ++--------- services/api/src/services/post-repository.ts | 125 ++++++++++ services/api/src/services/post-service.ts | 17 +- .../report-chat-repository.drizzle.ts | 31 +-- .../src/services/report-chat-repository.ts | 28 +++ .../src/services/report-chat-send-wiring.ts | 2 +- .../services/report-city-forward-wiring.ts | 6 +- .../api/src/services/report-city-forward.ts | 2 +- ...report-forward-audit-repository.drizzle.ts | 6 +- .../report-forward-audit-repository.ts | 4 + .../room-messages-repository.drizzle.ts | 18 +- .../src/services/room-messages-repository.ts | 19 ++ .../user-search-repository.drizzle.ts | 6 +- .../src/services/user-search-repository.ts | 6 + .../src/services/users-repository.drizzle.ts | 13 +- services/api/src/services/users-repository.ts | 12 + .../src/services/volunteer-hours-service.ts | 2 +- services/api/test/helpers/chat.ts | 14 +- .../test/integration/feed-ranked-pg.test.ts | 3 +- .../host-export-orphans-pg.test.ts | 6 +- .../host-metrics-export-pg.test.ts | 4 +- .../host-metrics-rollup-pg.test.ts | 6 +- .../integration/inbound-repository.test.ts | 10 +- .../media-attach-claim-security-pg.test.ts | 6 +- .../integration/posts-unrepost-pg.test.ts | 6 +- .../unit/admin-host-platform-routes.test.ts | 2 +- .../api/test/unit/admin-report-chat.test.ts | 5 +- .../test/unit/chat-group-kick-atomic.test.ts | 6 +- .../unit/chat-group-service-gates.test.ts | 2 +- .../unit/city-forward-logger-wiring.test.ts | 2 +- .../test/unit/city-forward-logging.test.ts | 2 +- .../api/test/unit/city-forward-wiring.test.ts | 2 +- .../unit/conversation-hides-routes.test.ts | 4 +- .../api/test/unit/conversation-hides.test.ts | 2 +- .../unit/conversation-mutes-routes.test.ts | 2 +- .../api/test/unit/feed-candidates-sql.test.ts | 2 +- .../api/test/unit/feed-counts-service.test.ts | 2 +- .../api/test/unit/feed-ranked-service.test.ts | 2 +- .../unit/feed-repost-count-fanout.test.ts | 2 +- services/api/test/unit/host-analytics.test.ts | 4 +- .../api/test/unit/host-event-metrics.test.ts | 5 +- .../api/test/unit/host-export-csv.test.ts | 2 +- .../test/unit/host-export-lifecycle.test.ts | 10 +- services/api/test/unit/host-insights.test.ts | 2 +- .../host-metrics-rollup-correctness.test.ts | 9 +- .../host/announcement-send-failure.test.ts | 2 +- .../unit/host/announcement-service.test.ts | 2 +- .../unit/host/event-analytics-service.test.ts | 6 +- .../host/host-portfolio-correctness.test.ts | 2 +- .../unit/host/host-portfolio-service.test.ts | 4 +- .../host/host-team-service-security.test.ts | 2 +- .../test/unit/host/host-team-service.test.ts | 2 +- .../test/unit/host/org-team-routes.test.ts | 2 +- .../unit/media-attach-claim-security.test.ts | 6 +- .../unit/post-repository-writes-sql.test.ts | 6 +- .../test/unit/post-service-security.test.ts | 2 +- services/api/test/unit/post-service.test.ts | 12 +- .../unit/post-unrepost-unreadable.test.ts | 2 +- services/api/test/unit/posts-routes.test.ts | 2 +- .../test/unit/report-chat-bell-wiring.test.ts | 2 +- .../test/unit/report-chat-membership.test.ts | 2 +- .../unit/report-chat-notifications.test.ts | 2 +- .../test/unit/report-forward-audit.test.ts | 2 +- .../api/test/unit/thread-read-routes.test.ts | 6 +- .../test/unit/web-base-url-fallback.test.ts | 2 +- 175 files changed, 1856 insertions(+), 1773 deletions(-) create mode 100644 services/api/src/services/admin/admin-report-chat-repository.ts create mode 100644 services/api/src/services/admin/inbound-repository.ts create mode 100644 services/api/src/services/admin/system-health-repository.ts create mode 100644 services/api/src/services/affiliation-repository.ts create mode 100644 services/api/src/services/blocks-repository.ts create mode 100644 services/api/src/services/chat-group-repository.ts create mode 100644 services/api/src/services/chat-poll-repository.ts create mode 100644 services/api/src/services/chat-repository.ts create mode 100644 services/api/src/services/cleanup-map-repository.ts create mode 100644 services/api/src/services/conversation-hides-repository.ts create mode 100644 services/api/src/services/conversation-mutes-repository.ts create mode 100644 services/api/src/services/data-export-repository.ts create mode 100644 services/api/src/services/dm-repository.ts create mode 100644 services/api/src/services/erasure-repository.ts create mode 100644 services/api/src/services/geocode-cache-repository.ts create mode 100644 services/api/src/services/host/admin-pages-repository.ts create mode 100644 services/api/src/services/host/analytics-repository.ts create mode 100644 services/api/src/services/host/announcement-identity-repository.ts create mode 100644 services/api/src/services/host/broadcast-audience-repository.ts create mode 100644 services/api/src/services/host/event-analytics-repository.ts create mode 100644 services/api/src/services/host/export-repository.ts create mode 100644 services/api/src/services/host/host-portfolio-repository.ts create mode 100644 services/api/src/services/host/host-standing-repository.ts create mode 100644 services/api/src/services/host/metrics-repository.ts create mode 100644 services/api/src/services/host/organization-membership-repository.ts create mode 100644 services/api/src/services/host/registration-retention-repository.ts create mode 100644 services/api/src/services/jurisdiction-repository.ts create mode 100644 services/api/src/services/media-authorization-repository.ts create mode 100644 services/api/src/services/message-attachments-repository.ts create mode 100644 services/api/src/services/message-mentions-repository.ts create mode 100644 services/api/src/services/message-reactions-repository.ts create mode 100644 services/api/src/services/post-repository.ts create mode 100644 services/api/src/services/report-chat-repository.ts create mode 100644 services/api/src/services/report-forward-audit-repository.ts create mode 100644 services/api/src/services/room-messages-repository.ts create mode 100644 services/api/src/services/user-search-repository.ts create mode 100644 services/api/src/services/users-repository.ts diff --git a/services/api/src/adapters/chat-service.ws.ts b/services/api/src/adapters/chat-service.ws.ts index 9d7db67a..1246b551 100644 --- a/services/api/src/adapters/chat-service.ws.ts +++ b/services/api/src/adapters/chat-service.ws.ts @@ -8,7 +8,7 @@ import type { ChatMessageDTO, WsServerMessage } from "@civfix/shared" import { randomUUID } from "node:crypto" import { chatChannel, type ChatPubSub } from "./chat-pubsub.js" import { RefCountedSubscriptions } from "./ref-counted-subscriptions.js" -import type { ChatRepository } from "../services/chat-repository.drizzle.js" +import type { ChatRepository } from "../services/chat-repository.js" export interface WsChatServiceDeps { repo: ChatRepository diff --git a/services/api/src/adapters/geocoder.tiger.ts b/services/api/src/adapters/geocoder.tiger.ts index 2f21b982..93859060 100644 --- a/services/api/src/adapters/geocoder.tiger.ts +++ b/services/api/src/adapters/geocoder.tiger.ts @@ -14,10 +14,8 @@ import { AppError } from "@civfix/shared" import type { Geocoder } from "@civfix/shared/interfaces" import type { Sql } from "../db/client.js" -import { - makeDrizzleJurisdictionRepository, - type JurisdictionRepository, -} from "../services/jurisdiction-repository.drizzle.js" +import { makeDrizzleJurisdictionRepository } from "../services/jurisdiction-repository.drizzle.js" +import type { JurisdictionRepository } from "../services/jurisdiction-repository.js" /** A fixed public Census code list, so it lives in-process rather than in a lookup table. */ const STATE_FIPS_TO_USPS: Readonly> = { diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index b948c3f5..3cfb5b81 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -30,16 +30,14 @@ import { } from "../services/media-claim-repository.drizzle.js" import { userUploader } from "../services/media-uploader.js" import { enqueueWaitlistPromotion } from "../services/host/waitlist-promotion.js" -import { - makeDrizzleOrganizationMembershipRepository, - type OrganizationMembershipRepository, -} from "../services/host/organization-membership-repository.drizzle.js" -import { - makeDrizzleErasureRepository, - type DbTransaction, - type ErasureRepository, - type TransferredEvent, -} from "../services/erasure-repository.drizzle.js" +import { makeDrizzleOrganizationMembershipRepository } from "../services/host/organization-membership-repository.drizzle.js" +import type { OrganizationMembershipRepository } from "../services/host/organization-membership-repository.js" +import type { + DbTransaction, + ErasureRepository, + TransferredEvent, +} from "../services/erasure-repository.js" +import { makeDrizzleErasureRepository } from "../services/erasure-repository.drizzle.js" import type { NotificationService } from "../services/notification-service.js" import { EmailTakenError, diff --git a/services/api/src/di.ts b/services/api/src/di.ts index 20506b54..5da8c8e2 100644 --- a/services/api/src/di.ts +++ b/services/api/src/di.ts @@ -55,20 +55,17 @@ import { WsChatService } from "./adapters/chat-service.ws.js" import { RedisChatPubSub } from "./adapters/chat-pubsub.js" import { RedisUserChannel } from "./adapters/user-channel.redis.js" import { makeDrizzleChatRepository } from "./services/chat-repository.drizzle.js" -import { makeDrizzleDmRepository, type DmRepository } from "./services/dm-repository.drizzle.js" +import { makeDrizzleDmRepository } from "./services/dm-repository.drizzle.js" +import type { DmRepository } from "./services/dm-repository.js" +import type { BlocksRepository } from "./services/blocks-repository.js" +import type { PostRepository } from "./services/post-repository.js" import { makeMediaPresigner, makePrivateMediaPresigner } from "./services/media-presign.js" -import { - makeDrizzleBlocksRepository, - type BlocksRepository, -} from "./services/blocks-repository.drizzle.js" +import { makeDrizzleBlocksRepository } from "./services/blocks-repository.drizzle.js" import { makeDrizzleVolunteerHoursRepository } from "./services/volunteer-hours-repository.drizzle.js" import type { VolunteerHoursRepository } from "./services/volunteer-hours-repository.js" import type { CertificateRepository } from "./services/certificate-repository.js" import { makeDrizzleCertificateRepository } from "./services/certificate-repository.drizzle.js" -import { - makeDrizzlePostRepository, - type PostRepository, -} from "./services/post-repository.drizzle.js" +import { makeDrizzlePostRepository } from "./services/post-repository.drizzle.js" import { makePostService, type PostService } from "./services/post-service.js" import { makeFeedPresence } from "./services/feed-presence.js" import { diff --git a/services/api/src/routes/admin/inbox.routes.ts b/services/api/src/routes/admin/inbox.routes.ts index bdc052ec..4533b950 100644 --- a/services/api/src/routes/admin/inbox.routes.ts +++ b/services/api/src/routes/admin/inbox.routes.ts @@ -19,10 +19,8 @@ import { requireOperator } from "../../auth/admin-guard.js" import { MEDIA_GET_URL_TTL_SEC } from "../../services/media-intake-service.js" import { PRESIGN_CONCURRENCY } from "../../services/media-presign.js" import { mapWithLimit } from "../../lib/concurrency.js" -import { - makeDrizzleInboundRepository, - type InboundRepository, -} from "../../services/admin/inbound-repository.drizzle.js" +import { makeDrizzleInboundRepository } from "../../services/admin/inbound-repository.drizzle.js" +import type { InboundRepository } from "../../services/admin/inbound-repository.js" /** Defends against a crafted mail with thousands of parts. */ const MAX_INBOX_ATTACHMENTS = 50 diff --git a/services/api/src/routes/admin/pages.routes.ts b/services/api/src/routes/admin/pages.routes.ts index 489260e3..9fa0c7de 100644 --- a/services/api/src/routes/admin/pages.routes.ts +++ b/services/api/src/routes/admin/pages.routes.ts @@ -16,11 +16,11 @@ import { parse, paramsOverBody } from "../_validate.js" import type { WriteAuditInput } from "../../services/admin/audit.js" import { insertAuditRow } from "../../services/admin/audit-repository.drizzle.js" import { paginateKeyset, parseKeysetCursor } from "../../db/cursor-helpers.js" -import { - makeDrizzleAdminEventPageRepository, - type AdminEventPageRepository, - type AdminEventPageRow, -} from "../../services/host/admin-pages-repository.drizzle.js" +import { makeDrizzleAdminEventPageRepository } from "../../services/host/admin-pages-repository.drizzle.js" +import type { + AdminEventPageRepository, + AdminEventPageRow, +} from "../../services/host/admin-pages-repository.js" import { makeDrizzleHostRegistrationRepository } from "../../services/host/registration-repository.drizzle.js" import { toEventPageDTO } from "../../services/host/registration-dto.js" import { makeEventMediaPresigner } from "../../services/host/event-media.js" diff --git a/services/api/src/routes/admin/report-chat.routes.ts b/services/api/src/routes/admin/report-chat.routes.ts index c1f8e30d..f323e319 100644 --- a/services/api/src/routes/admin/report-chat.routes.ts +++ b/services/api/src/routes/admin/report-chat.routes.ts @@ -22,14 +22,10 @@ import { makeAdminReportChatService, type AdminReportChatService, } from "../../services/admin/admin-report-chat-service.js" -import { - makeDrizzleAdminReportChatRepository, - type AdminReportChatRepository, -} from "../../services/admin/admin-report-chat-repository.drizzle.js" -import { - makeDrizzleChatRepository, - type ChatRepository, -} from "../../services/chat-repository.drizzle.js" +import { makeDrizzleAdminReportChatRepository } from "../../services/admin/admin-report-chat-repository.drizzle.js" +import type { AdminReportChatRepository } from "../../services/admin/admin-report-chat-repository.js" +import type { ChatRepository } from "../../services/chat-repository.js" +import { makeDrizzleChatRepository } from "../../services/chat-repository.drizzle.js" import type { ChatHistorySource } from "../chat-route-helpers.js" import { makePrivateMediaPresigner } from "../../services/media-presign.js" import { makeContainerReportChatSendDeps } from "../../services/report-chat-send-wiring.js" diff --git a/services/api/src/routes/chat-gateway-wiring.ts b/services/api/src/routes/chat-gateway-wiring.ts index 7a2869e7..3c38d69d 100644 --- a/services/api/src/routes/chat-gateway-wiring.ts +++ b/services/api/src/routes/chat-gateway-wiring.ts @@ -32,15 +32,17 @@ import { resolveMentionTargets } from "../services/mention-targets-repository.dr import { makeChatMentionResolver } from "../services/chat-mention-resolver.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" -import { - makeReportChatRepository, - type ReportChatRepository, -} from "../services/report-chat-repository.drizzle.js" +import { makeReportChatRepository } from "../services/report-chat-repository.drizzle.js" +import type { ReportChatRepository } from "../services/report-chat-repository.js" +import type { ChatGroupRepository } from "../services/chat-group-repository.js" +import type { ChatRepository } from "../services/chat-repository.js" +import type { ConversationMutesRepository } from "../services/conversation-mutes-repository.js" +import type { DmRepository } from "../services/dm-repository.js" +import type { BlocksRepository } from "../services/blocks-repository.js" import { canPostToGroup, GROUP_MEMBER_SCAN_CAP, makeChatGroupRepository, - type ChatGroupRepository, } from "../services/chat-group-repository.drizzle.js" import { WS_ROUTE, WS_SEND_LIMITS, type GatewayGroupChat } from "../ws/types.js" import { makeCityForwardThrottle } from "../services/report-city-forward.js" @@ -48,10 +50,7 @@ import { makeContainerReportCityForward } from "../services/report-city-forward- import { isReportVisibleTo } from "../services/report-visibility.js" import { makeTokenBucketLimiter, type RateLimiter } from "../ws/report-rate-limit.js" import { THREAD_SIGNAL_MEMBER_CAP } from "../services/cleanup-service.js" -import { - makeDrizzleChatRepository, - type ChatRepository, -} from "../services/chat-repository.drizzle.js" +import { makeDrizzleChatRepository } from "../services/chat-repository.drizzle.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" import { recordChatMentions, @@ -67,7 +66,6 @@ import { bindMutedUserIdsFor, makeConversationMutesRepository, makeFailOpenMuteCheck, - type ConversationMutesRepository, } from "../services/conversation-mutes-repository.drizzle.js" import { makeReportChatNotifier } from "../services/report-chat-notifier.js" import { makeGroupChatNotifier } from "../services/group-chat-notifier.js" @@ -81,12 +79,8 @@ import { clearConversationBellFor, type ConversationBellKind, } from "../services/conversation-bell.js" -import type { DmRepository } from "../services/dm-repository.drizzle.js" import { makeDmPeerOf } from "../services/dm-peer.js" -import { - bindBlockedIdsAmong, - type BlocksRepository, -} from "../services/blocks-repository.drizzle.js" +import { bindBlockedIdsAmong } from "../services/blocks-repository.drizzle.js" import { InMemoryChatPresence, RedisChatPresence, diff --git a/services/api/src/routes/chat-groups.routes.ts b/services/api/src/routes/chat-groups.routes.ts index 6fd0f80b..0cc4a825 100644 --- a/services/api/src/routes/chat-groups.routes.ts +++ b/services/api/src/routes/chat-groups.routes.ts @@ -27,20 +27,14 @@ import { deleteMessageWithPowers, } from "./chat-route-helpers.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" -import { - makeChatGroupRepository, - type ChatGroupRepository, -} from "../services/chat-group-repository.drizzle.js" +import { makeChatGroupRepository } from "../services/chat-group-repository.drizzle.js" +import type { ChatGroupRepository } from "../services/chat-group-repository.js" +import type { ChatRepository } from "../services/chat-repository.js" +import type { ConversationMutesRepository } from "../services/conversation-mutes-repository.js" import { makeChatGroupService, type ChatGroupService } from "../services/chat-group-service.js" import { nudgeThreads } from "../services/threads-nudge.js" -import { - makeDrizzleChatRepository, - type ChatRepository, -} from "../services/chat-repository.drizzle.js" -import { - makeConversationMutesRepository, - type ConversationMutesRepository, -} from "../services/conversation-mutes-repository.drizzle.js" +import { makeDrizzleChatRepository } from "../services/chat-repository.drizzle.js" +import { makeConversationMutesRepository } from "../services/conversation-mutes-repository.drizzle.js" import { wireChatPowers } from "./chat-powers-wiring.js" export const CreateChatGroupBodySchema = trimTextFields( diff --git a/services/api/src/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index 2b9ced0b..760203f7 100644 --- a/services/api/src/routes/chat-powers-wiring.ts +++ b/services/api/src/routes/chat-powers-wiring.ts @@ -16,21 +16,15 @@ import type { Env } from "../env.js" import { isAdminEmail } from "../auth/admin-allowlist.js" import { makeChatPowersResolver, type ResolveChatPowers } from "../services/chat-room-roles.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" -import { - makeReportChatRepository, - type ReportChatRepository, -} from "../services/report-chat-repository.drizzle.js" -import { - makeChatGroupRepository, - type ChatGroupRepository, -} from "../services/chat-group-repository.drizzle.js" -import type { DmRepository } from "../services/dm-repository.drizzle.js" +import { makeReportChatRepository } from "../services/report-chat-repository.drizzle.js" +import type { ReportChatRepository } from "../services/report-chat-repository.js" +import type { ChatGroupRepository } from "../services/chat-group-repository.js" +import type { DmRepository } from "../services/dm-repository.js" +import type { BlocksRepository } from "../services/blocks-repository.js" +import type { GlobalRole } from "../services/users-repository.js" +import { makeChatGroupRepository } from "../services/chat-group-repository.drizzle.js" import { makeDmPeerOf } from "../services/dm-peer.js" -import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" -import { - makeDrizzleUsersRepository, - type GlobalRole, -} from "../services/users-repository.drizzle.js" +import { makeDrizzleUsersRepository } from "../services/users-repository.drizzle.js" /** * A thread the caller is not in resolves to no peer and answers false: isDmParticipant already gates diff --git a/services/api/src/routes/chat-route-helpers.ts b/services/api/src/routes/chat-route-helpers.ts index 9a61dd7f..6bbadbcf 100644 --- a/services/api/src/routes/chat-route-helpers.ts +++ b/services/api/src/routes/chat-route-helpers.ts @@ -1,7 +1,7 @@ import { AppError, type ChatHistoryResponse, type ChatMessageDTO } from "@civfix/shared" import type { ChatHistoryPage, ChatService } from "@civfix/shared/interfaces" import { broadcastMessageUpdate, roomKeyFor } from "../ws/gateway.js" -import type { ChatMessageMeta, SoftDeleteOpts } from "../services/chat-repository.drizzle.js" +import type { ChatMessageMeta, SoftDeleteOpts } from "../services/chat-repository.js" import type { ResolveChatPowers } from "../services/chat-room-roles.js" import { neutralizeChatViewerFields } from "../services/chat-viewer-fields.js" import { clampPageLimit } from "../lib/page-limit.js" diff --git a/services/api/src/routes/chat.routes.ts b/services/api/src/routes/chat.routes.ts index 6bd198f4..112090cc 100644 --- a/services/api/src/routes/chat.routes.ts +++ b/services/api/src/routes/chat.routes.ts @@ -18,9 +18,13 @@ import { wireChatGateway } from "./chat-gateway-wiring.js" import { deleteMessageWithPowers, DELETE_MESSAGE_FORBIDDEN } from "./chat-route-helpers.js" import { neutralizeChatViewerFields } from "../services/chat-viewer-fields.js" import { makeChatReactionService } from "../services/chat-reaction-service.js" -import type { ChatRepository } from "../services/chat-repository.drizzle.js" -import type { ReportChatRepository } from "../services/report-chat-repository.drizzle.js" -import type { ChatPollRepository } from "../services/chat-poll-repository.drizzle.js" +import type { ChatRepository } from "../services/chat-repository.js" +import type { ReportChatRepository } from "../services/report-chat-repository.js" +import type { ChatPollRepository } from "../services/chat-poll-repository.js" +import type { DmRepository } from "../services/dm-repository.js" +import type { BlocksRepository } from "../services/blocks-repository.js" +import type { ChatGroupRepository } from "../services/chat-group-repository.js" +import type { ConversationMutesRepository } from "../services/conversation-mutes-repository.js" import { roomKeyFor, type GatewayChatMentions, @@ -32,9 +36,6 @@ import { makeDrizzleReportThreadsSource, makeDrizzleThreadsRepository, } from "../services/threads-repository.drizzle.js" -import type { DmRepository } from "../services/dm-repository.drizzle.js" -import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" -import type { ChatGroupRepository } from "../services/chat-group-repository.drizzle.js" import type { ChatPresence } from "../adapters/chat-presence.js" import { makeThreadsService, @@ -49,10 +50,7 @@ import type { ThreadsRepository } from "../services/threads-repository.js" import type { NotificationService } from "../services/notification-service.js" import type { ResolveChatPowers } from "../services/chat-room-roles.js" import { wireChatPowers } from "./chat-powers-wiring.js" -import { - makeConversationMutesRepository, - type ConversationMutesRepository, -} from "../services/conversation-mutes-repository.drizzle.js" +import { makeConversationMutesRepository } from "../services/conversation-mutes-repository.drizzle.js" export interface ChatGatewayOverrides { isMember: IsMemberFn diff --git a/services/api/src/routes/cleanups.routes.ts b/services/api/src/routes/cleanups.routes.ts index 9c5c5ac9..739af830 100644 --- a/services/api/src/routes/cleanups.routes.ts +++ b/services/api/src/routes/cleanups.routes.ts @@ -45,10 +45,8 @@ import { webBaseUrlOf } from "../lib/base-url.js" import { makeInsightsGeneration } from "../services/host/host-analytics-cache.js" import { makeEventMediaPresigner } from "../services/host/event-media.js" import { SCHEDULE_MAX_AHEAD_MS, SCHEDULE_MAX_BACKDATE_MS } from "../services/cleanup-rules.js" -import { - makeDrizzleChatRepository, - type ChatRepository, -} from "../services/chat-repository.drizzle.js" +import { makeDrizzleChatRepository } from "../services/chat-repository.drizzle.js" +import type { ChatRepository } from "../services/chat-repository.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" import { buildIcs } from "@civfix/shared/ics" import { makeCachedAddressResolver, makeGeoidResolver } from "../services/route-geo-helpers.js" diff --git a/services/api/src/routes/conversations.routes.ts b/services/api/src/routes/conversations.routes.ts index f670aa42..7c3d03c5 100644 --- a/services/api/src/routes/conversations.routes.ts +++ b/services/api/src/routes/conversations.routes.ts @@ -14,23 +14,15 @@ import { requireAuth } from "../auth/context.js" import { parse } from "./_validate.js" import { route } from "../versioning/route.js" import type { ConversationMuteRoomKind } from "../db/schema/conversation_mutes.js" -import { - makeConversationMutesRepository, - type ConversationMutesRepository, -} from "../services/conversation-mutes-repository.drizzle.js" -import { - makeConversationHidesRepository, - type ConversationHidesRepository, -} from "../services/conversation-hides-repository.drizzle.js" +import { makeConversationMutesRepository } from "../services/conversation-mutes-repository.drizzle.js" +import type { ConversationMutesRepository } from "../services/conversation-mutes-repository.js" +import type { ConversationHidesRepository } from "../services/conversation-hides-repository.js" +import type { ReportChatRepository } from "../services/report-chat-repository.js" +import type { ChatGroupRepository } from "../services/chat-group-repository.js" +import { makeConversationHidesRepository } from "../services/conversation-hides-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" -import { - makeReportChatRepository, - type ReportChatRepository, -} from "../services/report-chat-repository.drizzle.js" -import { - makeChatGroupRepository, - type ChatGroupRepository, -} from "../services/chat-group-repository.drizzle.js" +import { makeReportChatRepository } from "../services/report-chat-repository.drizzle.js" +import { makeChatGroupRepository } from "../services/chat-group-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" import type { DiscussionRepository } from "../services/discussion-repository.js" import { isReportVisibleTo } from "../services/report-visibility.js" diff --git a/services/api/src/routes/dm.routes.ts b/services/api/src/routes/dm.routes.ts index c05f5ad0..e7d5b8db 100644 --- a/services/api/src/routes/dm.routes.ts +++ b/services/api/src/routes/dm.routes.ts @@ -21,12 +21,10 @@ import { makeDmService, type DmService, type DmUserLookup } from "../services/dm import { makeChatReactionService } from "../services/chat-reaction-service.js" import { makeChatEditService } from "../services/chat-edit-service.js" import { makeDmPeerOf } from "../services/dm-peer.js" -import type { DmRepository } from "../services/dm-repository.drizzle.js" -import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" -import { - makeConversationMutesRepository, - type ConversationMutesRepository, -} from "../services/conversation-mutes-repository.drizzle.js" +import type { DmRepository } from "../services/dm-repository.js" +import type { BlocksRepository } from "../services/blocks-repository.js" +import type { ConversationMutesRepository } from "../services/conversation-mutes-repository.js" +import { makeConversationMutesRepository } from "../services/conversation-mutes-repository.drizzle.js" import { chatHistoryPayload, clampChatHistoryLimit, diff --git a/services/api/src/routes/messages.routes.ts b/services/api/src/routes/messages.routes.ts index 5e2cd1ed..aa7d4841 100644 --- a/services/api/src/routes/messages.routes.ts +++ b/services/api/src/routes/messages.routes.ts @@ -19,19 +19,17 @@ import { requireAuth } from "../auth/context.js" import { parse, trimTextFields } from "./_validate.js" import { route } from "../versioning/route.js" import { makeChatEditService, type IsRoomMemberFn } from "../services/chat-edit-service.js" -import { - makeDrizzleChatRepository, - type ChatRepository, -} from "../services/chat-repository.drizzle.js" -import { - makeReportChatRepository, - type ReportChatRepository, -} from "../services/report-chat-repository.drizzle.js" +import { makeDrizzleChatRepository } from "../services/chat-repository.drizzle.js" +import type { ChatRepository } from "../services/chat-repository.js" +import type { ReportChatRepository } from "../services/report-chat-repository.js" +import type { ChatGroupRepository } from "../services/chat-group-repository.js" +import type { DmRepository } from "../services/dm-repository.js" +import type { BlocksRepository } from "../services/blocks-repository.js" +import { makeReportChatRepository } from "../services/report-chat-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" import { canPostToGroup, makeChatGroupRepository, - type ChatGroupRepository, } from "../services/chat-group-repository.drizzle.js" import { makeChatReactionService } from "../services/chat-reaction-service.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" @@ -42,8 +40,6 @@ import { messageRoomMatches, REPORT_NOT_FOUND } from "./chat-route-helpers.js" import { neutralizeChatViewerFields } from "../services/chat-viewer-fields.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" import { chatMentionDeps, type ChatMentionSeam } from "./chat-gateway-wiring.js" -import type { DmRepository } from "../services/dm-repository.drizzle.js" -import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" import { broadcastMessageUpdate, roomKeyFor } from "../ws/gateway.js" import { wireChatPowers } from "./chat-powers-wiring.js" import { makeChatPollRepository } from "../services/chat-poll-repository.drizzle.js" diff --git a/services/api/src/routes/report-chat.routes.ts b/services/api/src/routes/report-chat.routes.ts index 18f5c234..e346e201 100644 --- a/services/api/src/routes/report-chat.routes.ts +++ b/services/api/src/routes/report-chat.routes.ts @@ -25,14 +25,10 @@ import { REPORT_NOT_FOUND, } from "./chat-route-helpers.js" import { neutralizeChatViewerFields } from "../services/chat-viewer-fields.js" -import { - makeDrizzleChatRepository, - type ChatRepository, -} from "../services/chat-repository.drizzle.js" -import { - makeReportChatRepository, - type ReportChatRepository, -} from "../services/report-chat-repository.drizzle.js" +import { makeDrizzleChatRepository } from "../services/chat-repository.drizzle.js" +import type { ChatRepository } from "../services/chat-repository.js" +import type { ReportChatRepository } from "../services/report-chat-repository.js" +import { makeReportChatRepository } from "../services/report-chat-repository.drizzle.js" import { makeDrizzleDiscussionRepository } from "../services/discussion-repository.drizzle.js" import type { DiscussionRepository } from "../services/discussion-repository.js" import { isReportVisibleTo } from "../services/report-visibility.js" diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index b6ea5da4..5c1a3a20 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -27,7 +27,7 @@ import { exposeMessage } from "../errors/exposed-message.js" import { insertAuditRow } from "../services/admin/audit-repository.drizzle.js" import { dataExportSupportEmail } from "../services/data-export-jobs.js" import { DATA_EXPORT_JOB } from "../lib/queue-names.js" -import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" +import type { BlocksRepository } from "../services/blocks-repository.js" import { dropContainerSuggestions } from "../services/social-suggestions-wiring.js" import { route } from "../versioning/route.js" import { perIdentity } from "../plugins/rate-limit.js" diff --git a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts index 0d611171..f1b8f65a 100644 --- a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts @@ -1,20 +1,10 @@ import type { RoomKind } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" import { insertAuditRow } from "./audit-repository.drizzle.js" - -export interface RemoveReportMessageInput { - reason: string | null - actorId: string | null -} - -export interface AdminReportChatRepository { - reportExists(reportId: string): Promise - removeMessage( - reportId: string, - messageId: string, - input: RemoveReportMessageInput, - ): Promise -} +import type { + AdminReportChatRepository, + RemoveReportMessageInput, +} from "./admin-report-chat-repository.js" export async function findMessageRoom( sql: Queryable, diff --git a/services/api/src/services/admin/admin-report-chat-repository.memory.ts b/services/api/src/services/admin/admin-report-chat-repository.memory.ts index 02ca2cd9..e3a65526 100644 --- a/services/api/src/services/admin/admin-report-chat-repository.memory.ts +++ b/services/api/src/services/admin/admin-report-chat-repository.memory.ts @@ -1,7 +1,7 @@ import type { AdminReportChatRepository, RemoveReportMessageInput, -} from "./admin-report-chat-repository.drizzle.js" +} from "./admin-report-chat-repository.js" export interface MemoryReportChatMessage { id: string diff --git a/services/api/src/services/admin/admin-report-chat-repository.ts b/services/api/src/services/admin/admin-report-chat-repository.ts new file mode 100644 index 00000000..677cd80c --- /dev/null +++ b/services/api/src/services/admin/admin-report-chat-repository.ts @@ -0,0 +1,13 @@ +export interface RemoveReportMessageInput { + reason: string | null + actorId: string | null +} + +export interface AdminReportChatRepository { + reportExists(reportId: string): Promise + removeMessage( + reportId: string, + messageId: string, + input: RemoveReportMessageInput, + ): Promise +} diff --git a/services/api/src/services/admin/admin-report-chat-service.ts b/services/api/src/services/admin/admin-report-chat-service.ts index 42075cb3..0b69ad2c 100644 --- a/services/api/src/services/admin/admin-report-chat-service.ts +++ b/services/api/src/services/admin/admin-report-chat-service.ts @@ -13,7 +13,7 @@ import { type ChatHistorySource, } from "../../routes/chat-route-helpers.js" import { sendReportChatMessage, type ReportChatSendDeps } from "../report-chat-send.js" -import type { AdminReportChatRepository } from "./admin-report-chat-repository.drizzle.js" +import type { AdminReportChatRepository } from "./admin-report-chat-repository.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../auth/official-account.js" /** Called only after the operator's change committed; never throws. */ diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index 2e8bb756..f29d5f3f 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -7,10 +7,8 @@ import { type MailRepository, type MailThreadRecord, } from "./mail-repository.drizzle.js" -import { - makeDrizzleInboundRepository, - type InboundRepository, -} from "./inbound-repository.drizzle.js" +import { makeDrizzleInboundRepository } from "./inbound-repository.drizzle.js" +import type { InboundRepository } from "./inbound-repository.js" import type { ReporterNotifier } from "./admin-report-service.js" import type { AdminReportRepository } from "./admin-report-repository.js" import { detectBounce, handleBounce } from "./inbound-bounce.js" diff --git a/services/api/src/services/admin/inbound-repository.drizzle.ts b/services/api/src/services/admin/inbound-repository.drizzle.ts index d9396e51..c2cfa356 100644 --- a/services/api/src/services/admin/inbound-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-repository.drizzle.ts @@ -17,29 +17,7 @@ import type { InboxListResponse, MailAttachment, } from "@civfix/shared" - -export interface InboundEmailInsert { - messageId: string - fromAddr: string | null - toAddr: string | null - recipient: string | null - subject: string | null - bodyText: string | null - bodyHtml: string | null - headers: Record - attachments: MailAttachment[] - receivedAt?: Date -} - -export interface InboundRepository { - insertIdempotent(input: InboundEmailInsert): Promise<{ id: string; inserted: boolean }> - list(query: InboxListQuery): Promise - get(id: string): Promise - setStatus(id: string, status: InboundEmailStatus, actorId: string | null): Promise - /** Counts one more failed bounce-bookkeeping run for a pending object; returns the new total. */ - recordBounceFailure(objectKey: string): Promise - clearBounceFailures(objectKey: string): Promise -} +import type { InboundEmailInsert, InboundRepository } from "./inbound-repository.js" export function localPartOf(recipient: string | null): string { if (!recipient) return "" diff --git a/services/api/src/services/admin/inbound-repository.memory.ts b/services/api/src/services/admin/inbound-repository.memory.ts index 2693c08a..871752bc 100644 --- a/services/api/src/services/admin/inbound-repository.memory.ts +++ b/services/api/src/services/admin/inbound-repository.memory.ts @@ -1,11 +1,8 @@ import { randomUUID } from "node:crypto" import { clampLimit } from "./pagination.js" import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" -import { - localPartOf, - type InboundEmailInsert, - type InboundRepository, -} from "./inbound-repository.drizzle.js" +import { localPartOf } from "./inbound-repository.drizzle.js" +import type { InboundEmailInsert, InboundRepository } from "./inbound-repository.js" import { toPreview } from "./mail-preview.js" import type { InboundEmailDTO, diff --git a/services/api/src/services/admin/inbound-repository.ts b/services/api/src/services/admin/inbound-repository.ts new file mode 100644 index 00000000..a2662e14 --- /dev/null +++ b/services/api/src/services/admin/inbound-repository.ts @@ -0,0 +1,30 @@ +import type { + InboundEmailDTO, + InboundEmailStatus, + InboxListQuery, + InboxListResponse, + MailAttachment, +} from "@civfix/shared" + +export interface InboundEmailInsert { + messageId: string + fromAddr: string | null + toAddr: string | null + recipient: string | null + subject: string | null + bodyText: string | null + bodyHtml: string | null + headers: Record + attachments: MailAttachment[] + receivedAt?: Date +} + +export interface InboundRepository { + insertIdempotent(input: InboundEmailInsert): Promise<{ id: string; inserted: boolean }> + list(query: InboxListQuery): Promise + get(id: string): Promise + setStatus(id: string, status: InboundEmailStatus, actorId: string | null): Promise + /** Counts one more failed bounce-bookkeeping run for a pending object; returns the new total. */ + recordBounceFailure(objectKey: string): Promise + clearBounceFailures(objectKey: string): Promise +} diff --git a/services/api/src/services/admin/system-health-repository.drizzle.ts b/services/api/src/services/admin/system-health-repository.drizzle.ts index 820e7d32..7df4b901 100644 --- a/services/api/src/services/admin/system-health-repository.drizzle.ts +++ b/services/api/src/services/admin/system-health-repository.drizzle.ts @@ -1,15 +1,5 @@ -import type postgres from "postgres" import type { Sql } from "../../db/client.js" - -export interface CountRow { - n: string -} - -// Each read returns the pending query rather than its rows so the probe can cancel it on timeout. -export interface SystemHealthRepository { - countJurisdictions(): postgres.PendingQuery - countMailEventsLast7Days(): postgres.PendingQuery -} +import type { CountRow, SystemHealthRepository } from "./system-health-repository.js" export function makeDrizzleSystemHealthRepository(sql: Sql): SystemHealthRepository { return { diff --git a/services/api/src/services/admin/system-health-repository.ts b/services/api/src/services/admin/system-health-repository.ts new file mode 100644 index 00000000..72d97075 --- /dev/null +++ b/services/api/src/services/admin/system-health-repository.ts @@ -0,0 +1,11 @@ +import type postgres from "postgres" + +export interface CountRow { + n: string +} + +// Each read returns the pending query rather than its rows so the probe can cancel it on timeout. +export interface SystemHealthRepository { + countJurisdictions(): postgres.PendingQuery + countMailEventsLast7Days(): postgres.PendingQuery +} diff --git a/services/api/src/services/affiliation-repository.drizzle.ts b/services/api/src/services/affiliation-repository.drizzle.ts index 867c17c4..4ad57818 100644 --- a/services/api/src/services/affiliation-repository.drizzle.ts +++ b/services/api/src/services/affiliation-repository.drizzle.ts @@ -1,21 +1,7 @@ -import type { OrganizationRefDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" import { blockedPairExpr } from "./blocks-sql.js" import { publicServedKeyExpr } from "./media-served-key.js" - -export interface AffiliationRow { - user_id: string - id: string - slug: string - name: string - verified_status: string - verified_kind: OrganizationRefDTO["verifiedKind"] - logo_key: string | null -} - -export interface AffiliationRepository { - primaryAffiliationRows(ids: string[], viewerId: string | null): Promise -} +import type { AffiliationRepository, AffiliationRow } from "./affiliation-repository.js" export function makeDrizzleAffiliationRepository(sql: Sql): AffiliationRepository { return { diff --git a/services/api/src/services/affiliation-repository.ts b/services/api/src/services/affiliation-repository.ts new file mode 100644 index 00000000..a67606e2 --- /dev/null +++ b/services/api/src/services/affiliation-repository.ts @@ -0,0 +1,15 @@ +import type { OrganizationRefDTO } from "@civfix/shared" + +export interface AffiliationRow { + user_id: string + id: string + slug: string + name: string + verified_status: string + verified_kind: OrganizationRefDTO["verifiedKind"] + logo_key: string | null +} + +export interface AffiliationRepository { + primaryAffiliationRows(ids: string[], viewerId: string | null): Promise +} diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 46370234..31c00ef1 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -24,9 +24,7 @@ import type { Sql, TransactionSql } from "../db/client.js" import { generateToken, sha256Hex } from "../auth/crypto.js" -import type { AnonAbuseReason } from "./anon-service.js" import type { - AnonReportRepository, AnonReportStatusRow, ClaimRepository, CreateAnonReportTxArgs, @@ -46,6 +44,7 @@ import { insertModerationItem } from "./admin/moderation-repository.drizzle.js" import { claimableAsReportMedia } from "./media-bindings.js" import { lockUploadsForClaimIn } from "./media-claim-repository.drizzle.js" import { isUniqueViolation } from "../db/pg-errors.js" +import type { DrizzleAnonReportRepository } from "./anon-repository.js" const HELD_REVIEW_NOTE = "Awaiting automated review" @@ -105,14 +104,6 @@ export interface DrizzleAnonReportRepositoryOptions { newClaimCode?: () => string } -export interface DrizzleAnonReportRepository extends AnonReportRepository { - raiseAbuseFlag( - subjectType: "report" | "anon_token", - subjectId: string, - reason: AnonAbuseReason, - ): Promise -} - export function makeDrizzleAnonReportRepository( sql: Sql, opts: DrizzleAnonReportRepositoryOptions = {}, diff --git a/services/api/src/services/anon-repository.ts b/services/api/src/services/anon-repository.ts index 85bdfb67..58c333b8 100644 --- a/services/api/src/services/anon-repository.ts +++ b/services/api/src/services/anon-repository.ts @@ -8,6 +8,7 @@ import type { ReportType, } from "@civfix/shared" import type { AnonTokenStore } from "../abuse/anon-token.js" +import type { AnonAbuseReason } from "./anon-service.js" export interface CreateAnonReportTxArgs { reportId: string @@ -62,3 +63,11 @@ export interface ClaimRepository extends AnonTokenStore { rotatePendingClaimCode(tokenId: string, claimCodeHash: string): Promise claimByCode(claimCodeHash: string, userId: string): Promise<{ reportId: string } | null> } + +export interface DrizzleAnonReportRepository extends AnonReportRepository { + raiseAbuseFlag( + subjectType: "report" | "anon_token", + subjectId: string, + reason: AnonAbuseReason, + ): Promise +} diff --git a/services/api/src/services/blocks-repository.drizzle.ts b/services/api/src/services/blocks-repository.drizzle.ts index b9d071df..d1a1ddf2 100644 --- a/services/api/src/services/blocks-repository.drizzle.ts +++ b/services/api/src/services/blocks-repository.drizzle.ts @@ -1,39 +1,20 @@ import type { Sql } from "../db/client.js" import { avatarGradient } from "@civfix/shared" -import type { PersonDTO } from "@civfix/shared" import { keysetInstant, keysetPredicate, paginateKeyset, parseKeysetCursor, } from "../db/cursor-helpers.js" +import type { + BlockState, + BlocksRepository, + ListBlockedArgs, + ListBlockedPage, +} from "./blocks-repository.js" export const LIST_BLOCKS_DEFAULT_LIMIT = 50 -export interface ListBlockedArgs { - cursor?: string | null - limit?: number -} - -export interface ListBlockedPage { - blocked: PersonDTO[] - nextCursor: string | null -} - -export interface BlockState { - blockedByViewer: boolean - blockedByTarget: boolean -} - -export interface BlocksRepository { - block(blockerId: string, blockedId: string): Promise - unblock(blockerId: string, blockedId: string): Promise - isBlockedEitherWay(a: string, b: string): Promise - blockState(viewerId: string, targetId: string): Promise - blockedIdsAmong?(actorId: string, candidateIds: string[]): Promise> - listBlocked(blockerId: string, args?: ListBlockedArgs): Promise -} - export function bindBlockedIdsAmong( repo: BlocksRepository, ): ((actorId: string, candidateIds: string[]) => Promise>) | undefined { diff --git a/services/api/src/services/blocks-repository.ts b/services/api/src/services/blocks-repository.ts new file mode 100644 index 00000000..32ee8f4b --- /dev/null +++ b/services/api/src/services/blocks-repository.ts @@ -0,0 +1,25 @@ +import type { PersonDTO } from "@civfix/shared" + +export interface ListBlockedArgs { + cursor?: string | null + limit?: number +} + +export interface ListBlockedPage { + blocked: PersonDTO[] + nextCursor: string | null +} + +export interface BlockState { + blockedByViewer: boolean + blockedByTarget: boolean +} + +export interface BlocksRepository { + block(blockerId: string, blockedId: string): Promise + unblock(blockerId: string, blockedId: string): Promise + isBlockedEitherWay(a: string, b: string): Promise + blockState(viewerId: string, targetId: string): Promise + blockedIdsAmong?(actorId: string, candidateIds: string[]): Promise> + listBlocked(blockerId: string, args?: ListBlockedArgs): Promise +} diff --git a/services/api/src/services/chat-edit-service.ts b/services/api/src/services/chat-edit-service.ts index 271d7b93..6e3fed05 100644 --- a/services/api/src/services/chat-edit-service.ts +++ b/services/api/src/services/chat-edit-service.ts @@ -14,8 +14,8 @@ import { parseUserMentions } from "./discussion-mentions.js" import { broadcastMessageUpdate } from "../ws/frame-handler.js" import { neutralizeChatViewerFields } from "./chat-viewer-fields.js" import type { GatewayChatMentions } from "../ws/types.js" -import type { ChatMessageMeta, ChatRepository } from "./chat-repository.drizzle.js" -import type { DmRepository } from "./dm-repository.drizzle.js" +import type { ChatMessageMeta, ChatRepository } from "./chat-repository.js" +import type { DmRepository } from "./dm-repository.js" import { MS_PER_HOUR } from "../lib/time.js" const CHAT_EDIT_FORBIDDEN = "You can't edit this message." diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index bf3712fa..bed1d645 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -1,7 +1,6 @@ import type { Sql } from "../db/client.js" -import type { MediaDTO, MediaKind, MediaStatus, PersonDTO } from "@civfix/shared" +import type { MediaDTO, MediaKind, MediaStatus } from "@civfix/shared" import type { ChatGroupKind, ChatGroupVisibility } from "../db/schema/chat_groups.js" -import type { GROUP_MEMBER_ROLE_VALUES } from "../db/schema/types.js" import type { PresignMedia } from "./media-presign.js" import { blockedPairExpr } from "./blocks-sql.js" import { toRoomMemberPerson, type RoomMemberIdentityRow } from "./room-member-person.js" @@ -10,17 +9,18 @@ import { userUploader } from "./media-uploader.js" import { monotonicReadWatermarkUpdate } from "./read-watermark-repository.drizzle.js" import { isUuid } from "../db/cursor-helpers.js" import { publicServedKeyExpr } from "./media-served-key.js" - -export type GroupMemberRole = (typeof GROUP_MEMBER_ROLE_VALUES)[number] +import type { + ChatGroupRepository, + ChatGroupView, + CreateChatGroupInput, + GroupMemberRole, + GroupMemberView, + GroupRoomAccess, + UpdateChatGroupPatch, +} from "./chat-group-repository.js" export const GROUP_MEMBER_SCAN_CAP = 2000 -export interface GroupRoomAccess { - kind: ChatGroupKind - visibility: ChatGroupVisibility - role: GroupMemberRole | null -} - export function canPostToGroup(access: { kind: ChatGroupKind role: GroupMemberRole | null @@ -29,69 +29,6 @@ export function canPostToGroup(access: { return access.kind === "group" || access.role === "owner" || access.role === "admin" } -export interface ChatGroupView { - id: string - kind: ChatGroupKind - name: string - description: string | null - visibility: ChatGroupVisibility - ownerId: string - createdAt: Date - avatar: MediaDTO | null - memberCount: number -} - -export interface GroupMemberView { - user: PersonDTO - role: GroupMemberRole - joinedAt: Date -} - -export interface CreateChatGroupInput { - kind: ChatGroupKind - name: string - description: string | null - avatarUploadId: string | null - ownerId: string - visibility: ChatGroupVisibility -} - -export interface UpdateChatGroupPatch { - name?: string - description?: string | null - avatarUploadId?: string - visibility?: ChatGroupVisibility -} - -export interface ChatGroupRepository { - create(input: CreateChatGroupInput, memberIds: string[]): Promise - findById(id: string): Promise - update(id: string, patch: UpdateChatGroupPatch, actorId: string): Promise - roleOf(groupId: string, userId: string): Promise - accessOf(groupId: string, userId: string): Promise - addMembers(groupId: string, userIds: string[]): Promise - joinUnlessBanned(groupId: string, userId: string): Promise - removeMember(groupId: string, userId: string): Promise - banMember(groupId: string, userId: string, bannedBy: string): Promise - setRole(groupId: string, userId: string, role: "admin" | "member"): Promise - findMember( - groupId: string, - userId: string, - viewerId: string | null, - ): Promise - listMembers( - groupId: string, - viewerId: string | null, - cursor: string | null, - limit: number, - ): Promise<{ members: GroupMemberView[]; nextCursor: string | null }> - invitableIdsOf(actorId: string, candidateIds: string[]): Promise - blockedPairsAmong(userIds: string[]): Promise> - listMemberIds(groupId: string, limit?: number): Promise - advanceReadWatermark(groupId: string, userId: string, upToMessageId: string): Promise - markRead(groupId: string, userId: string, at: Date): Promise -} - interface GroupRowSelect { id: string kind: ChatGroupKind diff --git a/services/api/src/services/chat-group-repository.ts b/services/api/src/services/chat-group-repository.ts new file mode 100644 index 00000000..cedcefa8 --- /dev/null +++ b/services/api/src/services/chat-group-repository.ts @@ -0,0 +1,74 @@ +import type { MediaDTO, PersonDTO } from "@civfix/shared" +import type { ChatGroupKind, ChatGroupVisibility } from "../db/schema/chat_groups.js" +import type { GROUP_MEMBER_ROLE_VALUES } from "../db/schema/types.js" + +export type GroupMemberRole = (typeof GROUP_MEMBER_ROLE_VALUES)[number] + +export interface GroupRoomAccess { + kind: ChatGroupKind + visibility: ChatGroupVisibility + role: GroupMemberRole | null +} + +export interface ChatGroupView { + id: string + kind: ChatGroupKind + name: string + description: string | null + visibility: ChatGroupVisibility + ownerId: string + createdAt: Date + avatar: MediaDTO | null + memberCount: number +} + +export interface GroupMemberView { + user: PersonDTO + role: GroupMemberRole + joinedAt: Date +} + +export interface CreateChatGroupInput { + kind: ChatGroupKind + name: string + description: string | null + avatarUploadId: string | null + ownerId: string + visibility: ChatGroupVisibility +} + +export interface UpdateChatGroupPatch { + name?: string + description?: string | null + avatarUploadId?: string + visibility?: ChatGroupVisibility +} + +export interface ChatGroupRepository { + create(input: CreateChatGroupInput, memberIds: string[]): Promise + findById(id: string): Promise + update(id: string, patch: UpdateChatGroupPatch, actorId: string): Promise + roleOf(groupId: string, userId: string): Promise + accessOf(groupId: string, userId: string): Promise + addMembers(groupId: string, userIds: string[]): Promise + joinUnlessBanned(groupId: string, userId: string): Promise + removeMember(groupId: string, userId: string): Promise + banMember(groupId: string, userId: string, bannedBy: string): Promise + setRole(groupId: string, userId: string, role: "admin" | "member"): Promise + findMember( + groupId: string, + userId: string, + viewerId: string | null, + ): Promise + listMembers( + groupId: string, + viewerId: string | null, + cursor: string | null, + limit: number, + ): Promise<{ members: GroupMemberView[]; nextCursor: string | null }> + invitableIdsOf(actorId: string, candidateIds: string[]): Promise + blockedPairsAmong(userIds: string[]): Promise> + listMemberIds(groupId: string, limit?: number): Promise + advanceReadWatermark(groupId: string, userId: string, upToMessageId: string): Promise + markRead(groupId: string, userId: string, at: Date): Promise +} diff --git a/services/api/src/services/chat-group-service.ts b/services/api/src/services/chat-group-service.ts index 11ad7973..7b98f43a 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -13,7 +13,7 @@ import type { ChatGroupView, GroupMemberRole, GroupMemberView, -} from "./chat-group-repository.drizzle.js" +} from "./chat-group-repository.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { isOfficialAccount } from "../auth/official-account.js" import { clampPageLimit } from "../lib/page-limit.js" diff --git a/services/api/src/services/chat-poll-repository.drizzle.ts b/services/api/src/services/chat-poll-repository.drizzle.ts index f67a1ac6..b9b88ea7 100644 --- a/services/api/src/services/chat-poll-repository.drizzle.ts +++ b/services/api/src/services/chat-poll-repository.drizzle.ts @@ -10,41 +10,7 @@ import type { Queryable, Sql } from "../db/client.js" import type { PollDTO, PollOptionDTO } from "@civfix/shared" - -/** dm is excluded upstream: a poll needs an audience. */ -export type PollRoomColumn = "cleanup_id" | "report_id" | "group_id" - -export interface CreatePollRow { - roomColumn: PollRoomColumn - roomId: string - question: string - options: string[] - allowMultiple: boolean - anonymous: boolean - createdBy: string -} - -export interface PollMeta { - messageId: string - createdBy: string - closedAt: Date | null - allowMultiple: boolean - optionIdxs: number[] -} - -export interface ChatPollRepository { - /** `messageId` comes from the container's id factory so it matches the other chat write paths. */ - create(input: CreatePollRow, messageId: string): Promise - findPollMeta(messageId: string): Promise - /** - * An empty `optionIdxs` retracts. The idxs must already be validated against the poll's options. The - * tx re-asserts the poll is still open under a share lock, so a close racing the vote wins and the - * replace no-ops. - */ - replaceVotes(pollId: string, userId: string, optionIdxs: number[]): Promise - /** Idempotent: a re-close keeps the original closed_at and never moves the timestamp. */ - close(pollId: string): Promise -} +import type { ChatPollRepository, CreatePollRow, PollMeta } from "./chat-poll-repository.js" export function makeChatPollRepository(sql: Sql): ChatPollRepository { return { diff --git a/services/api/src/services/chat-poll-repository.ts b/services/api/src/services/chat-poll-repository.ts new file mode 100644 index 00000000..e778a0b3 --- /dev/null +++ b/services/api/src/services/chat-poll-repository.ts @@ -0,0 +1,34 @@ +/** dm is excluded upstream: a poll needs an audience. */ +export type PollRoomColumn = "cleanup_id" | "report_id" | "group_id" + +export interface CreatePollRow { + roomColumn: PollRoomColumn + roomId: string + question: string + options: string[] + allowMultiple: boolean + anonymous: boolean + createdBy: string +} + +export interface PollMeta { + messageId: string + createdBy: string + closedAt: Date | null + allowMultiple: boolean + optionIdxs: number[] +} + +export interface ChatPollRepository { + /** `messageId` comes from the container's id factory so it matches the other chat write paths. */ + create(input: CreatePollRow, messageId: string): Promise + findPollMeta(messageId: string): Promise + /** + * An empty `optionIdxs` retracts. The idxs must already be validated against the poll's options. The + * tx re-asserts the poll is still open under a share lock, so a close racing the vote wins and the + * replace no-ops. + */ + replaceVotes(pollId: string, userId: string, optionIdxs: number[]): Promise + /** Idempotent: a re-close keeps the original closed_at and never moves the timestamp. */ + close(pollId: string): Promise +} diff --git a/services/api/src/services/chat-poll-service.ts b/services/api/src/services/chat-poll-service.ts index d421c02f..c667dd0f 100644 --- a/services/api/src/services/chat-poll-service.ts +++ b/services/api/src/services/chat-poll-service.ts @@ -1,7 +1,7 @@ import { AppError, ErrorCode } from "@civfix/shared" import type { ChatMessageDTO } from "@civfix/shared" -import type { ChatRepository } from "./chat-repository.drizzle.js" -import type { ChatPollRepository, PollRoomColumn } from "./chat-poll-repository.drizzle.js" +import type { ChatRepository } from "./chat-repository.js" +import type { ChatPollRepository, PollRoomColumn } from "./chat-poll-repository.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { neutralizeChatViewerFields } from "./chat-viewer-fields.js" diff --git a/services/api/src/services/chat-reaction-service.ts b/services/api/src/services/chat-reaction-service.ts index 087f49a1..f087a164 100644 --- a/services/api/src/services/chat-reaction-service.ts +++ b/services/api/src/services/chat-reaction-service.ts @@ -10,8 +10,8 @@ import { AppError, ReactionEmojiSchema } from "@civfix/shared" import type { ChatMessageDTO, ReactionEmoji } from "@civfix/shared" -import type { ChatMessageMeta, ChatRepository } from "./chat-repository.drizzle.js" -import type { DmRepository } from "./dm-repository.drizzle.js" +import type { ChatMessageMeta, ChatRepository } from "./chat-repository.js" +import type { DmRepository } from "./dm-repository.js" const CHAT_REACTION_FORBIDDEN = "You can't react in this conversation." diff --git a/services/api/src/services/chat-repository.drizzle.ts b/services/api/src/services/chat-repository.drizzle.ts index 4a2cce46..359b05f8 100644 --- a/services/api/src/services/chat-repository.drizzle.ts +++ b/services/api/src/services/chat-repository.drizzle.ts @@ -31,6 +31,12 @@ import { senderColumns, type MessageCoreRow, } from "./chat-message-core-sql.js" +import type { + ChatMessageMeta, + ChatRepository, + InsertMessageOptions, + SoftDeleteOpts, +} from "./chat-repository.js" export interface ReportCityContext { geoid: string @@ -38,134 +44,6 @@ export interface ReportCityContext { handle: string | null } -export interface ChatMessageMeta { - id: string - cleanupId: string | null - reportId: string | null - groupId: string | null - senderId: string | null - kind: ChatMessageKind - createdAt: Date - deletedAt: Date | null -} - -export interface InsertedChatRow { - id: string - createdAt: Date -} - -export interface InsertMessageOptions { - inTx?: (tx: Queryable, row: InsertedChatRow) => Promise -} - -export interface ChatRepository { - insertMessage( - input: PersistChatInput, - id: string, - options?: InsertMessageOptions, - ): Promise - findMessageMeta(messageId: string): Promise - history( - cleanupId: string, - before: string | undefined, - limit: number, - viewerUserId?: string | null, - around?: string, - ): Promise - findMessage( - cleanupId: string, - messageId: string, - viewerUserId: string | null, - ): Promise - toggleReaction(messageId: string, userId: string, emoji: ReactionEmoji): Promise - editMessage( - cleanupId: string, - messageId: string, - senderId: string, - body: string, - ): Promise - softDelete( - cleanupId: string, - messageId: string, - senderId: string, - opts?: SoftDeleteOpts, - ): Promise - setPinned( - cleanupId: string, - messageId: string, - userId: string, - pinned: boolean, - ): Promise - setReportPinned( - reportId: string, - messageId: string, - userId: string, - pinned: boolean, - ): Promise - listPins(cleanupId: string, viewerUserId: string | null): Promise - listReportPins(reportId: string, viewerUserId: string | null): Promise - reportHistory( - reportId: string, - before: string | undefined, - limit: number, - viewerUserId?: string | null, - around?: string, - ): Promise - findReportMessage( - reportId: string, - messageId: string, - viewerUserId: string | null, - ): Promise - editReportMessage( - reportId: string, - messageId: string, - senderId: string, - body: string, - ): Promise - softDeleteReport( - reportId: string, - messageId: string, - senderId: string, - opts?: SoftDeleteOpts, - ): Promise - countReportMessages(reportId: string): Promise - groupHistory( - groupId: string, - before: string | undefined, - limit: number, - viewerUserId?: string | null, - around?: string, - ): Promise - findGroupMessage( - groupId: string, - messageId: string, - viewerUserId: string | null, - ): Promise - editGroupMessage( - groupId: string, - messageId: string, - senderId: string, - body: string, - ): Promise - softDeleteGroup( - groupId: string, - messageId: string, - senderId: string, - opts?: SoftDeleteOpts, - ): Promise - setGroupPinned( - groupId: string, - messageId: string, - userId: string, - pinned: boolean, - ): Promise - listGroupPins(groupId: string, viewerUserId: string | null): Promise -} - -export interface SoftDeleteOpts { - bypassSenderGate?: boolean -} - export { PIN_LIST_CAP } interface ChatRowSelect extends MessageCoreRow { diff --git a/services/api/src/services/chat-repository.ts b/services/api/src/services/chat-repository.ts new file mode 100644 index 00000000..5fd33289 --- /dev/null +++ b/services/api/src/services/chat-repository.ts @@ -0,0 +1,131 @@ +import type { ChatMessageDTO, ChatMessageKind, ReactionEmoji } from "@civfix/shared" +import type { ChatHistoryPage, PersistChatInput } from "@civfix/shared/interfaces" +import type { Queryable } from "../db/client.js" + +export interface ChatMessageMeta { + id: string + cleanupId: string | null + reportId: string | null + groupId: string | null + senderId: string | null + kind: ChatMessageKind + createdAt: Date + deletedAt: Date | null +} + +export interface InsertedChatRow { + id: string + createdAt: Date +} + +export interface InsertMessageOptions { + inTx?: (tx: Queryable, row: InsertedChatRow) => Promise +} + +export interface ChatRepository { + insertMessage( + input: PersistChatInput, + id: string, + options?: InsertMessageOptions, + ): Promise + findMessageMeta(messageId: string): Promise + history( + cleanupId: string, + before: string | undefined, + limit: number, + viewerUserId?: string | null, + around?: string, + ): Promise + findMessage( + cleanupId: string, + messageId: string, + viewerUserId: string | null, + ): Promise + toggleReaction(messageId: string, userId: string, emoji: ReactionEmoji): Promise + editMessage( + cleanupId: string, + messageId: string, + senderId: string, + body: string, + ): Promise + softDelete( + cleanupId: string, + messageId: string, + senderId: string, + opts?: SoftDeleteOpts, + ): Promise + setPinned( + cleanupId: string, + messageId: string, + userId: string, + pinned: boolean, + ): Promise + setReportPinned( + reportId: string, + messageId: string, + userId: string, + pinned: boolean, + ): Promise + listPins(cleanupId: string, viewerUserId: string | null): Promise + listReportPins(reportId: string, viewerUserId: string | null): Promise + reportHistory( + reportId: string, + before: string | undefined, + limit: number, + viewerUserId?: string | null, + around?: string, + ): Promise + findReportMessage( + reportId: string, + messageId: string, + viewerUserId: string | null, + ): Promise + editReportMessage( + reportId: string, + messageId: string, + senderId: string, + body: string, + ): Promise + softDeleteReport( + reportId: string, + messageId: string, + senderId: string, + opts?: SoftDeleteOpts, + ): Promise + countReportMessages(reportId: string): Promise + groupHistory( + groupId: string, + before: string | undefined, + limit: number, + viewerUserId?: string | null, + around?: string, + ): Promise + findGroupMessage( + groupId: string, + messageId: string, + viewerUserId: string | null, + ): Promise + editGroupMessage( + groupId: string, + messageId: string, + senderId: string, + body: string, + ): Promise + softDeleteGroup( + groupId: string, + messageId: string, + senderId: string, + opts?: SoftDeleteOpts, + ): Promise + setGroupPinned( + groupId: string, + messageId: string, + userId: string, + pinned: boolean, + ): Promise + listGroupPins(groupId: string, viewerUserId: string | null): Promise +} + +export interface SoftDeleteOpts { + bypassSenderGate?: boolean +} diff --git a/services/api/src/services/cleanup-map-repository.drizzle.ts b/services/api/src/services/cleanup-map-repository.drizzle.ts index 3ed2df9d..d8229f45 100644 --- a/services/api/src/services/cleanup-map-repository.drizzle.ts +++ b/services/api/src/services/cleanup-map-repository.drizzle.ts @@ -1,22 +1,15 @@ import type { CleanupPinDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" -import type { CleanupBBox } from "./cleanup-repository.js" import { buildBboxFilter, buildVisibilityFilter, buildWhenFilter, goingScalar, } from "./cleanup-sql.js" +import type { CleanupMapRepository } from "./cleanup-map-repository.js" export const MAP_CLEANUPS_LIMIT = 500 -export interface CleanupMapRepository { - listCleanupPins( - bbox: CleanupBBox, - when: "upcoming" | "past" | undefined, - ): Promise -} - export function makeDrizzleCleanupMapRepository(sql: Sql): CleanupMapRepository { return { async listCleanupPins(bbox, when): Promise { diff --git a/services/api/src/services/cleanup-map-repository.ts b/services/api/src/services/cleanup-map-repository.ts new file mode 100644 index 00000000..d667d72f --- /dev/null +++ b/services/api/src/services/cleanup-map-repository.ts @@ -0,0 +1,9 @@ +import type { CleanupPinDTO } from "@civfix/shared" +import type { CleanupBBox } from "./cleanup-repository.js" + +export interface CleanupMapRepository { + listCleanupPins( + bbox: CleanupBBox, + when: "upcoming" | "past" | undefined, + ): Promise +} diff --git a/services/api/src/services/content-report-subject.ts b/services/api/src/services/content-report-subject.ts index 64827b1c..d1c24d8c 100644 --- a/services/api/src/services/content-report-subject.ts +++ b/services/api/src/services/content-report-subject.ts @@ -6,10 +6,8 @@ import { authorizeReportBound, makeDrizzleMediaViewAuthorizer, } from "./media-authorization.js" -import { - makeDrizzleMediaAuthorizationRepository, - type MediaAuthorizationRepository, -} from "./media-authorization-repository.drizzle.js" +import { makeDrizzleMediaAuthorizationRepository } from "./media-authorization-repository.drizzle.js" +import type { MediaAuthorizationRepository } from "./media-authorization-repository.js" import { makeDrizzleMediaRepository } from "./media-repository.drizzle.js" import { isPubliclyVisibleStatus } from "./report-visibility.js" import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" diff --git a/services/api/src/services/conversation-hides-repository.drizzle.ts b/services/api/src/services/conversation-hides-repository.drizzle.ts index ae82dfe1..7217eb02 100644 --- a/services/api/src/services/conversation-hides-repository.drizzle.ts +++ b/services/api/src/services/conversation-hides-repository.drizzle.ts @@ -1,19 +1,6 @@ import type { Sql } from "../db/client.js" import type { ConversationHideRoomKind } from "../db/schema/conversation_hides.js" - -export interface ConversationHidesRepository { - setHidden( - userId: string, - roomKind: ConversationHideRoomKind, - roomId: string, - hidden: boolean, - ): Promise - hiddenAtFor( - userId: string, - roomKind: ConversationHideRoomKind, - roomIds: string[], - ): Promise> -} +import type { ConversationHidesRepository } from "./conversation-hides-repository.js" export function makeConversationHidesRepository(sql: Sql): ConversationHidesRepository { return { diff --git a/services/api/src/services/conversation-hides-repository.memory.ts b/services/api/src/services/conversation-hides-repository.memory.ts index 4ce28c44..94a9abe4 100644 --- a/services/api/src/services/conversation-hides-repository.memory.ts +++ b/services/api/src/services/conversation-hides-repository.memory.ts @@ -1,5 +1,5 @@ import type { ConversationHideRoomKind } from "../db/schema/conversation_hides.js" -import type { ConversationHidesRepository } from "./conversation-hides-repository.drizzle.js" +import type { ConversationHidesRepository } from "./conversation-hides-repository.js" export function makeMemoryConversationHidesRepository( now: () => Date = () => new Date(), diff --git a/services/api/src/services/conversation-hides-repository.ts b/services/api/src/services/conversation-hides-repository.ts new file mode 100644 index 00000000..580998f7 --- /dev/null +++ b/services/api/src/services/conversation-hides-repository.ts @@ -0,0 +1,15 @@ +import type { ConversationHideRoomKind } from "../db/schema/conversation_hides.js" + +export interface ConversationHidesRepository { + setHidden( + userId: string, + roomKind: ConversationHideRoomKind, + roomId: string, + hidden: boolean, + ): Promise + hiddenAtFor( + userId: string, + roomKind: ConversationHideRoomKind, + roomIds: string[], + ): Promise> +} diff --git a/services/api/src/services/conversation-mutes-repository.drizzle.ts b/services/api/src/services/conversation-mutes-repository.drizzle.ts index 8a936abc..978aa1ab 100644 --- a/services/api/src/services/conversation-mutes-repository.drizzle.ts +++ b/services/api/src/services/conversation-mutes-repository.drizzle.ts @@ -4,31 +4,7 @@ import type { FastifyBaseLogger } from "fastify" import type { Sql } from "../db/client.js" import type { ConversationMuteRoomKind } from "../db/schema/conversation_mutes.js" - -export interface ConversationMutesRepository { - isMuted(userId: string, roomKind: ConversationMuteRoomKind, roomId: string): Promise - /** Idempotent both ways: re-muting and unmuting an unmuted room are no-ops, not errors. */ - setMuted( - userId: string, - roomKind: ConversationMuteRoomKind, - roomId: string, - muted: boolean, - ): Promise - mutedRoomIdsFor( - userId: string, - roomKind: ConversationMuteRoomKind, - roomIds: string[], - ): Promise> - /** - * Optional so the offline fakes that predate it still satisfy the interface; callers must probe - * (`repo.mutedUserIdsFor?.(...)`) and fall back to the per-user `isMuted`. - */ - mutedUserIdsFor?( - roomKind: ConversationMuteRoomKind, - roomId: string, - userIds: string[], - ): Promise> -} +import type { ConversationMutesRepository } from "./conversation-mutes-repository.js" export type FailOpenMuteCheck = ( userId: string, diff --git a/services/api/src/services/conversation-mutes-repository.ts b/services/api/src/services/conversation-mutes-repository.ts new file mode 100644 index 00000000..0e8db567 --- /dev/null +++ b/services/api/src/services/conversation-mutes-repository.ts @@ -0,0 +1,26 @@ +import type { ConversationMuteRoomKind } from "../db/schema/conversation_mutes.js" + +export interface ConversationMutesRepository { + isMuted(userId: string, roomKind: ConversationMuteRoomKind, roomId: string): Promise + /** Idempotent both ways: re-muting and unmuting an unmuted room are no-ops, not errors. */ + setMuted( + userId: string, + roomKind: ConversationMuteRoomKind, + roomId: string, + muted: boolean, + ): Promise + mutedRoomIdsFor( + userId: string, + roomKind: ConversationMuteRoomKind, + roomIds: string[], + ): Promise> + /** + * Optional so the offline fakes that predate it still satisfy the interface; callers must probe + * (`repo.mutedUserIdsFor?.(...)`) and fall back to the per-user `isMuted`. + */ + mutedUserIdsFor?( + roomKind: ConversationMuteRoomKind, + roomId: string, + userIds: string[], + ): Promise> +} diff --git a/services/api/src/services/data-export-repository.drizzle.ts b/services/api/src/services/data-export-repository.drizzle.ts index 1ef2d124..c1601a7f 100644 --- a/services/api/src/services/data-export-repository.drizzle.ts +++ b/services/api/src/services/data-export-repository.drizzle.ts @@ -1,203 +1,27 @@ import type { Sql } from "../db/client.js" - -export interface ProfileRow { - id: string - display_name: string - handle: string | null - email: string | null - email_verified: boolean - bio: string | null - avatar_url: string | null - donation_url: string | null - created_at: Date - deleted_at: Date | null -} - -export interface ReportExportRow { - id: string - category: string - title: string | null - description: string | null - place: string | null - status: string - created_at: Date -} - -export interface PostExportRow { - id: string - kind: string - body: string | null - visibility: string - reply_to_id: string | null - repost_of_id: string | null - created_at: Date - updated_at: Date - deleted_at: Date | null -} - -export interface ChatMessageExportRow { - id: string - cleanup_id: string | null - report_id: string | null - group_id: string | null - body: string | null - created_at: Date - deleted_at: Date | null -} - -export interface DmMessageExportRow { - id: string - thread_id: string - body: string | null - created_at: Date - deleted_at: Date | null -} - -export interface VolunteerHoursExportRow { - id: string - source: string - report_id: string | null - cleanup_id: string | null - jurisdiction_geoid: string | null - hours: number - logged_by_user_id: string | null - created_at: Date -} - -export interface CleanupOrganizedExportRow { - id: string - title: string | null - created_at: Date -} - -export interface CleanupJoinedExportRow { - cleanup_id: string - role: string - joined_at: Date -} - -export interface FollowingExportRow { - followee_id: string -} - -export interface FollowerExportRow { - follower_id: string -} - -export interface BlockExportRow { - blocked_id: string -} - -export interface NotificationPrefsExportRow { - user_id: string - push: boolean - cleanup_chat: boolean - report_updates: boolean - follows: boolean - quiet_start: string | null - quiet_end: string | null - mentions: boolean - host_broadcasts: boolean -} - -export interface PushTokenRow { - id: string - platform: string - created_at: Date - revoked_at: Date | null -} - -export interface CertificateExportRow { - code: string - locale: string - holder_name: string - holder_handle: string | null - total_hours: number - entry_count: number - period_start: Date | null - period_end: Date | null - document_sha256: string - byte_size: number - issued_at: Date - revoked_at: Date | null - revoked_reason: string | null -} - -export interface OrganizationExportRow { - organization_id: string - slug: string - name: string - role: string - joined_at: Date -} - -export interface EventTeamMembershipExportRow { - cleanup_id: string - role: string - joined_at: Date | null -} - -export interface EventConsentExportRow { - cleanup_id: string - terms_version: string - disclosure_version: string - host_contact_opt_in: boolean - sms_opt_in: boolean - accepted_at: Date -} - -export interface EventRegistrationExportRow { - id: string - cleanup_id: string - ticket_type_name: string | null - party_size: number - status: string - source: string - registered_at: Date - cancelled_at: Date | null -} - -export interface EventAnswerExportRow { - cleanup_id: string - prompt: string - value: string | null -} - -export interface EventCheckinExportRow { - cleanup_id: string - seat_index: number - checked_in_at: Date - checkin_method: string | null -} - -/** - * Every column a personal data export can carry: a column that is not selected here cannot leak into one. - * Section methods return the pending query unawaited, so the service decides when each statement is sent, - * and fetch one row past `rowCap` so a clipped section is detectable. - */ -export interface DataExportRepository { - profile(userId: string): Promise - reports(userId: string, rowCap: number): Promise - posts(userId: string, rowCap: number): Promise - chatMessages(userId: string, rowCap: number): Promise - dmMessages(userId: string, rowCap: number): Promise - volunteerHours(userId: string, rowCap: number): Promise - cleanupsOrganized(userId: string, rowCap: number): Promise - cleanupsJoined(userId: string, rowCap: number): Promise - following(userId: string, rowCap: number): Promise - followers(userId: string, rowCap: number): Promise - blocks(userId: string, rowCap: number): Promise - notificationPrefs(userId: string): Promise - /** The projection has no token column: a raw device token never reaches the export. */ - pushTokens(userId: string, rowCap: number): Promise - certificates(userId: string, rowCap: number): Promise - organizations(userId: string, rowCap: number): Promise - eventTeamMemberships(userId: string, rowCap: number): Promise - eventConsents(userId: string, rowCap: number): Promise - eventRegistrations(userId: string, rowCap: number): Promise - eventAnswers(userId: string, rowCap: number): Promise - eventCheckins(userId: string, rowCap: number): Promise -} +import type { + BlockExportRow, + CertificateExportRow, + ChatMessageExportRow, + CleanupJoinedExportRow, + CleanupOrganizedExportRow, + DataExportRepository, + DmMessageExportRow, + EventAnswerExportRow, + EventCheckinExportRow, + EventConsentExportRow, + EventRegistrationExportRow, + EventTeamMembershipExportRow, + FollowerExportRow, + FollowingExportRow, + NotificationPrefsExportRow, + OrganizationExportRow, + PostExportRow, + ProfileRow, + PushTokenRow, + ReportExportRow, + VolunteerHoursExportRow, +} from "./data-export-repository.js" export function makeDrizzleDataExportRepository(sql: Sql): DataExportRepository { return { diff --git a/services/api/src/services/data-export-repository.ts b/services/api/src/services/data-export-repository.ts new file mode 100644 index 00000000..4e25d4f4 --- /dev/null +++ b/services/api/src/services/data-export-repository.ts @@ -0,0 +1,198 @@ +export interface ProfileRow { + id: string + display_name: string + handle: string | null + email: string | null + email_verified: boolean + bio: string | null + avatar_url: string | null + donation_url: string | null + created_at: Date + deleted_at: Date | null +} + +export interface ReportExportRow { + id: string + category: string + title: string | null + description: string | null + place: string | null + status: string + created_at: Date +} + +export interface PostExportRow { + id: string + kind: string + body: string | null + visibility: string + reply_to_id: string | null + repost_of_id: string | null + created_at: Date + updated_at: Date + deleted_at: Date | null +} + +export interface ChatMessageExportRow { + id: string + cleanup_id: string | null + report_id: string | null + group_id: string | null + body: string | null + created_at: Date + deleted_at: Date | null +} + +export interface DmMessageExportRow { + id: string + thread_id: string + body: string | null + created_at: Date + deleted_at: Date | null +} + +export interface VolunteerHoursExportRow { + id: string + source: string + report_id: string | null + cleanup_id: string | null + jurisdiction_geoid: string | null + hours: number + logged_by_user_id: string | null + created_at: Date +} + +export interface CleanupOrganizedExportRow { + id: string + title: string | null + created_at: Date +} + +export interface CleanupJoinedExportRow { + cleanup_id: string + role: string + joined_at: Date +} + +export interface FollowingExportRow { + followee_id: string +} + +export interface FollowerExportRow { + follower_id: string +} + +export interface BlockExportRow { + blocked_id: string +} + +export interface NotificationPrefsExportRow { + user_id: string + push: boolean + cleanup_chat: boolean + report_updates: boolean + follows: boolean + quiet_start: string | null + quiet_end: string | null + mentions: boolean + host_broadcasts: boolean +} + +export interface PushTokenRow { + id: string + platform: string + created_at: Date + revoked_at: Date | null +} + +export interface CertificateExportRow { + code: string + locale: string + holder_name: string + holder_handle: string | null + total_hours: number + entry_count: number + period_start: Date | null + period_end: Date | null + document_sha256: string + byte_size: number + issued_at: Date + revoked_at: Date | null + revoked_reason: string | null +} + +export interface OrganizationExportRow { + organization_id: string + slug: string + name: string + role: string + joined_at: Date +} + +export interface EventTeamMembershipExportRow { + cleanup_id: string + role: string + joined_at: Date | null +} + +export interface EventConsentExportRow { + cleanup_id: string + terms_version: string + disclosure_version: string + host_contact_opt_in: boolean + sms_opt_in: boolean + accepted_at: Date +} + +export interface EventRegistrationExportRow { + id: string + cleanup_id: string + ticket_type_name: string | null + party_size: number + status: string + source: string + registered_at: Date + cancelled_at: Date | null +} + +export interface EventAnswerExportRow { + cleanup_id: string + prompt: string + value: string | null +} + +export interface EventCheckinExportRow { + cleanup_id: string + seat_index: number + checked_in_at: Date + checkin_method: string | null +} + +/** + * Every column a personal data export can carry: a column that is not selected here cannot leak into one. + * Section methods return the pending query unawaited, so the service decides when each statement is sent, + * and fetch one row past `rowCap` so a clipped section is detectable. + */ +export interface DataExportRepository { + profile(userId: string): Promise + reports(userId: string, rowCap: number): Promise + posts(userId: string, rowCap: number): Promise + chatMessages(userId: string, rowCap: number): Promise + dmMessages(userId: string, rowCap: number): Promise + volunteerHours(userId: string, rowCap: number): Promise + cleanupsOrganized(userId: string, rowCap: number): Promise + cleanupsJoined(userId: string, rowCap: number): Promise + following(userId: string, rowCap: number): Promise + followers(userId: string, rowCap: number): Promise + blocks(userId: string, rowCap: number): Promise + notificationPrefs(userId: string): Promise + /** The projection has no token column: a raw device token never reaches the export. */ + pushTokens(userId: string, rowCap: number): Promise + certificates(userId: string, rowCap: number): Promise + organizations(userId: string, rowCap: number): Promise + eventTeamMemberships(userId: string, rowCap: number): Promise + eventConsents(userId: string, rowCap: number): Promise + eventRegistrations(userId: string, rowCap: number): Promise + eventAnswers(userId: string, rowCap: number): Promise + eventCheckins(userId: string, rowCap: number): Promise +} diff --git a/services/api/src/services/data-export-service.ts b/services/api/src/services/data-export-service.ts index 9e9800e4..e9ba1c68 100644 --- a/services/api/src/services/data-export-service.ts +++ b/services/api/src/services/data-export-service.ts @@ -6,15 +6,15 @@ import { heading, paragraph } from "../adapters/email-blocks.js" import { renderEmailBody } from "../adapters/email-layout.js" import { mailFailure } from "../adapters/mail-failure.js" import { insertAuditRow } from "./admin/audit-repository.drizzle.js" -import { - makeDrizzleDataExportRepository, - type BlockExportRow, - type DataExportRepository, - type FollowerExportRow, - type FollowingExportRow, - type ProfileRow, - type PushTokenRow, -} from "./data-export-repository.drizzle.js" +import { makeDrizzleDataExportRepository } from "./data-export-repository.drizzle.js" +import type { + BlockExportRow, + DataExportRepository, + FollowerExportRow, + FollowingExportRow, + ProfileRow, + PushTokenRow, +} from "./data-export-repository.js" export interface DataExportServiceDeps { sql: Sql diff --git a/services/api/src/services/dm-peer.ts b/services/api/src/services/dm-peer.ts index ecf762ab..89c606fd 100644 --- a/services/api/src/services/dm-peer.ts +++ b/services/api/src/services/dm-peer.ts @@ -1,7 +1,7 @@ // Every DM gate (history, send, edit, react, pin powers, mention scoping) must agree on this, because // "no peer" is also how each of them decides "not a participant". -import type { DmRepository, DmThread } from "./dm-repository.drizzle.js" +import type { DmRepository, DmThread } from "./dm-repository.js" function dmPeerOfThread(thread: DmThread, userId: string): string | null { if (thread.userLo === userId) return thread.userHi diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index b1ebbaa2..27c65e4c 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -29,102 +29,18 @@ import { senderColumns, type MessageCoreRow, } from "./chat-message-core-sql.js" +import type { + DmMessageMeta, + DmPersistInput, + DmRepository, + DmThread, + DmThreadAggregate, +} from "./dm-repository.js" // dm_messages is range-partitioned on created_at and an ack carries only the message id, so the bound // lets the planner prune the lookup to recent partitions instead of probing every month ever created. const DM_ACK_LOOKUP_WINDOW_DAYS = 90 -export interface DmThread { - id: string - userLo: string - userHi: string - createdAt: Date -} - -export interface DmThreadAggregate { - threadId: string - createdAt: Date - peer: { - id: string - displayName: string - handle: string | null - bio: string | null - avatarUrl: string | null - deleted: boolean - } - last: { - body: string | null - createdAt: Date - senderId: string - } | null - unread: number -} - -export interface DmPersistInput { - threadId: string - senderId: string - body: string - kind?: ChatMessageKind - clientId?: string - attachments?: unknown[] | null - mediaUploadIds?: string[] - replyToId?: string -} - -export interface DmMessageMeta { - id: string - threadId: string - senderId: string - kind: ChatMessageKind - createdAt: Date - deletedAt: Date | null -} - -export interface DmRepository { - openOrCreateThread(userA: string, userB: string): Promise - getThreadForPair(userA: string, userB: string): Promise - getThread(threadId: string): Promise - isParticipant(threadId: string, userId: string): Promise - persist(input: DmPersistInput): Promise - editMessage( - threadId: string, - messageId: string, - senderId: string, - body: string, - ): Promise - findMessageMeta(messageId: string): Promise - softDelete(threadId: string, messageId: string, senderId: string): Promise - setPinned( - threadId: string, - messageId: string, - userId: string, - pinned: boolean, - ): Promise - listPins(threadId: string, viewerUserId: string | null): Promise - history( - threadId: string, - before: string | undefined, - limit: number, - viewerUserId?: string | null, - around?: string, - ): Promise - findMessage( - threadId: string, - messageId: string, - viewerUserId: string | null, - ): Promise - toggleReaction(messageId: string, userId: string, emoji: ReactionEmoji): Promise - markRead(threadId: string, userId: string, at: Date): Promise - lastReadAt(threadId: string, userId: string): Promise - countUnread(threadId: string, userId: string): Promise - resolveMessageCreatedAt(threadId: string, messageId: string): Promise - listThreadsForUser( - userId: string, - limit?: number, - cursor?: TimeCursor | null, - ): Promise -} - interface DmRowSelect extends MessageCoreRow { thread_id: string sender_id: string diff --git a/services/api/src/services/dm-repository.memory.ts b/services/api/src/services/dm-repository.memory.ts index bfca6799..fa5a6abc 100644 --- a/services/api/src/services/dm-repository.memory.ts +++ b/services/api/src/services/dm-repository.memory.ts @@ -7,21 +7,21 @@ import { PIN_LIST_CAP } from "./room-messages-repository.drizzle.js" import { publicAuthorIdentity } from "./public-author.js" import { aroundLimits } from "./chat-history-window.js" import { toTombstoneDTO } from "./chat-tombstone.js" -import type { ConversationHidesRepository } from "./conversation-hides-repository.drizzle.js" -import { visibleAfterHides } from "./conversation-hides-repository.memory.js" +import type { ConversationHidesRepository } from "./conversation-hides-repository.js" import type { DmMessageMeta, DmPersistInput, DmRepository, DmThread, DmThreadAggregate, -} from "./dm-repository.drizzle.js" +} from "./dm-repository.js" import type { BlockState, BlocksRepository, ListBlockedArgs, ListBlockedPage, -} from "./blocks-repository.drizzle.js" +} from "./blocks-repository.js" +import { visibleAfterHides } from "./conversation-hides-repository.memory.js" import { LIST_BLOCKS_DEFAULT_LIMIT } from "./blocks-repository.drizzle.js" import type { TimeCursor } from "../db/cursor-helpers.js" import type { PersonDTO } from "@civfix/shared" diff --git a/services/api/src/services/dm-repository.ts b/services/api/src/services/dm-repository.ts new file mode 100644 index 00000000..554e7c38 --- /dev/null +++ b/services/api/src/services/dm-repository.ts @@ -0,0 +1,94 @@ +import type { ChatMessageDTO, ChatMessageKind, ReactionEmoji } from "@civfix/shared" +import type { ChatHistoryPage } from "@civfix/shared/interfaces" +import type { TimeCursor } from "../db/cursor-helpers.js" + +export interface DmThread { + id: string + userLo: string + userHi: string + createdAt: Date +} + +export interface DmThreadAggregate { + threadId: string + createdAt: Date + peer: { + id: string + displayName: string + handle: string | null + bio: string | null + avatarUrl: string | null + deleted: boolean + } + last: { + body: string | null + createdAt: Date + senderId: string + } | null + unread: number +} + +export interface DmPersistInput { + threadId: string + senderId: string + body: string + kind?: ChatMessageKind + clientId?: string + attachments?: unknown[] | null + mediaUploadIds?: string[] + replyToId?: string +} + +export interface DmMessageMeta { + id: string + threadId: string + senderId: string + kind: ChatMessageKind + createdAt: Date + deletedAt: Date | null +} + +export interface DmRepository { + openOrCreateThread(userA: string, userB: string): Promise + getThreadForPair(userA: string, userB: string): Promise + getThread(threadId: string): Promise + isParticipant(threadId: string, userId: string): Promise + persist(input: DmPersistInput): Promise + editMessage( + threadId: string, + messageId: string, + senderId: string, + body: string, + ): Promise + findMessageMeta(messageId: string): Promise + softDelete(threadId: string, messageId: string, senderId: string): Promise + setPinned( + threadId: string, + messageId: string, + userId: string, + pinned: boolean, + ): Promise + listPins(threadId: string, viewerUserId: string | null): Promise + history( + threadId: string, + before: string | undefined, + limit: number, + viewerUserId?: string | null, + around?: string, + ): Promise + findMessage( + threadId: string, + messageId: string, + viewerUserId: string | null, + ): Promise + toggleReaction(messageId: string, userId: string, emoji: ReactionEmoji): Promise + markRead(threadId: string, userId: string, at: Date): Promise + lastReadAt(threadId: string, userId: string): Promise + countUnread(threadId: string, userId: string): Promise + resolveMessageCreatedAt(threadId: string, messageId: string): Promise + listThreadsForUser( + userId: string, + limit?: number, + cursor?: TimeCursor | null, + ): Promise +} diff --git a/services/api/src/services/dm-service.ts b/services/api/src/services/dm-service.ts index 501e2084..b4a2fe70 100644 --- a/services/api/src/services/dm-service.ts +++ b/services/api/src/services/dm-service.ts @@ -1,7 +1,7 @@ import { AppError, relativeAgo, avatarGradient } from "@civfix/shared" import type { ChatMessageDTO, MessageThreadDTO, PersonDTO } from "@civfix/shared" -import type { BlocksRepository } from "./blocks-repository.drizzle.js" -import type { DmRepository } from "./dm-repository.drizzle.js" +import type { BlocksRepository } from "./blocks-repository.js" +import type { DmRepository } from "./dm-repository.js" export const DM_FORBIDDEN_MESSAGE = "You can't message this account." diff --git a/services/api/src/services/erasure-repository.drizzle.ts b/services/api/src/services/erasure-repository.drizzle.ts index b9817d13..e4a3e123 100644 --- a/services/api/src/services/erasure-repository.drizzle.ts +++ b/services/api/src/services/erasure-repository.drizzle.ts @@ -1,22 +1,6 @@ import { inArray, sql } from "drizzle-orm" import { DELETED_USER_LABEL } from "@civfix/shared" -import type { Db } from "../db/client.js" - -export type DbTransaction = Parameters[0]>[0] - -export interface TransferredEvent extends Record { - cleanup_id: string - new_organizer: string - title: string -} - -export interface ErasureRepository { - transferHostedEvents(tx: DbTransaction, userId: string): Promise - releaseOrganizations(tx: DbTransaction, userId: string): Promise - scrubAttendeeContributions(tx: DbTransaction, userId: string): Promise - scrubModerationSnapshots(tx: DbTransaction, userId: string): Promise - purgeVerificationDocuments(tx: DbTransaction, userId: string): Promise -} +import type { DbTransaction, ErasureRepository, TransferredEvent } from "./erasure-repository.js" // Takes no handle: every step runs on the erasure transaction its caller passes in, never on the pool. export function makeDrizzleErasureRepository(): ErasureRepository { diff --git a/services/api/src/services/erasure-repository.ts b/services/api/src/services/erasure-repository.ts new file mode 100644 index 00000000..d13bca4a --- /dev/null +++ b/services/api/src/services/erasure-repository.ts @@ -0,0 +1,17 @@ +import type { Db } from "../db/client.js" + +export type DbTransaction = Parameters[0]>[0] + +export interface TransferredEvent extends Record { + cleanup_id: string + new_organizer: string + title: string +} + +export interface ErasureRepository { + transferHostedEvents(tx: DbTransaction, userId: string): Promise + releaseOrganizations(tx: DbTransaction, userId: string): Promise + scrubAttendeeContributions(tx: DbTransaction, userId: string): Promise + scrubModerationSnapshots(tx: DbTransaction, userId: string): Promise + purgeVerificationDocuments(tx: DbTransaction, userId: string): Promise +} diff --git a/services/api/src/services/geocode-cache-repository.drizzle.ts b/services/api/src/services/geocode-cache-repository.drizzle.ts index d6d022fb..423b63f7 100644 --- a/services/api/src/services/geocode-cache-repository.drizzle.ts +++ b/services/api/src/services/geocode-cache-repository.drizzle.ts @@ -1,19 +1,5 @@ -import type { AddressPrecision } from "@civfix/shared" import type { Queryable } from "../db/client.js" -import type { GeocodeCacheEntry } from "./geocode-cache.js" - -export interface GeocodeCacheRowSelect { - address: string | null - address_precision: AddressPrecision | null - city_state_label: string | null - provider: string | null - resolved_at: Date -} - -export interface GeocodeCacheRepository { - findByPointKey(pointKey: string): Promise - upsert(pointKey: string, entry: GeocodeCacheEntry, now: () => Date): Promise -} +import type { GeocodeCacheRepository, GeocodeCacheRowSelect } from "./geocode-cache-repository.js" export function makeDrizzleGeocodeCacheRepository(sql: Queryable): GeocodeCacheRepository { return { diff --git a/services/api/src/services/geocode-cache-repository.ts b/services/api/src/services/geocode-cache-repository.ts new file mode 100644 index 00000000..f8955b69 --- /dev/null +++ b/services/api/src/services/geocode-cache-repository.ts @@ -0,0 +1,15 @@ +import type { AddressPrecision } from "@civfix/shared" +import type { GeocodeCacheEntry } from "./geocode-cache.js" + +export interface GeocodeCacheRowSelect { + address: string | null + address_precision: AddressPrecision | null + city_state_label: string | null + provider: string | null + resolved_at: Date +} + +export interface GeocodeCacheRepository { + findByPointKey(pointKey: string): Promise + upsert(pointKey: string, entry: GeocodeCacheEntry, now: () => Date): Promise +} diff --git a/services/api/src/services/host/admin-pages-repository.drizzle.ts b/services/api/src/services/host/admin-pages-repository.drizzle.ts index aa4e12ba..632c77d6 100644 --- a/services/api/src/services/host/admin-pages-repository.drizzle.ts +++ b/services/api/src/services/host/admin-pages-repository.drizzle.ts @@ -1,49 +1,12 @@ import type { EventPageStatus, EventVisibility } from "@civfix/shared" import type { Queryable } from "../../db/client.js" -import { keysetInstant, keysetPredicate, type KeysetCursor } from "../../db/cursor-helpers.js" +import { keysetInstant, keysetPredicate } from "../../db/cursor-helpers.js" import { likeContains } from "../../db/like.js" - -export interface AdminEventPageRow { - cleanupId: string - slug: string | null - title: string - status: EventPageStatus - visibility: EventVisibility - organizerId: string | null - organizerName: string | null - organizerHandle: string | null - organizerJoined: Date | null - orgName: string | null - viewCount: number - publishedAt: Date | null - flaggedAt: Date | null - flagReason: string | null - flaggedById: string | null - flaggedByName: string | null - flaggedByHandle: string | null - flaggedByJoined: Date | null - /** COALESCE(published_at, updated_at) at microsecond precision, the list's keyset instant. */ - cursorAt: string - pageId: string -} - -export interface AdminEventPageListParams { - q?: string - status?: EventPageStatus - flagged?: boolean - cursor: KeysetCursor | null - limit: number -} - -export interface AdminEventPageRepository { - list(params: AdminEventPageListParams): Promise - get(cleanupId: string): Promise - setFlagged( - cleanupId: string, - input: { flagged: boolean; reason: string | null; operatorId: string }, - ): Promise - unpublish(cleanupId: string): Promise -} +import type { + AdminEventPageListParams, + AdminEventPageRepository, + AdminEventPageRow, +} from "./admin-pages-repository.js" interface PageRowSelect { page_id: string diff --git a/services/api/src/services/host/admin-pages-repository.ts b/services/api/src/services/host/admin-pages-repository.ts new file mode 100644 index 00000000..3c9bb3d4 --- /dev/null +++ b/services/api/src/services/host/admin-pages-repository.ts @@ -0,0 +1,44 @@ +import type { EventPageStatus, EventVisibility } from "@civfix/shared" +import type { KeysetCursor } from "../../db/cursor-helpers.js" + +export interface AdminEventPageRow { + cleanupId: string + slug: string | null + title: string + status: EventPageStatus + visibility: EventVisibility + organizerId: string | null + organizerName: string | null + organizerHandle: string | null + organizerJoined: Date | null + orgName: string | null + viewCount: number + publishedAt: Date | null + flaggedAt: Date | null + flagReason: string | null + flaggedById: string | null + flaggedByName: string | null + flaggedByHandle: string | null + flaggedByJoined: Date | null + /** COALESCE(published_at, updated_at) at microsecond precision, the list's keyset instant. */ + cursorAt: string + pageId: string +} + +export interface AdminEventPageListParams { + q?: string + status?: EventPageStatus + flagged?: boolean + cursor: KeysetCursor | null + limit: number +} + +export interface AdminEventPageRepository { + list(params: AdminEventPageListParams): Promise + get(cleanupId: string): Promise + setFlagged( + cleanupId: string, + input: { flagged: boolean; reason: string | null; operatorId: string }, + ): Promise + unpublish(cleanupId: string): Promise +} diff --git a/services/api/src/services/host/analytics-repository.drizzle.ts b/services/api/src/services/host/analytics-repository.drizzle.ts index 79a8ab47..2fbd8cce 100644 --- a/services/api/src/services/host/analytics-repository.drizzle.ts +++ b/services/api/src/services/host/analytics-repository.drizzle.ts @@ -1,9 +1,17 @@ import { MAX_HOST_SUMMARY_EVENT_ROWS } from "@civfix/shared" import type { BroadcastKind, CleanupStatus, RegistrationSource } from "@civfix/shared" -import type { DayCount, DayTimeCount, KeyCount } from "@civfix/shared/host" +import type { DayCount, KeyCount } from "@civfix/shared/host" import type { Sql } from "../../db/client.js" import { cleanupStatusExpr } from "../cleanup-sql.js" import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" +import type { + AnalyticsRepository, + EventKpiRow, + HeldEventTotals, + HostActivityTotals, + LabeledKeyCount, + PortfolioHoursTotals, +} from "./analytics-repository.js" export const INSIGHTS_TREND_LIMIT = 400 @@ -11,25 +19,6 @@ export const INSIGHTS_SOURCE_LIMIT = 10 const SUMMARY_DAY_ZONE = "UTC" -export interface EventKpiRow { - registered: number - checkedIn: number - waitlisted: number - cancelled: number - noShow: number - capacity: number | null -} - -export interface EventClockRecord { - status: CleanupStatus - createdAt: Date - scheduledAt: Date - endsAt: Date | null - completedAt: Date | null - registrationClosesAt: Date | null - timezone: string | null -} - interface EventClockRowSelect { status: CleanupStatus created_at: Date @@ -44,27 +33,6 @@ function eventClockColumns(tag: Sql) { return tag`${cleanupStatusExpr(tag)} AS status, c.created_at, c.scheduled_at, c.ends_at, c.completed_at, c.registration_closes_at, c.timezone` } -export interface SeatTrendPoint { - day: string - added: number - removed: number -} - -export interface SourceSeats { - source: RegistrationSource - seats: number -} - -export interface EventBroadcastRecord { - id: string - kind: BroadcastKind - finishedAt: Date | null - recipients: number - sent: number - failed: number - suppressed: number -} - interface EventBroadcastRowSelect { id: string kind: BroadcastKind @@ -79,53 +47,11 @@ function eventBroadcastColumns(tag: Sql) { return tag`id, kind, finished_at, recipient_count, sent_count, failed_count, suppressed_count` } -export interface EventHoursTotals { - credited: number - attendeesCredited: number - attendeesCheckedIn: number -} - -export interface TopVolunteerRow { - userId: string - name: string - handle: string | null - avatarUrl: string | null - hours: number -} - -export interface PortfolioHoursTotals { - credited: number - volunteersCredited: number -} - const ZERO_PORTFOLIO_HOURS_TOTALS: PortfolioHoursTotals = Object.freeze({ credited: 0, volunteersCredited: 0, }) -export interface ReturningAttendees { - seats: number - ofRegistered: number -} - -export interface PortfolioTotals { - events: number - registrations: number - checkIns: number - uniqueAttendees: number - repeatAttendees: number -} - -export interface HostActivityTotals { - registrations: number - cancellations: number - hoursTotal: number - hoursVolunteers: number - reportsLinked: number - reportsResolved: number - postsCreated: number -} - const ZERO_HOST_ACTIVITY_TOTALS: HostActivityTotals = Object.freeze({ registrations: 0, cancellations: 0, @@ -136,13 +62,6 @@ const ZERO_HOST_ACTIVITY_TOTALS: HostActivityTotals = Object.freeze({ postsCreated: 0, }) -export interface HeldEventTotals { - events: number - registered: number - checkedIn: number - noShow: number -} - const ZERO_HELD_EVENT_TOTALS: HeldEventTotals = Object.freeze({ events: 0, registered: 0, @@ -150,61 +69,6 @@ const ZERO_HELD_EVENT_TOTALS: HeldEventTotals = Object.freeze({ noShow: 0, }) -export interface LabeledKeyCount extends KeyCount { - label: string -} - -export interface HostSummarySignups { - daily: DayCount[] - byEvent: LabeledKeyCount[] - hoursByEvent: LabeledKeyCount[] -} - -export interface AnalyticsRepository { - eventKpis(cleanupId: string): Promise - registrationsByDay( - cleanupId: string, - timezone: string, - from: string, - to: string, - ): Promise - cancellationsByDay( - cleanupId: string, - timezone: string, - from: string, - to: string, - ): Promise - registrationsByTicketType(cleanupId: string): Promise - registrationsByAudience(cleanupId: string): Promise - checkinsByTicketType(cleanupId: string): Promise - checkinsBySlot(cleanupId: string): Promise - arrivalOffsets(cleanupId: string, limit: number): Promise - waitlistConversion(cleanupId: string): Promise<{ promoted: number; joined: number }> - hostedEventIds(userId: string, organizationId: string | null, limit: number): Promise - portfolioTotals(cleanupIds: readonly string[]): Promise - portfolioByEvent(cleanupIds: readonly string[], limit: number): Promise - portfolioDayTime(cleanupIds: readonly string[]): Promise - broadcastsSent(cleanupId: string, timezone: string, from: string, to: string): Promise - eventClock(cleanupId: string): Promise - seatTrend(cleanupId: string, timezone: string): Promise - registrationsBySource(cleanupId: string): Promise - broadcastsForEvent(cleanupId: string, limit: number): Promise - eventHoursTotals(cleanupId: string): Promise - topVolunteers(cleanupIds: readonly string[], limit: number): Promise - hoursTotals(cleanupIds: readonly string[]): Promise - returningAttendees( - cleanupId: string, - hostedEventIds: readonly string[], - ): Promise - activityTotals(cleanupIds: readonly string[], from: Date, to: Date): Promise - heldEventTotals(cleanupIds: readonly string[], from: Date, to: Date): Promise - signupsByDayAcross( - cleanupIds: readonly string[], - fromDay: string, - toDay: string, - ): Promise -} - function dayCounts(rows: { day: string; n: string }[]): DayCount[] { return rows.map((row) => ({ day: row.day, count: Number(row.n) })) } diff --git a/services/api/src/services/host/analytics-repository.ts b/services/api/src/services/host/analytics-repository.ts new file mode 100644 index 00000000..36f1aff1 --- /dev/null +++ b/services/api/src/services/host/analytics-repository.ts @@ -0,0 +1,146 @@ +import type { BroadcastKind, CleanupStatus, RegistrationSource } from "@civfix/shared" +import type { DayCount, DayTimeCount, KeyCount } from "@civfix/shared/host" + +export interface EventKpiRow { + registered: number + checkedIn: number + waitlisted: number + cancelled: number + noShow: number + capacity: number | null +} + +export interface EventClockRecord { + status: CleanupStatus + createdAt: Date + scheduledAt: Date + endsAt: Date | null + completedAt: Date | null + registrationClosesAt: Date | null + timezone: string | null +} + +export interface SeatTrendPoint { + day: string + added: number + removed: number +} + +export interface SourceSeats { + source: RegistrationSource + seats: number +} + +export interface EventBroadcastRecord { + id: string + kind: BroadcastKind + finishedAt: Date | null + recipients: number + sent: number + failed: number + suppressed: number +} + +export interface EventHoursTotals { + credited: number + attendeesCredited: number + attendeesCheckedIn: number +} + +export interface TopVolunteerRow { + userId: string + name: string + handle: string | null + avatarUrl: string | null + hours: number +} + +export interface PortfolioHoursTotals { + credited: number + volunteersCredited: number +} + +export interface ReturningAttendees { + seats: number + ofRegistered: number +} + +export interface PortfolioTotals { + events: number + registrations: number + checkIns: number + uniqueAttendees: number + repeatAttendees: number +} + +export interface HostActivityTotals { + registrations: number + cancellations: number + hoursTotal: number + hoursVolunteers: number + reportsLinked: number + reportsResolved: number + postsCreated: number +} + +export interface HeldEventTotals { + events: number + registered: number + checkedIn: number + noShow: number +} + +export interface LabeledKeyCount extends KeyCount { + label: string +} + +export interface HostSummarySignups { + daily: DayCount[] + byEvent: LabeledKeyCount[] + hoursByEvent: LabeledKeyCount[] +} + +export interface AnalyticsRepository { + eventKpis(cleanupId: string): Promise + registrationsByDay( + cleanupId: string, + timezone: string, + from: string, + to: string, + ): Promise + cancellationsByDay( + cleanupId: string, + timezone: string, + from: string, + to: string, + ): Promise + registrationsByTicketType(cleanupId: string): Promise + registrationsByAudience(cleanupId: string): Promise + checkinsByTicketType(cleanupId: string): Promise + checkinsBySlot(cleanupId: string): Promise + arrivalOffsets(cleanupId: string, limit: number): Promise + waitlistConversion(cleanupId: string): Promise<{ promoted: number; joined: number }> + hostedEventIds(userId: string, organizationId: string | null, limit: number): Promise + portfolioTotals(cleanupIds: readonly string[]): Promise + portfolioByEvent(cleanupIds: readonly string[], limit: number): Promise + portfolioDayTime(cleanupIds: readonly string[]): Promise + broadcastsSent(cleanupId: string, timezone: string, from: string, to: string): Promise + eventClock(cleanupId: string): Promise + seatTrend(cleanupId: string, timezone: string): Promise + registrationsBySource(cleanupId: string): Promise + broadcastsForEvent(cleanupId: string, limit: number): Promise + eventHoursTotals(cleanupId: string): Promise + topVolunteers(cleanupIds: readonly string[], limit: number): Promise + hoursTotals(cleanupIds: readonly string[]): Promise + returningAttendees( + cleanupId: string, + hostedEventIds: readonly string[], + ): Promise + activityTotals(cleanupIds: readonly string[], from: Date, to: Date): Promise + heldEventTotals(cleanupIds: readonly string[], from: Date, to: Date): Promise + signupsByDayAcross( + cleanupIds: readonly string[], + fromDay: string, + toDay: string, + ): Promise +} diff --git a/services/api/src/services/host/analytics-service.ts b/services/api/src/services/host/analytics-service.ts index ec12320b..5418926e 100644 --- a/services/api/src/services/host/analytics-service.ts +++ b/services/api/src/services/host/analytics-service.ts @@ -29,9 +29,9 @@ import { type DayRange, type KeyCount, } from "@civfix/shared/host" -import type { AnalyticsRepository, LabeledKeyCount } from "./analytics-repository.drizzle.js" +import type { AnalyticsRepository, LabeledKeyCount } from "./analytics-repository.js" +import type { MetricRow, MetricsRepository } from "./metrics-repository.js" import { leaderboardEntryOf } from "../volunteer-hours-service.js" -import type { MetricRow, MetricsRepository } from "./metrics-repository.drizzle.js" import { hostAnalyticsCacheKey, type HostAnalyticsCache } from "./host-analytics-cache.js" import { eventDayKey } from "./event-day.js" import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" diff --git a/services/api/src/services/host/announcement-identity-repository.drizzle.ts b/services/api/src/services/host/announcement-identity-repository.drizzle.ts index bbcf287e..d48d42ce 100644 --- a/services/api/src/services/host/announcement-identity-repository.drizzle.ts +++ b/services/api/src/services/host/announcement-identity-repository.drizzle.ts @@ -5,14 +5,10 @@ import { PRESIGN_CONCURRENCY } from "../media-presign.js" import { presentIds } from "../present-ids.js" import { publicAuthorIdentity } from "../public-author.js" import { publicServedKeyExpr } from "../media-served-key.js" +import type { AnnouncementIdentityRepository } from "./announcement-identity-repository.js" export type AnnouncementImagePresigner = (r2Key: string) => Promise -export interface AnnouncementIdentityRepository { - authorsFor(userIds: readonly (string | null)[]): Promise> - organizationFor(cleanupId: string): Promise -} - interface AuthorRowSelect { id: string display_name: string | null diff --git a/services/api/src/services/host/announcement-identity-repository.ts b/services/api/src/services/host/announcement-identity-repository.ts new file mode 100644 index 00000000..181b1674 --- /dev/null +++ b/services/api/src/services/host/announcement-identity-repository.ts @@ -0,0 +1,6 @@ +import type { OrganizationRefDTO, PersonDTO } from "@civfix/shared" + +export interface AnnouncementIdentityRepository { + authorsFor(userIds: readonly (string | null)[]): Promise> + organizationFor(cleanupId: string): Promise +} diff --git a/services/api/src/services/host/announcement-service.ts b/services/api/src/services/host/announcement-service.ts index c8a1a954..9434fea7 100644 --- a/services/api/src/services/host/announcement-service.ts +++ b/services/api/src/services/host/announcement-service.ts @@ -22,7 +22,7 @@ import type { BroadcastRecord } from "./broadcast-types.js" import { ANNOUNCEMENT_VISIBLE_STATUSES } from "./broadcast-types.js" import { announcementPath, broadcastLinkWarnings } from "./broadcast-render.js" import type { BroadcastConfig, BroadcastService } from "./broadcast-service.js" -import type { AnnouncementIdentityRepository } from "./announcement-identity-repository.drizzle.js" +import type { AnnouncementIdentityRepository } from "./announcement-identity-repository.js" const ANNOUNCEMENT_DEFAULT_LIMIT = 20 const ANNOUNCEMENT_MAX_LIMIT = 50 diff --git a/services/api/src/services/host/authz.ts b/services/api/src/services/host/authz.ts index 8c59dade..72fa77f6 100644 --- a/services/api/src/services/host/authz.ts +++ b/services/api/src/services/host/authz.ts @@ -6,11 +6,8 @@ import { } from "@civfix/shared" import { can, hostCapabilities, type HostStanding } from "@civfix/shared/host" import type { Queryable } from "../../db/client.js" -import { - hostStandingOf, - orgStandingOf, - type HostStandingResolution, -} from "./host-standing-repository.drizzle.js" +import { hostStandingOf, orgStandingOf } from "./host-standing-repository.drizzle.js" +import type { HostStandingResolution } from "./host-standing-repository.js" const FORBIDDEN_COPY: Record = { view_event_private: "Only the event team can view this.", diff --git a/services/api/src/services/host/broadcast-audience-repository.drizzle.ts b/services/api/src/services/host/broadcast-audience-repository.drizzle.ts index a35bde71..5011b2fb 100644 --- a/services/api/src/services/host/broadcast-audience-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-audience-repository.drizzle.ts @@ -2,6 +2,7 @@ import type { BroadcastKind, BroadcastSegment } from "@civfix/shared" import type { Queryable } from "../../db/client.js" import { CRITICAL_BROADCAST_KINDS, HOST_COMPOSED_BROADCAST_KINDS } from "./broadcast-types.js" import type { AudiencePageQuery } from "./broadcast-repository.js" +import type { BroadcastAudienceRepository } from "./broadcast-audience-repository.js" // Sorts before every real id, so a first page starts at the beginning of the keyset. const FIRST_UUID = "00000000-0000-0000-0000-000000000000" @@ -34,10 +35,6 @@ export async function listGuestAudiencePage( return rows.map((row) => row.id) } -export interface BroadcastAudienceRepository { - audiencePage(query: AudiencePageQuery): Promise<{ members: string[]; guests: string[] }> -} - export function makeDrizzleBroadcastAudienceRepository( sql: Queryable, ): BroadcastAudienceRepository { diff --git a/services/api/src/services/host/broadcast-audience-repository.ts b/services/api/src/services/host/broadcast-audience-repository.ts new file mode 100644 index 00000000..3f7f6c66 --- /dev/null +++ b/services/api/src/services/host/broadcast-audience-repository.ts @@ -0,0 +1,5 @@ +import type { AudiencePageQuery } from "./broadcast-repository.js" + +export interface BroadcastAudienceRepository { + audiencePage(query: AudiencePageQuery): Promise<{ members: string[]; guests: string[] }> +} diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index b1ca506e..9a6e3c33 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -27,10 +27,8 @@ import { import { makeBroadcastPipeline, type BroadcastPipeline } from "./broadcast-pipeline.js" import { makeBroadcastLanes, type BroadcastLanes } from "./broadcast-lanes.js" import { BROADCAST_CHUNK_JOB, BROADCAST_PLAN_JOB } from "../../lib/queue-names.js" -import { - makeDrizzleMetricsRepository, - type MetricsRepository, -} from "./metrics-repository.drizzle.js" +import { makeDrizzleMetricsRepository } from "./metrics-repository.drizzle.js" +import type { MetricsRepository } from "./metrics-repository.js" import { makeMetricsService, type MetricsService } from "./metrics-service.js" import { makeDrizzleHostExportRepository } from "./export-repository.drizzle.js" import { diff --git a/services/api/src/services/host/event-analytics-repository.drizzle.ts b/services/api/src/services/host/event-analytics-repository.drizzle.ts index bd924c0d..6f63f9f5 100644 --- a/services/api/src/services/host/event-analytics-repository.drizzle.ts +++ b/services/api/src/services/host/event-analytics-repository.drizzle.ts @@ -1,15 +1,13 @@ import type { KeyCount } from "@civfix/shared/host" import type { Sql, SqlFragment } from "../../db/client.js" +import type { + EventAnalyticsFacts, + EventAnalyticsRepository, + EventComparisonMedians, +} from "./event-analytics-repository.js" const EVENT_ANALYTICS_SLOT_ROW_LIMIT = 50 -export interface EventAnalyticsFacts { - walkUps: number - reportsLinked: number - reportsResolved: number - postsCreated: number -} - const ZERO_EVENT_ANALYTICS_FACTS: EventAnalyticsFacts = Object.freeze({ walkUps: 0, reportsLinked: 0, @@ -17,28 +15,6 @@ const ZERO_EVENT_ANALYTICS_FACTS: EventAnalyticsFacts = Object.freeze({ postsCreated: 0, }) -export interface EventComparisonMedians { - sampleSize: number - signups: number | null - checkInRate: number | null - hoursPerVolunteer: number | null - fillRate: number | null -} - -export interface EventAnalyticsRepository { - previousCompletedEventIds(args: { - userId: string - organizationId: string | null - excludeCleanupId: string - limit: number - }): Promise - facts(cleanupId: string): Promise - registrationsBySlot(cleanupId: string): Promise - reportStatuses(cleanupId: string): Promise - hoursBuckets(cleanupId: string): Promise - comparisonMedians(cleanupIds: readonly string[]): Promise -} - const EMPTY_COMPARISON_MEDIANS: EventComparisonMedians = Object.freeze({ sampleSize: 0, signups: null, diff --git a/services/api/src/services/host/event-analytics-repository.ts b/services/api/src/services/host/event-analytics-repository.ts new file mode 100644 index 00000000..dde88763 --- /dev/null +++ b/services/api/src/services/host/event-analytics-repository.ts @@ -0,0 +1,30 @@ +import type { KeyCount } from "@civfix/shared/host" + +export interface EventAnalyticsFacts { + walkUps: number + reportsLinked: number + reportsResolved: number + postsCreated: number +} + +export interface EventComparisonMedians { + sampleSize: number + signups: number | null + checkInRate: number | null + hoursPerVolunteer: number | null + fillRate: number | null +} + +export interface EventAnalyticsRepository { + previousCompletedEventIds(args: { + userId: string + organizationId: string | null + excludeCleanupId: string + limit: number + }): Promise + facts(cleanupId: string): Promise + registrationsBySlot(cleanupId: string): Promise + reportStatuses(cleanupId: string): Promise + hoursBuckets(cleanupId: string): Promise + comparisonMedians(cleanupIds: readonly string[]): Promise +} diff --git a/services/api/src/services/host/event-analytics-service.ts b/services/api/src/services/host/event-analytics-service.ts index 00a7fbe2..20644bd1 100644 --- a/services/api/src/services/host/event-analytics-service.ts +++ b/services/api/src/services/host/event-analytics-service.ts @@ -31,12 +31,9 @@ import type { EventHoursTotals, EventKpiRow, SourceSeats, -} from "./analytics-repository.drizzle.js" -import type { - EventAnalyticsFacts, - EventAnalyticsRepository, -} from "./event-analytics-repository.drizzle.js" -import type { MetricRow, MetricsRepository } from "./metrics-repository.drizzle.js" +} from "./analytics-repository.js" +import type { EventAnalyticsFacts, EventAnalyticsRepository } from "./event-analytics-repository.js" +import type { MetricRow, MetricsRepository } from "./metrics-repository.js" import { hostAnalyticsCacheKey, perViewerScope, diff --git a/services/api/src/services/host/export-repository.drizzle.ts b/services/api/src/services/host/export-repository.drizzle.ts index 7befff4b..15c3f0eb 100644 --- a/services/api/src/services/host/export-repository.drizzle.ts +++ b/services/api/src/services/host/export-repository.drizzle.ts @@ -1,73 +1,14 @@ import type { HostExportKind, HostExportStatus } from "@civfix/shared" import type { Sql } from "../../db/client.js" -import type { WriteAuditInput } from "../admin/audit.js" import { insertAuditRow } from "../admin/audit-repository.drizzle.js" - -export interface HostExportRecord { - id: string - cleanupId: string | null - organizationId: string | null - requestedBy: string - kind: HostExportKind - filters: Record - status: HostExportStatus - r2Key: string | null - rowCount: number | null - byteSize: number | null - truncated: boolean - errorCode: string | null - runToken: string | null - requestedAt: Date - startedAt: Date | null - completedAt: Date | null - expiresAt: Date | null -} - -export interface HostExportRepository { - create( - input: { - cleanupId: string | null - organizationId: string | null - requestedBy: string - kind: HostExportKind - filters: Record - }, - audit?: (exportId: string) => WriteAuditInput, - ): Promise - findById(exportId: string): Promise - listForEvent(cleanupId: string, limit: number): Promise - listForOrganization(organizationId: string, limit: number): Promise - claimForRun(exportId: string, staleBefore: Date): Promise - /** - * Records the run's object key. `replaces` names the key the row must still hold (null for none): a - * re-claimed row keeps the crashed run's key, and overwriting it unseen would orphan that object. - */ - recordObjectKey( - exportId: string, - args: { r2Key: string; runToken: string | null; replaces: string | null }, - ): Promise - markReady( - exportId: string, - args: { - r2Key: string - rowCount: number - byteSize: number - truncated: boolean - expiresAt: Date - runToken: string | null - }, - ): Promise - /** Fails the run holding `runToken`; the row keeps any object key for the reaper to delete. */ - markFailed(exportId: string, errorCode: string, runToken: string | null): Promise - listExpired(now: Date, limit: number): Promise - markExpired(exportId: string): Promise - listOrphaned(args: { staleBefore: Date; limit: number }): Promise - releaseObject( - record: Pick, - errorCode: string, - ): Promise - deleteOlderThan(cutoff: Date, batchSize: number): Promise -} +import type { + HostExportAnswerRow, + HostExportCheckinRow, + HostExportRecord, + HostExportRepository, + HostExportRosterRow, + HostExportRowsRepository, +} from "./export-repository.js" interface RowSelect { id: string @@ -266,52 +207,6 @@ export function makeDrizzleHostExportRepository(sql: Sql): HostExportRepository } } -export interface HostExportRosterRow { - registration_id: string - attendee_name: string | null - attendee_kind: string - ticket_type: string | null - seats: number - slot: string | null - status: string - registered_at: Date - checked_in_at: Date | null - checkin_method: string | null - guest_email: string | null - guest_phone: string | null -} - -export interface HostExportCheckinRow { - id: string - attendee_name: string | null - attendee_kind: string - ticket_type: string | null - checked_in_at: Date | null - checkin_method: string | null - no_show_at: Date | null -} - -export interface HostExportAnswerRow { - id: string - registration_id: string - attendee_kind: string - prompt: string - value_text: string | null - value_json: unknown - scrubbed_at: Date | null - created_at: Date -} - -export interface HostExportRowsRepository { - rosterPage(cleanupId: string | null, after: string, limit: number): Promise - checkinPage( - cleanupId: string | null, - after: string, - limit: number, - ): Promise - answerPage(cleanupId: string | null, after: string, limit: number): Promise -} - export function makeDrizzleHostExportRowsRepository(sql: Sql): HostExportRowsRepository { return { async rosterPage(cleanupId, after, limit) { diff --git a/services/api/src/services/host/export-repository.ts b/services/api/src/services/host/export-repository.ts new file mode 100644 index 00000000..290bb190 --- /dev/null +++ b/services/api/src/services/host/export-repository.ts @@ -0,0 +1,114 @@ +import type { HostExportKind, HostExportStatus } from "@civfix/shared" +import type { WriteAuditInput } from "../admin/audit.js" + +export interface HostExportRecord { + id: string + cleanupId: string | null + organizationId: string | null + requestedBy: string + kind: HostExportKind + filters: Record + status: HostExportStatus + r2Key: string | null + rowCount: number | null + byteSize: number | null + truncated: boolean + errorCode: string | null + runToken: string | null + requestedAt: Date + startedAt: Date | null + completedAt: Date | null + expiresAt: Date | null +} + +export interface HostExportRepository { + create( + input: { + cleanupId: string | null + organizationId: string | null + requestedBy: string + kind: HostExportKind + filters: Record + }, + audit?: (exportId: string) => WriteAuditInput, + ): Promise + findById(exportId: string): Promise + listForEvent(cleanupId: string, limit: number): Promise + listForOrganization(organizationId: string, limit: number): Promise + claimForRun(exportId: string, staleBefore: Date): Promise + /** + * Records the run's object key. `replaces` names the key the row must still hold (null for none): a + * re-claimed row keeps the crashed run's key, and overwriting it unseen would orphan that object. + */ + recordObjectKey( + exportId: string, + args: { r2Key: string; runToken: string | null; replaces: string | null }, + ): Promise + markReady( + exportId: string, + args: { + r2Key: string + rowCount: number + byteSize: number + truncated: boolean + expiresAt: Date + runToken: string | null + }, + ): Promise + /** Fails the run holding `runToken`; the row keeps any object key for the reaper to delete. */ + markFailed(exportId: string, errorCode: string, runToken: string | null): Promise + listExpired(now: Date, limit: number): Promise + markExpired(exportId: string): Promise + listOrphaned(args: { staleBefore: Date; limit: number }): Promise + releaseObject( + record: Pick, + errorCode: string, + ): Promise + deleteOlderThan(cutoff: Date, batchSize: number): Promise +} + +export interface HostExportRosterRow { + registration_id: string + attendee_name: string | null + attendee_kind: string + ticket_type: string | null + seats: number + slot: string | null + status: string + registered_at: Date + checked_in_at: Date | null + checkin_method: string | null + guest_email: string | null + guest_phone: string | null +} + +export interface HostExportCheckinRow { + id: string + attendee_name: string | null + attendee_kind: string + ticket_type: string | null + checked_in_at: Date | null + checkin_method: string | null + no_show_at: Date | null +} + +export interface HostExportAnswerRow { + id: string + registration_id: string + attendee_kind: string + prompt: string + value_text: string | null + value_json: unknown + scrubbed_at: Date | null + created_at: Date +} + +export interface HostExportRowsRepository { + rosterPage(cleanupId: string | null, after: string, limit: number): Promise + checkinPage( + cleanupId: string | null, + after: string, + limit: number, + ): Promise + answerPage(cleanupId: string | null, after: string, limit: number): Promise +} diff --git a/services/api/src/services/host/export-service.ts b/services/api/src/services/host/export-service.ts index 9ed0e66a..fceb2b05 100644 --- a/services/api/src/services/host/export-service.ts +++ b/services/api/src/services/host/export-service.ts @@ -8,7 +8,7 @@ import { type HostExportBuilder, type HostExportContext, } from "./export-builders.js" -import type { HostExportRecord, HostExportRepository } from "./export-repository.drizzle.js" +import type { HostExportRecord, HostExportRepository } from "./export-repository.js" import type { WriteAuditInput } from "../admin/audit.js" import { MS_PER_HOUR, MS_PER_SECOND } from "../../lib/time.js" diff --git a/services/api/src/services/host/host-analytics-shaping.ts b/services/api/src/services/host/host-analytics-shaping.ts index 47f1f7ae..829a8924 100644 --- a/services/api/src/services/host/host-analytics-shaping.ts +++ b/services/api/src/services/host/host-analytics-shaping.ts @@ -14,8 +14,8 @@ import { type DerivedPanel, type SeriesClosure, } from "@civfix/shared/host" -import type { EventClockRecord } from "./analytics-repository.drizzle.js" -import type { MetricRow } from "./metrics-repository.drizzle.js" +import type { EventClockRecord } from "./analytics-repository.js" +import type { MetricRow } from "./metrics-repository.js" import { MS_PER_DAY } from "../../lib/time.js" export const PORTFOLIO_EVENT_LIMIT = 200 diff --git a/services/api/src/services/host/host-export-builders.ts b/services/api/src/services/host/host-export-builders.ts index 87b4fd7c..a1c23137 100644 --- a/services/api/src/services/host/host-export-builders.ts +++ b/services/api/src/services/host/host-export-builders.ts @@ -1,9 +1,7 @@ import type { Sql } from "../../db/client.js" import { registerHostExportBuilder, type HostExportContext } from "./export-builders.js" -import { - makeDrizzleHostExportRowsRepository, - type HostExportRowsRepository, -} from "./export-repository.drizzle.js" +import { makeDrizzleHostExportRowsRepository } from "./export-repository.drizzle.js" +import type { HostExportRowsRepository } from "./export-repository.js" const EXPORT_PAGE_SIZE = 1000 const SHORT_REF_LENGTH = 8 diff --git a/services/api/src/services/host/host-portfolio-repository.drizzle.ts b/services/api/src/services/host/host-portfolio-repository.drizzle.ts index 5bbce136..0a0b1a7b 100644 --- a/services/api/src/services/host/host-portfolio-repository.drizzle.ts +++ b/services/api/src/services/host/host-portfolio-repository.drizzle.ts @@ -9,30 +9,13 @@ import type { Queryable, Sql } from "../../db/client.js" import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" import { publicServedKeyExpr } from "../media-served-key.js" import { cleanupStatusExpr } from "../cleanup-sql.js" - -export interface HostedEventRecord { - id: string - referenceCode: string | null - title: string - startsAt: Date - endsAt: Date | null - timezone: string | null - status: CleanupStatus - visibility: EventVisibility - coverKey: string | null - capacity: number | null - eventRole: CleanupMemberRole | null - orgRole: OrganizationMemberRole | null - orgId: string | null - orgName: string | null - pageSlug: string | null - pageStatus: EventPageStatus | null -} - -export interface HostPortfolioKpiRecord { - eventsHosted: number - upcomingEvents: number -} +import type { + HostPortfolioKpiRecord, + HostPortfolioKpisArgs, + HostPortfolioRepository, + HostedEventRecord, + ListHostedEventsArgs, +} from "./host-portfolio-repository.js" export interface HostPortfolioTotals { totalRegistrations: number @@ -44,27 +27,6 @@ export interface HostPortfolioTotalsArgs { organizationId: string | null } -export interface ListHostedEventsArgs { - userId: string - when: "upcoming" | "past" | "all" - organizationId: string | null - cursor: string | null - limit: number -} - -export interface HostPortfolioKpisArgs { - userId: string - organizationId: string | null - now: Date -} - -export interface HostPortfolioRepository { - listHostedEvents( - args: ListHostedEventsArgs, - ): Promise<{ items: HostedEventRecord[]; nextCursor: string | null }> - kpisFor(args: HostPortfolioKpisArgs): Promise -} - interface HostedEventRowSelect { id: string reference_code: string | null diff --git a/services/api/src/services/host/host-portfolio-repository.ts b/services/api/src/services/host/host-portfolio-repository.ts new file mode 100644 index 00000000..4bed4a92 --- /dev/null +++ b/services/api/src/services/host/host-portfolio-repository.ts @@ -0,0 +1,52 @@ +import type { + CleanupMemberRole, + CleanupStatus, + EventPageStatus, + EventVisibility, + OrganizationMemberRole, +} from "@civfix/shared" + +export interface HostedEventRecord { + id: string + referenceCode: string | null + title: string + startsAt: Date + endsAt: Date | null + timezone: string | null + status: CleanupStatus + visibility: EventVisibility + coverKey: string | null + capacity: number | null + eventRole: CleanupMemberRole | null + orgRole: OrganizationMemberRole | null + orgId: string | null + orgName: string | null + pageSlug: string | null + pageStatus: EventPageStatus | null +} + +export interface HostPortfolioKpiRecord { + eventsHosted: number + upcomingEvents: number +} + +export interface ListHostedEventsArgs { + userId: string + when: "upcoming" | "past" | "all" + organizationId: string | null + cursor: string | null + limit: number +} + +export interface HostPortfolioKpisArgs { + userId: string + organizationId: string | null + now: Date +} + +export interface HostPortfolioRepository { + listHostedEvents( + args: ListHostedEventsArgs, + ): Promise<{ items: HostedEventRecord[]; nextCursor: string | null }> + kpisFor(args: HostPortfolioKpisArgs): Promise +} diff --git a/services/api/src/services/host/host-portfolio-service.ts b/services/api/src/services/host/host-portfolio-service.ts index ab2f5052..6f1f56e1 100644 --- a/services/api/src/services/host/host-portfolio-service.ts +++ b/services/api/src/services/host/host-portfolio-service.ts @@ -2,11 +2,10 @@ import type { HostedEventDTO, HostPortfolioKpis, ListMyHostedEventsResponse } fr import { hostCapabilities, NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" import type { EventMediaPresigner } from "./event-media.js" import type { - HostedEventRecord, - HostPortfolioRepository, HostPortfolioTotals, HostPortfolioTotalsArgs, } from "./host-portfolio-repository.drizzle.js" +import type { HostPortfolioRepository, HostedEventRecord } from "./host-portfolio-repository.js" import { ZERO_HOSTED_EVENT_COUNTS, type HostedEventCounts } from "./portfolio-counts.js" import { isEventPubliclyVisible } from "./authz.js" import { mapWithLimit } from "../../lib/concurrency.js" diff --git a/services/api/src/services/host/host-standing-repository.drizzle.ts b/services/api/src/services/host/host-standing-repository.drizzle.ts index 8bbc3f54..9c42ff23 100644 --- a/services/api/src/services/host/host-standing-repository.drizzle.ts +++ b/services/api/src/services/host/host-standing-repository.drizzle.ts @@ -1,14 +1,7 @@ import type { CleanupMemberRole, EventVisibility, OrganizationMemberRole } from "@civfix/shared" import { NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" import type { Queryable } from "../../db/client.js" - -export interface HostStandingResolution { - cleanupId: string - standing: HostStanding - organizerUserId: string - organizationId: string | null - visibility: EventVisibility -} +import type { HostStandingRepository, HostStandingResolution } from "./host-standing-repository.js" interface StandingRow { cleanup_id: string @@ -95,15 +88,6 @@ export async function orgStandingOf( return rows[0]?.role ?? null } -export interface HostStandingRepository { - standingOf(cleanupId: string, userId: string | null): Promise - standingsOf( - cleanupIds: readonly string[], - userId: string, - ): Promise> - orgRoleOf(organizationId: string, userId: string): Promise -} - export function makeDrizzleHostStandingRepository(sql: Queryable): HostStandingRepository { return { standingOf: (cleanupId, userId) => hostStandingOf(sql, cleanupId, userId), diff --git a/services/api/src/services/host/host-standing-repository.ts b/services/api/src/services/host/host-standing-repository.ts new file mode 100644 index 00000000..9ca6c1ed --- /dev/null +++ b/services/api/src/services/host/host-standing-repository.ts @@ -0,0 +1,19 @@ +import type { EventVisibility, OrganizationMemberRole } from "@civfix/shared" +import type { HostStanding } from "@civfix/shared/host" + +export interface HostStandingResolution { + cleanupId: string + standing: HostStanding + organizerUserId: string + organizationId: string | null + visibility: EventVisibility +} + +export interface HostStandingRepository { + standingOf(cleanupId: string, userId: string | null): Promise + standingsOf( + cleanupIds: readonly string[], + userId: string, + ): Promise> + orgRoleOf(organizationId: string, userId: string): Promise +} diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index d7557bb7..3d3d845c 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -31,7 +31,7 @@ import type { MessageKey } from "../../i18n/renderMessage.js" import type { CreateNotificationInput } from "../notification-service.js" import { assertMayGrantRole, isEventPubliclyVisible, requireCapability } from "./authz.js" import type { EventMediaPresigner } from "./event-media.js" -import type { HostStandingResolution } from "./host-standing-repository.drizzle.js" +import type { HostStandingResolution } from "./host-standing-repository.js" import type { EventTeamInviteRecord, EventTeamMemberRecord, diff --git a/services/api/src/services/host/insights-service.ts b/services/api/src/services/host/insights-service.ts index 07bdbfdd..d4644dfe 100644 --- a/services/api/src/services/host/insights-service.ts +++ b/services/api/src/services/host/insights-service.ts @@ -21,7 +21,7 @@ import type { AnalyticsRepository, EventClockRecord, SeatTrendPoint, -} from "./analytics-repository.drizzle.js" +} from "./analytics-repository.js" import { hostAnalyticsCacheKey, perViewerScope, diff --git a/services/api/src/services/host/metrics-repository.drizzle.ts b/services/api/src/services/host/metrics-repository.drizzle.ts index cca5b7df..54348294 100644 --- a/services/api/src/services/host/metrics-repository.drizzle.ts +++ b/services/api/src/services/host/metrics-repository.drizzle.ts @@ -1,40 +1,5 @@ import type { Sql } from "../../db/client.js" - -export interface MetricUpsert { - cleanupId: string - day: string - metric: string - bucket: string - value: number -} - -export interface MetricRow { - day: string - metric: string - bucket: string - value: number -} - -export interface MetricsRepository { - resolveSlug(slug: string): Promise<{ cleanupId: string; timezone: string | null } | null> - eventTimezone(cleanupId: string): Promise - listRollupEvents(since: Date, after: string | null, limit: number): Promise - recomputeFromSource(cleanupId: string, timezone: string, since: Date): Promise - upsertExact(rows: readonly MetricUpsert[]): Promise - upsertGreatest(rows: readonly MetricUpsert[]): Promise - read( - cleanupId: string, - metrics: readonly string[], - from: string, - to: string, - ): Promise - readMany( - cleanupIds: readonly string[], - metrics: readonly string[], - from: string, - to: string, - ): Promise -} +import type { MetricRow, MetricUpsert, MetricsRepository } from "./metrics-repository.js" interface MetricRowSelect { day: string diff --git a/services/api/src/services/host/metrics-repository.ts b/services/api/src/services/host/metrics-repository.ts new file mode 100644 index 00000000..926a0a65 --- /dev/null +++ b/services/api/src/services/host/metrics-repository.ts @@ -0,0 +1,35 @@ +export interface MetricUpsert { + cleanupId: string + day: string + metric: string + bucket: string + value: number +} + +export interface MetricRow { + day: string + metric: string + bucket: string + value: number +} + +export interface MetricsRepository { + resolveSlug(slug: string): Promise<{ cleanupId: string; timezone: string | null } | null> + eventTimezone(cleanupId: string): Promise + listRollupEvents(since: Date, after: string | null, limit: number): Promise + recomputeFromSource(cleanupId: string, timezone: string, since: Date): Promise + upsertExact(rows: readonly MetricUpsert[]): Promise + upsertGreatest(rows: readonly MetricUpsert[]): Promise + read( + cleanupId: string, + metrics: readonly string[], + from: string, + to: string, + ): Promise + readMany( + cleanupIds: readonly string[], + metrics: readonly string[], + from: string, + to: string, + ): Promise +} diff --git a/services/api/src/services/host/metrics-service.ts b/services/api/src/services/host/metrics-service.ts index 087517a5..fc1baf17 100644 --- a/services/api/src/services/host/metrics-service.ts +++ b/services/api/src/services/host/metrics-service.ts @@ -1,7 +1,7 @@ import type { PageViewSource } from "@civfix/shared" import type { FastifyBaseLogger } from "fastify" import type { CacheClient } from "../../auth/cache.js" -import type { MetricUpsert, MetricsRepository } from "./metrics-repository.drizzle.js" +import type { MetricUpsert, MetricsRepository } from "./metrics-repository.js" import { eventDayKey } from "./event-day.js" import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" import { diff --git a/services/api/src/services/host/organization-membership-repository.drizzle.ts b/services/api/src/services/host/organization-membership-repository.drizzle.ts index 4623253c..d5bd4b8a 100644 --- a/services/api/src/services/host/organization-membership-repository.drizzle.ts +++ b/services/api/src/services/host/organization-membership-repository.drizzle.ts @@ -1,9 +1,6 @@ import { sql } from "drizzle-orm" import type { Db } from "../../db/client.js" - -export interface OrganizationMembershipRepository { - isActiveMember(userId: string, organizationId: string): Promise -} +import type { OrganizationMembershipRepository } from "./organization-membership-repository.js" export function makeDrizzleOrganizationMembershipRepository( db: Db, diff --git a/services/api/src/services/host/organization-membership-repository.ts b/services/api/src/services/host/organization-membership-repository.ts new file mode 100644 index 00000000..5945bcf2 --- /dev/null +++ b/services/api/src/services/host/organization-membership-repository.ts @@ -0,0 +1,3 @@ +export interface OrganizationMembershipRepository { + isActiveMember(userId: string, organizationId: string): Promise +} diff --git a/services/api/src/services/host/registration-retention-repository.drizzle.ts b/services/api/src/services/host/registration-retention-repository.drizzle.ts index 56e9f56f..d6cd224d 100644 --- a/services/api/src/services/host/registration-retention-repository.drizzle.ts +++ b/services/api/src/services/host/registration-retention-repository.drizzle.ts @@ -1,11 +1,5 @@ import type { Sql } from "../../db/client.js" - -export interface RegistrationRetentionRepository { - scrubAnswers(cutoff: Date, now: Date, batchSize: number): Promise - coarsenCheckins(cutoff: Date, now: Date, batchSize: number): Promise - clearAttendeeNames(cutoff: Date, batchSize: number): Promise - clearHostNotes(cutoff: Date, batchSize: number): Promise -} +import type { RegistrationRetentionRepository } from "./registration-retention-repository.js" export function makeDrizzleRegistrationRetentionRepository( sql: Sql, diff --git a/services/api/src/services/host/registration-retention-repository.ts b/services/api/src/services/host/registration-retention-repository.ts new file mode 100644 index 00000000..ef04da4e --- /dev/null +++ b/services/api/src/services/host/registration-retention-repository.ts @@ -0,0 +1,6 @@ +export interface RegistrationRetentionRepository { + scrubAnswers(cutoff: Date, now: Date, batchSize: number): Promise + coarsenCheckins(cutoff: Date, now: Date, batchSize: number): Promise + clearAttendeeNames(cutoff: Date, batchSize: number): Promise + clearHostNotes(cutoff: Date, batchSize: number): Promise +} diff --git a/services/api/src/services/jurisdiction-repository.drizzle.ts b/services/api/src/services/jurisdiction-repository.drizzle.ts index a9a2fa37..8908122d 100644 --- a/services/api/src/services/jurisdiction-repository.drizzle.ts +++ b/services/api/src/services/jurisdiction-repository.drizzle.ts @@ -1,8 +1,8 @@ import type { Sql } from "../db/client.js" -import { resolveJurisdiction, type ResolvedJurisdiction } from "../db/sql/jurisdiction.js" +import { resolveJurisdiction } from "../db/sql/jurisdiction.js" import type { JurisdictionLookupResult } from "../adapters/jurisdiction-lookup.census.js" import { legacyContactEmailUsable } from "./admin/sql-fragments.js" -import type { JurisdictionHealthRow } from "./jurisdiction-service.js" +import type { JurisdictionRepository } from "./jurisdiction-repository.js" const LAYER_PRIORITY: Record = { place: 2, @@ -10,16 +10,6 @@ const LAYER_PRIORITY: Record = { state: 4, } -export interface JurisdictionRepository { - /** Takes (lng, lat), the ST_MakePoint order, like every method here. */ - resolveContaining(lng: number, lat: number): Promise - containingStateGeoid(lng: number, lat: number): Promise - exists(geoid: string): Promise - handleExists(handle: string): Promise - insertApiSourcedIfAbsent(hit: JurisdictionLookupResult): Promise - loadHealth(geoid: string): Promise -} - export function makeDrizzleJurisdictionRepository(sql: Sql): JurisdictionRepository { return { resolveContaining(lng, lat) { diff --git a/services/api/src/services/jurisdiction-repository.ts b/services/api/src/services/jurisdiction-repository.ts new file mode 100644 index 00000000..d038a377 --- /dev/null +++ b/services/api/src/services/jurisdiction-repository.ts @@ -0,0 +1,13 @@ +import type { JurisdictionLookupResult } from "../adapters/jurisdiction-lookup.census.js" +import type { ResolvedJurisdiction } from "../db/sql/jurisdiction.js" +import type { JurisdictionHealthRow } from "./jurisdiction-service.js" + +export interface JurisdictionRepository { + /** Takes (lng, lat), the ST_MakePoint order, like every method here. */ + resolveContaining(lng: number, lat: number): Promise + containingStateGeoid(lng: number, lat: number): Promise + exists(geoid: string): Promise + handleExists(handle: string): Promise + insertApiSourcedIfAbsent(hit: JurisdictionLookupResult): Promise + loadHealth(geoid: string): Promise +} diff --git a/services/api/src/services/jurisdiction-service.ts b/services/api/src/services/jurisdiction-service.ts index fe9a4a4c..0e6a7406 100644 --- a/services/api/src/services/jurisdiction-service.ts +++ b/services/api/src/services/jurisdiction-service.ts @@ -6,10 +6,8 @@ import type { JurisdictionLookup, JurisdictionLookupResult, } from "../adapters/jurisdiction-lookup.census.js" -import { - makeDrizzleJurisdictionRepository, - type JurisdictionRepository, -} from "./jurisdiction-repository.drizzle.js" +import { makeDrizzleJurisdictionRepository } from "./jurisdiction-repository.drizzle.js" +import type { JurisdictionRepository } from "./jurisdiction-repository.js" import { JURISDICTION_DISCOVERY_JOB } from "../lib/queue-names.js" const CONTACT_STALE_MONTHS = 18 diff --git a/services/api/src/services/media-authorization-repository.drizzle.ts b/services/api/src/services/media-authorization-repository.drizzle.ts index 8b9bb4b5..4fd0722b 100644 --- a/services/api/src/services/media-authorization-repository.drizzle.ts +++ b/services/api/src/services/media-authorization-repository.drizzle.ts @@ -1,63 +1,15 @@ import type { Sql } from "../db/client.js" import { eventsBindingMedia } from "./media-bindings.js" - -export interface DmMessageRef { - threadId: string - deletedAt: Date | null -} - -export interface DmMessageThread { - threadId: string -} - -export interface ChatMessageScope { - cleanupId: string | null - reportId: string | null - groupId: string | null - deletedAt: Date | null -} - -export interface ChatMessageRoom { - cleanupId: string | null - reportId: string | null - groupId: string | null -} - -export interface RoomAccess { - visibility: string - isMember: boolean -} - -export interface PostAccess { - authorId: string - visibility: string - deletedAt: Date | null -} - -export interface ReportAccess { - reporterUserId: string | null - status: string - visibility: string - deletedAt: Date | null -} - -// The media view authorizer and the content-report subject gate share these lookups on purpose: both ask -// whether a viewer may see the same thing, so tightening one lane must tighten the other. -export interface MediaAuthorizationRepository { - findDmMessage(messageId: string): Promise - findDmMessageThread(messageId: string): Promise - isDmParticipant(threadId: string, userId: string): Promise - findChatMessageScope(messageId: string): Promise - findChatMessageRoom(messageId: string): Promise - isCleanupMember(cleanupId: string, userId: string): Promise - findGroupAccess(groupId: string, userId: string): Promise - findEventAccess(mediaId: string, viewerId: string | null): Promise - isLiveOrgLogo(mediaId: string): Promise - findPostAccess(postId: string): Promise - findReportAccess(reportId: string): Promise - isAvatarMedia(mediaId: string): Promise - activeUserExists(userId: string): Promise -} +import type { + ChatMessageRoom, + ChatMessageScope, + DmMessageRef, + DmMessageThread, + MediaAuthorizationRepository, + PostAccess, + ReportAccess, + RoomAccess, +} from "./media-authorization-repository.js" export function makeDrizzleMediaAuthorizationRepository(sql: Sql): MediaAuthorizationRepository { return { diff --git a/services/api/src/services/media-authorization-repository.ts b/services/api/src/services/media-authorization-repository.ts new file mode 100644 index 00000000..b01e1f79 --- /dev/null +++ b/services/api/src/services/media-authorization-repository.ts @@ -0,0 +1,57 @@ +export interface DmMessageRef { + threadId: string + deletedAt: Date | null +} + +export interface DmMessageThread { + threadId: string +} + +export interface ChatMessageScope { + cleanupId: string | null + reportId: string | null + groupId: string | null + deletedAt: Date | null +} + +export interface ChatMessageRoom { + cleanupId: string | null + reportId: string | null + groupId: string | null +} + +export interface RoomAccess { + visibility: string + isMember: boolean +} + +export interface PostAccess { + authorId: string + visibility: string + deletedAt: Date | null +} + +export interface ReportAccess { + reporterUserId: string | null + status: string + visibility: string + deletedAt: Date | null +} + +// The media view authorizer and the content-report subject gate share these lookups on purpose: both ask +// whether a viewer may see the same thing, so tightening one lane must tighten the other. +export interface MediaAuthorizationRepository { + findDmMessage(messageId: string): Promise + findDmMessageThread(messageId: string): Promise + isDmParticipant(threadId: string, userId: string): Promise + findChatMessageScope(messageId: string): Promise + findChatMessageRoom(messageId: string): Promise + isCleanupMember(cleanupId: string, userId: string): Promise + findGroupAccess(groupId: string, userId: string): Promise + findEventAccess(mediaId: string, viewerId: string | null): Promise + isLiveOrgLogo(mediaId: string): Promise + findPostAccess(postId: string): Promise + findReportAccess(reportId: string): Promise + isAvatarMedia(mediaId: string): Promise + activeUserExists(userId: string): Promise +} diff --git a/services/api/src/services/media-authorization.ts b/services/api/src/services/media-authorization.ts index 4f18ce9c..e8740007 100644 --- a/services/api/src/services/media-authorization.ts +++ b/services/api/src/services/media-authorization.ts @@ -2,10 +2,8 @@ import type { Sql } from "../db/client.js" import type { MediaOwner } from "./media-intake-service.js" import type { MediaAssetView } from "./media-repository.js" import { isPubliclyVisibleStatus } from "./report-visibility.js" -import { - makeDrizzleMediaAuthorizationRepository, - type MediaAuthorizationRepository, -} from "./media-authorization-repository.drizzle.js" +import { makeDrizzleMediaAuthorizationRepository } from "./media-authorization-repository.drizzle.js" +import type { MediaAuthorizationRepository } from "./media-authorization-repository.js" export interface MediaAccessDecision { allowed: boolean diff --git a/services/api/src/services/message-attachments-repository.drizzle.ts b/services/api/src/services/message-attachments-repository.drizzle.ts index d3262255..64540eec 100644 --- a/services/api/src/services/message-attachments-repository.drizzle.ts +++ b/services/api/src/services/message-attachments-repository.drizzle.ts @@ -7,21 +7,12 @@ import { mapWithLimit } from "../lib/concurrency.js" import { PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { uploaderServableFilter, uploaderServedKeyExpr } from "./media-served-key.js" import { userUploader } from "./media-uploader.js" +import type { MessageAttachmentRepository } from "./message-attachments-repository.js" export type MessageMediaColumn = "chat_message_id" const CLAIM_GUARD_COLUMNS: readonly string[] = ["report_id", "post_id"] -export interface MessageAttachmentRepository { - attach( - tx: Queryable, - messageId: string, - uploadIds: string[], - messageCreatedAt: Date, - senderId: string, - ): Promise -} - interface MediaRow { id: string message_id: string diff --git a/services/api/src/services/message-attachments-repository.ts b/services/api/src/services/message-attachments-repository.ts new file mode 100644 index 00000000..dbef6008 --- /dev/null +++ b/services/api/src/services/message-attachments-repository.ts @@ -0,0 +1,11 @@ +import type { Queryable } from "../db/client.js" + +export interface MessageAttachmentRepository { + attach( + tx: Queryable, + messageId: string, + uploadIds: string[], + messageCreatedAt: Date, + senderId: string, + ): Promise +} diff --git a/services/api/src/services/message-mentions-repository.drizzle.ts b/services/api/src/services/message-mentions-repository.drizzle.ts index 16c8a050..7c77528f 100644 --- a/services/api/src/services/message-mentions-repository.drizzle.ts +++ b/services/api/src/services/message-mentions-repository.drizzle.ts @@ -1,5 +1,6 @@ import type { Queryable, Sql } from "../db/client.js" import type { UserMentionDTO } from "@civfix/shared" +import type { MessageMentionRepository } from "./message-mentions-repository.js" // `table` and `idColumn` are module-constant union literals, never user input, interpolated as postgres.js // identifiers. @@ -7,13 +8,6 @@ export type MentionTable = "chat_message_mentions" | "post_mentions" export type MentionIdColumn = "message_id" | "post_id" -export interface MessageMentionRepository { - // `mentionedUserIds` must already be deduped and self-excluded. Pass the create/edit tx so the - // delete-then-insert replace is atomic with the message write. - recordFor(tx: Queryable, messageId: string, mentionedUserIds: string[]): Promise - loadFor(messageIds: string[]): Promise> -} - export function makeMessageMentionRepository( sql: Sql, table: MentionTable, diff --git a/services/api/src/services/message-mentions-repository.ts b/services/api/src/services/message-mentions-repository.ts new file mode 100644 index 00000000..8f9c657d --- /dev/null +++ b/services/api/src/services/message-mentions-repository.ts @@ -0,0 +1,9 @@ +import type { UserMentionDTO } from "@civfix/shared" +import type { Queryable } from "../db/client.js" + +export interface MessageMentionRepository { + // `mentionedUserIds` must already be deduped and self-excluded. Pass the create/edit tx so the + // delete-then-insert replace is atomic with the message write. + recordFor(tx: Queryable, messageId: string, mentionedUserIds: string[]): Promise + loadFor(messageIds: string[]): Promise> +} diff --git a/services/api/src/services/message-reactions-repository.drizzle.ts b/services/api/src/services/message-reactions-repository.drizzle.ts index 14eedfb6..8bd6e7cb 100644 --- a/services/api/src/services/message-reactions-repository.drizzle.ts +++ b/services/api/src/services/message-reactions-repository.drizzle.ts @@ -1,18 +1,11 @@ import type { Queryable, Sql } from "../db/client.js" import type { ReactionEmoji, ReactionSummaryDTO } from "@civfix/shared" +import type { MessageReactionRepository } from "./message-reactions-repository.js" // `table` is a module-constant union literal, never user input, interpolated as a postgres.js identifier. // Every room kind and DMs share the one chat table because message ids are globally unique uuids. export type ReactionTable = "chat_message_reactions" -export interface MessageReactionRepository { - toggle(messageId: string, userId: string, emoji: ReactionEmoji): Promise - loadFor( - messageIds: string[], - viewerUserId: string | null, - ): Promise> -} - export function makeMessageReactionRepository( sql: Sql, table: ReactionTable, diff --git a/services/api/src/services/message-reactions-repository.ts b/services/api/src/services/message-reactions-repository.ts new file mode 100644 index 00000000..7f30346a --- /dev/null +++ b/services/api/src/services/message-reactions-repository.ts @@ -0,0 +1,9 @@ +import type { ReactionEmoji, ReactionSummaryDTO } from "@civfix/shared" + +export interface MessageReactionRepository { + toggle(messageId: string, userId: string, emoji: ReactionEmoji): Promise + loadFor( + messageIds: string[], + viewerUserId: string | null, + ): Promise> +} diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index 5b1ffd2b..125ff207 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -10,7 +10,6 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { Queryable, Sql, SqlFragment } from "../db/client.js" -import type { POST_KIND_VALUES, REPORT_VISIBILITY_VALUES } from "../db/schema/types.js" import { keysetInstant, keysetPredicate, @@ -37,9 +36,23 @@ import { type PrimaryAffiliations, } from "./affiliation.js" import { firstReadyStillLateral, publicReportFilter } from "./report-sql.js" - -type PostKind = (typeof POST_KIND_VALUES)[number] -type PostVisibility = (typeof REPORT_VISIBILITY_VALUES)[number] +import type { + CreatePostArgs, + FeedCandidateArgs, + FeedCandidateRow, + FeedCountsRow, + FeedFilter, + FeedPage, + HomeFeedArgs, + PostBrief, + PostKind, + PostListArgs, + PostRepository, + PostVisibility, + PublicFeedArgs, + RepliesPage, + ReplyListArgs, +} from "./post-repository.js" export const POSTS_DEFAULT_LIMIT = 20 @@ -70,55 +83,6 @@ function organizationRefOf( return organizations.get(id) ?? null } -export interface CreatePostArgs { - authorId: string - guestAnonSessionId?: string | undefined - kind: PostKind - body: string | null - replyToId: string | null - repostOfId: string | null - eventId: string | null - reportId: string | null - mediaUploadIds: string[] - mentionedUserIds: string[] - organizationId: string | null -} - -export interface PostBrief { - id: string - authorId: string - kind: PostKind - replyToId: string | null - repostOfId: string | null - deletedAt: Date | null - visibility: PostVisibility -} - -export interface FeedPage { - items: PostDTO[] - nextCursor: string | null -} - -export interface PostListArgs { - viewerId: string - cursor: string | null - limit: number -} - -export interface ReplyListArgs extends PostListArgs { - focalAuthorId: string -} - -export interface RepliesPage extends FeedPage { - authorReplies: PostDTO[] -} - -export interface HomeFeedArgs extends PostListArgs { - filter: "all" | "events" | "fixes" -} - -export type FeedFilter = "all" | "events" | "fixes" - export const FEED_IN_NETWORK_POOL = 250 export const FEED_NEARBY_POOL = 150 export const FEED_RECENT_POOL = 150 @@ -133,76 +97,6 @@ export function nearbyRadiusDegrees(radiusKm: number): number { return Math.min(MAX_RADIUS_DEGREES, radiusKm / KM_PER_DEGREE_LAT / MIN_LATITUDE_COSINE) } -export interface FeedCandidateArgs { - viewerId: string - filter: FeedFilter - fallbackLat: number | null - fallbackLng: number | null - windowDays: number - radiusKm: number - candidateCap: number -} - -export interface FeedCandidateRow { - id: string - author_id: string - created_at: Date - like_count: number - reply_count: number - repost_count: number - has_report: boolean - has_live_event: boolean - has_media: boolean - author_followed: boolean - author_is_viewer: boolean - viewer_mentioned: boolean - author_org_verified: boolean - distance_km: number | null -} - -export interface FeedCountsRow { - id: string - like_count: number - repost_count: number - reply_count: number - save_count: number -} - -export interface PublicFeedArgs { - filter: "all" | "events" | "fixes" - cursor: string | null - limit: number -} - -export interface PostRepository { - getPostBrief(id: string): Promise - actorNameOf(userId: string): Promise - canPostAsOrganization(organizationId: string, userId: string): Promise - isEventMember(eventId: string, userId: string): Promise - isReportAttachable(reportId: string): Promise - - createPost(args: CreatePostArgs): Promise - softDeletePost(postId: string): Promise - - like(postId: string, userId: string): Promise - unlike(postId: string, userId: string): Promise - save(postId: string, userId: string): Promise - unsave(postId: string, userId: string): Promise - repost(postId: string, userId: string): Promise<{ targetId: string; created: boolean }> - unrepost(postId: string, userId: string): Promise<{ targetId: string; removed: boolean }> - - getPostDTO(id: string, viewerId: string): Promise - homeFeedChronological(args: HomeFeedArgs): Promise - publicFeed(args: PublicFeedArgs): Promise - feedCandidates(args: FeedCandidateArgs): Promise - hydrateByIds(ids: readonly string[], viewerId: string): Promise - followerIdsOf(authorId: string, limit: number): Promise - readableCounts(postIds: readonly string[], viewerId: string): Promise - listReplies(postId: string, args: ReplyListArgs): Promise - listUserPosts(authorId: string, args: PostListArgs): Promise - listSaves(args: PostListArgs): Promise -} - interface PostRowSelect { id: string author_id: string diff --git a/services/api/src/services/post-repository.ts b/services/api/src/services/post-repository.ts new file mode 100644 index 00000000..6b38a403 --- /dev/null +++ b/services/api/src/services/post-repository.ts @@ -0,0 +1,125 @@ +import type { PostDTO } from "@civfix/shared" +import type { POST_KIND_VALUES, REPORT_VISIBILITY_VALUES } from "../db/schema/types.js" + +export type PostKind = (typeof POST_KIND_VALUES)[number] + +export type PostVisibility = (typeof REPORT_VISIBILITY_VALUES)[number] + +export interface CreatePostArgs { + authorId: string + guestAnonSessionId?: string | undefined + kind: PostKind + body: string | null + replyToId: string | null + repostOfId: string | null + eventId: string | null + reportId: string | null + mediaUploadIds: string[] + mentionedUserIds: string[] + organizationId: string | null +} + +export interface PostBrief { + id: string + authorId: string + kind: PostKind + replyToId: string | null + repostOfId: string | null + deletedAt: Date | null + visibility: PostVisibility +} + +export interface FeedPage { + items: PostDTO[] + nextCursor: string | null +} + +export interface PostListArgs { + viewerId: string + cursor: string | null + limit: number +} + +export interface ReplyListArgs extends PostListArgs { + focalAuthorId: string +} + +export interface RepliesPage extends FeedPage { + authorReplies: PostDTO[] +} + +export interface HomeFeedArgs extends PostListArgs { + filter: "all" | "events" | "fixes" +} + +export type FeedFilter = "all" | "events" | "fixes" + +export interface FeedCandidateArgs { + viewerId: string + filter: FeedFilter + fallbackLat: number | null + fallbackLng: number | null + windowDays: number + radiusKm: number + candidateCap: number +} + +export interface FeedCandidateRow { + id: string + author_id: string + created_at: Date + like_count: number + reply_count: number + repost_count: number + has_report: boolean + has_live_event: boolean + has_media: boolean + author_followed: boolean + author_is_viewer: boolean + viewer_mentioned: boolean + author_org_verified: boolean + distance_km: number | null +} + +export interface FeedCountsRow { + id: string + like_count: number + repost_count: number + reply_count: number + save_count: number +} + +export interface PublicFeedArgs { + filter: "all" | "events" | "fixes" + cursor: string | null + limit: number +} + +export interface PostRepository { + getPostBrief(id: string): Promise + actorNameOf(userId: string): Promise + canPostAsOrganization(organizationId: string, userId: string): Promise + isEventMember(eventId: string, userId: string): Promise + isReportAttachable(reportId: string): Promise + + createPost(args: CreatePostArgs): Promise + softDeletePost(postId: string): Promise + + like(postId: string, userId: string): Promise + unlike(postId: string, userId: string): Promise + save(postId: string, userId: string): Promise + unsave(postId: string, userId: string): Promise + repost(postId: string, userId: string): Promise<{ targetId: string; created: boolean }> + unrepost(postId: string, userId: string): Promise<{ targetId: string; removed: boolean }> + + getPostDTO(id: string, viewerId: string): Promise + homeFeedChronological(args: HomeFeedArgs): Promise + publicFeed(args: PublicFeedArgs): Promise + feedCandidates(args: FeedCandidateArgs): Promise + hydrateByIds(ids: readonly string[], viewerId: string): Promise + followerIdsOf(authorId: string, limit: number): Promise + readableCounts(postIds: readonly string[], viewerId: string): Promise + listReplies(postId: string, args: ReplyListArgs): Promise + listUserPosts(authorId: string, args: PostListArgs): Promise + listSaves(args: PostListArgs): Promise +} diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index d3593c10..c61320e8 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -19,15 +19,14 @@ import type { Sql } from "../db/client.js" import { assertNoSlur } from "../abuse/slur-filter.js" import { resolveMentionTargets } from "./mention-targets-repository.drizzle.js" import { parseTimeCursor } from "../db/cursor-helpers.js" -import { - NIL_VIEWER_ID, - POSTS_DEFAULT_LIMIT, - type FeedCandidateRow, - type FeedPage, - type PostBrief, - type PostRepository, - type RepliesPage, -} from "./post-repository.drizzle.js" +import { NIL_VIEWER_ID, POSTS_DEFAULT_LIMIT } from "./post-repository.drizzle.js" +import type { + FeedCandidateRow, + FeedPage, + PostBrief, + PostRepository, + RepliesPage, +} from "./post-repository.js" import { applyCutoff, bucketSeed, diff --git a/services/api/src/services/report-chat-repository.drizzle.ts b/services/api/src/services/report-chat-repository.drizzle.ts index c220890d..fd7bf885 100644 --- a/services/api/src/services/report-chat-repository.drizzle.ts +++ b/services/api/src/services/report-chat-repository.drizzle.ts @@ -9,6 +9,11 @@ import type { ReportChatMeta } from "./report-types.js" import { monotonicReadWatermarkUpdate } from "./read-watermark-repository.drizzle.js" import { blockedPairExpr } from "./blocks-sql.js" import { toRoomMemberPerson, type RoomMemberIdentityRow } from "./room-member-person.js" +import type { + ReportChatMetaQueries, + ReportChatRepository, + ReportChatRole, +} from "./report-chat-repository.js" type ChatSystemPayload = NonNullable> type ReportSystemStatus = ChatSystemPayload["status"] @@ -23,8 +28,6 @@ export interface SystemChatRow { system_body: string | null } -export type ReportChatRole = "owner" | "member" - export interface ReportMemberRowSelect extends RoomMemberIdentityRow { role: ReportChatRole joined_at: Date @@ -58,30 +61,6 @@ export function mapSystemRow(row: SystemChatRow): ChatMessageDTO { } } -export interface ReportChatRepository { - isMember(reportId: string, userId: string): Promise - roleOf(reportId: string, userId: string): Promise - join(reportId: string, userId: string, role?: ReportChatRole): Promise - leave(reportId: string, userId: string): Promise - advanceReadWatermark(reportId: string, userId: string, upToMessageId: string): Promise - markRead(reportId: string, userId: string, at: Date): Promise - insertSystemMessage(input: { - reportId: string - status: string - kind?: string | null - note?: string | null - body?: string | null - }): Promise - listMemberIds(reportId: string, limit?: number): Promise - countMembers(reportId: string): Promise - listMembers(reportId: string, viewerId: string): Promise -} - -export interface ReportChatMetaQueries { - loadChatMeta(reportId: string, viewerUserId: string | null): Promise - isReportVerified(userId: string): Promise -} - export function makeReportChatRepository( sql: Sql, _presignMedia?: PresignMedia, diff --git a/services/api/src/services/report-chat-repository.ts b/services/api/src/services/report-chat-repository.ts new file mode 100644 index 00000000..835469e5 --- /dev/null +++ b/services/api/src/services/report-chat-repository.ts @@ -0,0 +1,28 @@ +import type { ChatMessageDTO, ReportChatParticipantDTO } from "@civfix/shared" +import type { ReportChatMeta } from "./report-types.js" + +export type ReportChatRole = "owner" | "member" + +export interface ReportChatRepository { + isMember(reportId: string, userId: string): Promise + roleOf(reportId: string, userId: string): Promise + join(reportId: string, userId: string, role?: ReportChatRole): Promise + leave(reportId: string, userId: string): Promise + advanceReadWatermark(reportId: string, userId: string, upToMessageId: string): Promise + markRead(reportId: string, userId: string, at: Date): Promise + insertSystemMessage(input: { + reportId: string + status: string + kind?: string | null + note?: string | null + body?: string | null + }): Promise + listMemberIds(reportId: string, limit?: number): Promise + countMembers(reportId: string): Promise + listMembers(reportId: string, viewerId: string): Promise +} + +export interface ReportChatMetaQueries { + loadChatMeta(reportId: string, viewerUserId: string | null): Promise + isReportVerified(userId: string): Promise +} diff --git a/services/api/src/services/report-chat-send-wiring.ts b/services/api/src/services/report-chat-send-wiring.ts index 2fee1f48..f832a202 100644 --- a/services/api/src/services/report-chat-send-wiring.ts +++ b/services/api/src/services/report-chat-send-wiring.ts @@ -17,7 +17,7 @@ import { makeContainerReportCityForward } from "./report-city-forward-wiring.js" import { roomKeyFor } from "../ws/gateway.js" import type { ChatMentionRecordSeam } from "./chat-mention-resolver.js" import type { ReportChatSendDeps } from "./report-chat-send.js" -import type { ChatRepository } from "./chat-repository.drizzle.js" +import type { ChatRepository } from "./chat-repository.js" import { insertAuditRow } from "./admin/audit-repository.drizzle.js" const REPORT_MESSAGE_POSTED_AUDIT_ACTION = "report.message_posted" diff --git a/services/api/src/services/report-city-forward-wiring.ts b/services/api/src/services/report-city-forward-wiring.ts index 1e911ceb..716b60be 100644 --- a/services/api/src/services/report-city-forward-wiring.ts +++ b/services/api/src/services/report-city-forward-wiring.ts @@ -2,10 +2,8 @@ import type { ChatMessageDTO } from "@civfix/shared" import type { Container } from "../di.js" import { makeOutboundMailService, type OutboundMailService } from "./admin/outbound-mail-service.js" import { makeDrizzleMailRepository } from "./admin/mail-repository.drizzle.js" -import { - makeDrizzleReportForwardAuditRepository, - type ReportForwardAuditRepository, -} from "./report-forward-audit-repository.drizzle.js" +import { makeDrizzleReportForwardAuditRepository } from "./report-forward-audit-repository.drizzle.js" +import type { ReportForwardAuditRepository } from "./report-forward-audit-repository.js" import { makeDrizzleDiscussionRepository } from "./discussion-repository.drizzle.js" import type { DiscussionRepository } from "./discussion-repository.js" import { diff --git a/services/api/src/services/report-city-forward.ts b/services/api/src/services/report-city-forward.ts index 05cfde7a..763b2295 100644 --- a/services/api/src/services/report-city-forward.ts +++ b/services/api/src/services/report-city-forward.ts @@ -2,7 +2,7 @@ import { buildDiscussionForwardPacket } from "./admin/mail-format.js" import { parseCityMention, effectiveJurisdictionHandle } from "./discussion-mentions.js" import { replySubject, type OutboundMailService } from "./admin/outbound-mail-service.js" import type { MailThreadRecord } from "./admin/mail-repository.drizzle.js" -import type { ReportForwardAuditRepository } from "./report-forward-audit-repository.drizzle.js" +import type { ReportForwardAuditRepository } from "./report-forward-audit-repository.js" import type { ReportJurisdictionView } from "./discussion-repository.js" import type { CounterStore } from "../abuse/counter-store.js" diff --git a/services/api/src/services/report-forward-audit-repository.drizzle.ts b/services/api/src/services/report-forward-audit-repository.drizzle.ts index c88481fd..b1d286b7 100644 --- a/services/api/src/services/report-forward-audit-repository.drizzle.ts +++ b/services/api/src/services/report-forward-audit-repository.drizzle.ts @@ -11,11 +11,7 @@ */ import type { Sql } from "../db/client.js" - -export interface ReportForwardAuditRepository { - recordMention(messageId: string, geoid: string): Promise - markForwarded(messageId: string, geoid: string): Promise -} +import type { ReportForwardAuditRepository } from "./report-forward-audit-repository.js" export function makeDrizzleReportForwardAuditRepository(sql: Sql): ReportForwardAuditRepository { return { diff --git a/services/api/src/services/report-forward-audit-repository.ts b/services/api/src/services/report-forward-audit-repository.ts new file mode 100644 index 00000000..44a211a9 --- /dev/null +++ b/services/api/src/services/report-forward-audit-repository.ts @@ -0,0 +1,4 @@ +export interface ReportForwardAuditRepository { + recordMention(messageId: string, geoid: string): Promise + markForwarded(messageId: string, geoid: string): Promise +} diff --git a/services/api/src/services/room-messages-repository.drizzle.ts b/services/api/src/services/room-messages-repository.drizzle.ts index 53a0a85b..dd947d76 100644 --- a/services/api/src/services/room-messages-repository.drizzle.ts +++ b/services/api/src/services/room-messages-repository.drizzle.ts @@ -13,6 +13,7 @@ import type { Sql, SqlFragment } from "../db/client.js" import { isUuid } from "../db/cursor-helpers.js" import { aroundLimits, mergeAroundWindow } from "./chat-history-window.js" import type { ReplyTable } from "./reply-targets-repository.drizzle.js" +import type { RoomMessagesRepository } from "./room-messages-repository.js" export type RoomTable = ReplyTable @@ -54,23 +55,6 @@ export interface RoomScopeSql { hydrateOne(row: Row, viewerUserId: string | null): Promise } -export interface RoomMessagesRepository { - history( - before: string | undefined, - limit: number, - viewerUserId: string | null, - around?: string, - ): Promise - historyAround( - around: string, - limit: number, - viewerUserId: string | null, - ): Promise - findMessage(messageId: string, viewerUserId: string | null): Promise - setPinned(messageId: string, userId: string, pinned: boolean): Promise - listPins(viewerUserId: string | null): Promise -} - export function makeDrizzleRoomMessagesRepository( sql: Sql, spec: RoomScopeSql, diff --git a/services/api/src/services/room-messages-repository.ts b/services/api/src/services/room-messages-repository.ts new file mode 100644 index 00000000..e3629333 --- /dev/null +++ b/services/api/src/services/room-messages-repository.ts @@ -0,0 +1,19 @@ +import type { ChatMessageDTO } from "@civfix/shared" +import type { ChatHistoryPage } from "@civfix/shared/interfaces" + +export interface RoomMessagesRepository { + history( + before: string | undefined, + limit: number, + viewerUserId: string | null, + around?: string, + ): Promise + historyAround( + around: string, + limit: number, + viewerUserId: string | null, + ): Promise + findMessage(messageId: string, viewerUserId: string | null): Promise + setPinned(messageId: string, userId: string, pinned: boolean): Promise + listPins(viewerUserId: string | null): Promise +} diff --git a/services/api/src/services/user-search-repository.drizzle.ts b/services/api/src/services/user-search-repository.drizzle.ts index f747492e..623377c6 100644 --- a/services/api/src/services/user-search-repository.drizzle.ts +++ b/services/api/src/services/user-search-repository.drizzle.ts @@ -2,6 +2,7 @@ import type { Sql } from "../db/client.js" import { avatarGradient } from "@civfix/shared" import type { UserSearchResultDTO } from "@civfix/shared" import { likeContains, likePrefix } from "../db/like.js" +import type { UserSearchRepository } from "./user-search-repository.js" interface UserSearchRow { id: string @@ -20,11 +21,6 @@ function toSearchResult(r: UserSearchRow): UserSearchResultDTO { } } -export interface UserSearchRepository { - searchByHandlePrefix(q: string, viewerId: string, limit: number): Promise - searchMentionable(q: string, viewerId: string, limit: number): Promise -} - export function makeDrizzleUserSearchRepository(sql: Sql): UserSearchRepository { return { // Locked product rule: DM search never surfaces the viewer, deleted or handle-less users, accounts with diff --git a/services/api/src/services/user-search-repository.ts b/services/api/src/services/user-search-repository.ts new file mode 100644 index 00000000..52b0ed79 --- /dev/null +++ b/services/api/src/services/user-search-repository.ts @@ -0,0 +1,6 @@ +import type { UserSearchResultDTO } from "@civfix/shared" + +export interface UserSearchRepository { + searchByHandlePrefix(q: string, viewerId: string, limit: number): Promise + searchMentionable(q: string, viewerId: string, limit: number): Promise +} diff --git a/services/api/src/services/users-repository.drizzle.ts b/services/api/src/services/users-repository.drizzle.ts index 119a208c..83a4f601 100644 --- a/services/api/src/services/users-repository.drizzle.ts +++ b/services/api/src/services/users-repository.drizzle.ts @@ -1,16 +1,5 @@ import type { Sql } from "../db/client.js" -import type { ROLE_VALUES } from "../db/schema/types.js" - -export type GlobalRole = (typeof ROLE_VALUES)[number] - -export interface UserRoleAndEmail { - role: GlobalRole - email: string | null -} - -export interface UsersRepository { - findRoleAndEmail(userId: string): Promise -} +import type { UserRoleAndEmail, UsersRepository } from "./users-repository.js" export function makeDrizzleUsersRepository(sql: Sql): UsersRepository { return { diff --git a/services/api/src/services/users-repository.ts b/services/api/src/services/users-repository.ts new file mode 100644 index 00000000..4cefd2f7 --- /dev/null +++ b/services/api/src/services/users-repository.ts @@ -0,0 +1,12 @@ +import type { ROLE_VALUES } from "../db/schema/types.js" + +export type GlobalRole = (typeof ROLE_VALUES)[number] + +export interface UserRoleAndEmail { + role: GlobalRole + email: string | null +} + +export interface UsersRepository { + findRoleAndEmail(userId: string): Promise +} diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 692a7cd2..31ab6433 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -33,7 +33,7 @@ import { MS_PER_HOUR, MS_PER_MINUTE } from "../lib/time.js" import { PRESIGN_CONCURRENCY } from "./media-presign.js" import type { AffiliationLoader } from "./affiliation.js" import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" -import type { TopVolunteerRow } from "./host/analytics-repository.drizzle.js" +import type { TopVolunteerRow } from "./host/analytics-repository.js" import type { InsightsInvalidator } from "./host/host-analytics-cache.js" import type { NotificationService } from "./notification-service.js" import type { diff --git a/services/api/test/helpers/chat.ts b/services/api/test/helpers/chat.ts index a2cd4f62..6a533f55 100644 --- a/services/api/test/helpers/chat.ts +++ b/services/api/test/helpers/chat.ts @@ -2,12 +2,13 @@ import { randomUUID } from "node:crypto" import { avatarGradient, AppError } from "@civfix/shared" import type { ChatConnection, ChatHistoryPage, PersistChatInput } from "@civfix/shared/interfaces" import type { ChatMessageDTO, ReactionEmoji, ReactionSummaryDTO, ReplyToDTO } from "@civfix/shared" -import { - PIN_LIST_CAP, - type ChatMessageMeta, - type ChatRepository, - type SoftDeleteOpts, -} from "../../src/services/chat-repository.drizzle.js" +import { PIN_LIST_CAP } from "../../src/services/chat-repository.drizzle.js" +import type { + ChatMessageMeta, + ChatRepository, + SoftDeleteOpts, +} from "../../src/services/chat-repository.js" +import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.js" import { REPLY_EXCERPT_MAX, replyDeletedTarget, @@ -16,7 +17,6 @@ import { import { aroundLimits } from "../../src/services/chat-history-window.js" import { toTombstoneDTO } from "../../src/services/chat-tombstone.js" import type { ThreadAggregate, ThreadsRepository } from "../../src/services/threads-repository.js" -import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.drizzle.js" import { visibleAfterHides } from "../../src/services/conversation-hides-repository.memory.js" import type { TimeCursor } from "../../src/db/cursor-helpers.js" diff --git a/services/api/test/integration/feed-ranked-pg.test.ts b/services/api/test/integration/feed-ranked-pg.test.ts index 0a3f8cd1..0880b9aa 100644 --- a/services/api/test/integration/feed-ranked-pg.test.ts +++ b/services/api/test/integration/feed-ranked-pg.test.ts @@ -7,9 +7,8 @@ import { explainFeedCandidates, makeDrizzlePostRepository, FEED_NEARBY_INDEX, - type FeedCandidateArgs, - type PostRepository, } from "../../src/services/post-repository.drizzle.js" +import type { FeedCandidateArgs, PostRepository } from "../../src/services/post-repository.js" import { makePostService, type PostService } from "../../src/services/post-service.js" const pg = await withPg() diff --git a/services/api/test/integration/host-export-orphans-pg.test.ts b/services/api/test/integration/host-export-orphans-pg.test.ts index db37dba4..858cff5b 100644 --- a/services/api/test/integration/host-export-orphans-pg.test.ts +++ b/services/api/test/integration/host-export-orphans-pg.test.ts @@ -1,10 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" -import { - makeDrizzleHostExportRepository, - type HostExportRepository, -} from "../../src/services/host/export-repository.drizzle.js" +import { makeDrizzleHostExportRepository } from "../../src/services/host/export-repository.drizzle.js" +import type { HostExportRepository } from "../../src/services/host/export-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/host-metrics-export-pg.test.ts b/services/api/test/integration/host-metrics-export-pg.test.ts index 83fe6fb7..1ec1ae23 100644 --- a/services/api/test/integration/host-metrics-export-pg.test.ts +++ b/services/api/test/integration/host-metrics-export-pg.test.ts @@ -2,9 +2,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleMetricsRepository } from "../../src/services/host/metrics-repository.drizzle.js" -import type { MetricsRepository } from "../../src/services/host/metrics-repository.drizzle.js" +import type { MetricsRepository } from "../../src/services/host/metrics-repository.js" +import type { HostExportRepository } from "../../src/services/host/export-repository.js" import { makeDrizzleHostExportRepository } from "../../src/services/host/export-repository.drizzle.js" -import type { HostExportRepository } from "../../src/services/host/export-repository.drizzle.js" import { makeHostExportService } from "../../src/services/host/export-service.js" import { registerHostExportBuilder, diff --git a/services/api/test/integration/host-metrics-rollup-pg.test.ts b/services/api/test/integration/host-metrics-rollup-pg.test.ts index 8130bf06..d7153c70 100644 --- a/services/api/test/integration/host-metrics-rollup-pg.test.ts +++ b/services/api/test/integration/host-metrics-rollup-pg.test.ts @@ -1,10 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" -import { - makeDrizzleMetricsRepository, - type MetricsRepository, -} from "../../src/services/host/metrics-repository.drizzle.js" +import { makeDrizzleMetricsRepository } from "../../src/services/host/metrics-repository.drizzle.js" +import type { MetricsRepository } from "../../src/services/host/metrics-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/inbound-repository.test.ts b/services/api/test/integration/inbound-repository.test.ts index f7d09183..28744034 100644 --- a/services/api/test/integration/inbound-repository.test.ts +++ b/services/api/test/integration/inbound-repository.test.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" -import { - makeDrizzleInboundRepository, - type InboundEmailInsert, - type InboundRepository, -} from "../../src/services/admin/inbound-repository.drizzle.js" +import { makeDrizzleInboundRepository } from "../../src/services/admin/inbound-repository.drizzle.js" +import type { + InboundEmailInsert, + InboundRepository, +} from "../../src/services/admin/inbound-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/media-attach-claim-security-pg.test.ts b/services/api/test/integration/media-attach-claim-security-pg.test.ts index 5f2c0e15..9917ac3d 100644 --- a/services/api/test/integration/media-attach-claim-security-pg.test.ts +++ b/services/api/test/integration/media-attach-claim-security-pg.test.ts @@ -4,10 +4,8 @@ import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" import { attachChatMedia } from "../../src/services/chat-attachments-repository.drizzle.js" import { userUploader } from "../../src/services/media-uploader.js" -import { - makeDrizzlePostRepository, - type PostRepository, -} from "../../src/services/post-repository.drizzle.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import type { PostRepository } from "../../src/services/post-repository.js" const pg = await withPg() diff --git a/services/api/test/integration/posts-unrepost-pg.test.ts b/services/api/test/integration/posts-unrepost-pg.test.ts index a253a65f..5d5642fb 100644 --- a/services/api/test/integration/posts-unrepost-pg.test.ts +++ b/services/api/test/integration/posts-unrepost-pg.test.ts @@ -6,10 +6,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" -import { - makeDrizzlePostRepository, - type PostRepository, -} from "../../src/services/post-repository.drizzle.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import type { PostRepository } from "../../src/services/post-repository.js" const pg = await withPg() diff --git a/services/api/test/unit/admin-host-platform-routes.test.ts b/services/api/test/unit/admin-host-platform-routes.test.ts index 4e867c52..51ec1edd 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -8,7 +8,7 @@ import { registerAdminMediaRoutes } from "../../src/routes/admin/media.routes.js import { registerAdminLegalRoutes } from "../../src/routes/admin/legal.routes.js" import { registerAdminBroadcastRoutes } from "../../src/routes/admin/broadcasts.routes.js" import { registerAdminOrgRoutes } from "../../src/routes/admin/orgs.routes.js" -import type { AdminEventPageRow } from "../../src/services/host/admin-pages-repository.drizzle.js" +import type { AdminEventPageRow } from "../../src/services/host/admin-pages-repository.js" import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" diff --git a/services/api/test/unit/admin-report-chat.test.ts b/services/api/test/unit/admin-report-chat.test.ts index d0ad6b1f..6c63efa5 100644 --- a/services/api/test/unit/admin-report-chat.test.ts +++ b/services/api/test/unit/admin-report-chat.test.ts @@ -14,10 +14,7 @@ import { type ReportChatSendDeps, } from "../../src/services/report-chat-send.js" import { makeAuditedReportChatPersist } from "../../src/services/report-chat-send-wiring.js" -import type { - ChatRepository, - InsertMessageOptions, -} from "../../src/services/chat-repository.drizzle.js" +import type { ChatRepository, InsertMessageOptions } from "../../src/services/chat-repository.js" import type { Queryable } from "../../src/db/client.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" import { roomKeyFor } from "../../src/ws/gateway.js" diff --git a/services/api/test/unit/chat-group-kick-atomic.test.ts b/services/api/test/unit/chat-group-kick-atomic.test.ts index cf265e27..2639b9de 100644 --- a/services/api/test/unit/chat-group-kick-atomic.test.ts +++ b/services/api/test/unit/chat-group-kick-atomic.test.ts @@ -2,10 +2,8 @@ import { describe, it, expect } from "vitest" import { AppError } from "@civfix/shared" import type { Sql } from "../../src/db/client.js" import { makeChatGroupService } from "../../src/services/chat-group-service.js" -import { - makeChatGroupRepository, - type ChatGroupRepository, -} from "../../src/services/chat-group-repository.drizzle.js" +import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import type { ChatGroupRepository } from "../../src/services/chat-group-repository.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" const GROUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/chat-group-service-gates.test.ts b/services/api/test/unit/chat-group-service-gates.test.ts index ddbf3a61..61e7cffc 100644 --- a/services/api/test/unit/chat-group-service-gates.test.ts +++ b/services/api/test/unit/chat-group-service-gates.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest" import { AppError } from "@civfix/shared" import { makeChatGroupService } from "../../src/services/chat-group-service.js" -import type { ChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import type { ChatGroupRepository } from "../../src/services/chat-group-repository.js" import { CIVFIX_OFFICIAL_USER_ID } from "../../src/auth/official-account.js" const GROUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/city-forward-logger-wiring.test.ts b/services/api/test/unit/city-forward-logger-wiring.test.ts index 3725fda7..35f96538 100644 --- a/services/api/test/unit/city-forward-logger-wiring.test.ts +++ b/services/api/test/unit/city-forward-logger-wiring.test.ts @@ -4,7 +4,7 @@ import type { ChatMessageDTO } from "@civfix/shared" import type { FastifyBaseLogger, FastifyInstance } from "fastify" import type { Container } from "../../src/di.js" import type { OnReportMessage } from "../../src/ws/types.js" -import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" +import type { ReportChatRepository } from "../../src/services/report-chat-repository.js" import type { DiscussionReportView } from "../../src/services/discussion-repository.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" diff --git a/services/api/test/unit/city-forward-logging.test.ts b/services/api/test/unit/city-forward-logging.test.ts index 4ea68eef..519ea3d7 100644 --- a/services/api/test/unit/city-forward-logging.test.ts +++ b/services/api/test/unit/city-forward-logging.test.ts @@ -4,7 +4,7 @@ import { makeCityForwardThrottle, } from "../../src/services/report-city-forward.js" import type { CounterStore } from "../../src/abuse/counter-store.js" -import type { ReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.drizzle.js" +import type { ReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.js" import type { OutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import type { MailThreadRecord } from "../../src/services/admin/mail-repository.drizzle.js" diff --git a/services/api/test/unit/city-forward-wiring.test.ts b/services/api/test/unit/city-forward-wiring.test.ts index 4ebda5dd..7d9f40c9 100644 --- a/services/api/test/unit/city-forward-wiring.test.ts +++ b/services/api/test/unit/city-forward-wiring.test.ts @@ -4,7 +4,7 @@ import type { ChatMessageDTO } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../../src/di.js" import type { OnReportMessage } from "../../src/ws/types.js" -import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" +import type { ReportChatRepository } from "../../src/services/report-chat-repository.js" import type { DiscussionReportView } from "../../src/services/discussion-repository.js" import type { AppendOutboundInput, diff --git a/services/api/test/unit/conversation-hides-routes.test.ts b/services/api/test/unit/conversation-hides-routes.test.ts index 630a2da7..fadb011f 100644 --- a/services/api/test/unit/conversation-hides-routes.test.ts +++ b/services/api/test/unit/conversation-hides-routes.test.ts @@ -8,8 +8,8 @@ import { makeInMemoryStores } from "../../src/auth/stores.js" import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { ConversationRoutesOverrides } from "../../src/routes/conversations.routes.js" -import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" -import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.drizzle.js" +import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.js" +import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.js" const ROOM_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/conversation-hides.test.ts b/services/api/test/unit/conversation-hides.test.ts index 40e52cb6..cb712131 100644 --- a/services/api/test/unit/conversation-hides.test.ts +++ b/services/api/test/unit/conversation-hides.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import { makeThreadsService, InMemoryChatReadState } from "../../src/services/threads-service.js" import { makeMemoryConversationHidesRepository } from "../../src/services/conversation-hides-repository.memory.js" -import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.drizzle.js" +import type { ConversationHidesRepository } from "../../src/services/conversation-hides-repository.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" import { InMemoryDmRepository } from "../../src/services/dm-repository.memory.js" diff --git a/services/api/test/unit/conversation-mutes-routes.test.ts b/services/api/test/unit/conversation-mutes-routes.test.ts index 25fe7bf4..c889a1de 100644 --- a/services/api/test/unit/conversation-mutes-routes.test.ts +++ b/services/api/test/unit/conversation-mutes-routes.test.ts @@ -8,7 +8,7 @@ import { makeInMemoryStores } from "../../src/auth/stores.js" import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { ConversationRoutesOverrides } from "../../src/routes/conversations.routes.js" -import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" +import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.js" /** * Route-level tests for PUT /conversations/mute, run with NO database: a fake diff --git a/services/api/test/unit/feed-candidates-sql.test.ts b/services/api/test/unit/feed-candidates-sql.test.ts index 4a283496..4c7519c0 100644 --- a/services/api/test/unit/feed-candidates-sql.test.ts +++ b/services/api/test/unit/feed-candidates-sql.test.ts @@ -7,8 +7,8 @@ import { FEED_RECENT_POOL, makeDrizzlePostRepository, nearbyRadiusDegrees, - type FeedCandidateArgs, } from "../../src/services/post-repository.drizzle.js" +import type { FeedCandidateArgs } from "../../src/services/post-repository.js" const VIEWER = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/feed-counts-service.test.ts b/services/api/test/unit/feed-counts-service.test.ts index bfcf64b3..7b81b1a7 100644 --- a/services/api/test/unit/feed-counts-service.test.ts +++ b/services/api/test/unit/feed-counts-service.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../src/db/client.js" import { makePostService } from "../../src/services/post-service.js" -import type { FeedCountsRow, PostRepository } from "../../src/services/post-repository.drizzle.js" +import type { FeedCountsRow, PostRepository } from "../../src/services/post-repository.js" const VIEWER = "11111111-1111-1111-1111-111111111111" const PUBLIC_POST = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/feed-ranked-service.test.ts b/services/api/test/unit/feed-ranked-service.test.ts index 5a1a8515..19c552b8 100644 --- a/services/api/test/unit/feed-ranked-service.test.ts +++ b/services/api/test/unit/feed-ranked-service.test.ts @@ -11,7 +11,7 @@ import type { FeedCandidateRow, PostBrief, PostRepository, -} from "../../src/services/post-repository.drizzle.js" +} from "../../src/services/post-repository.js" const VIEWER = "11111111-1111-1111-1111-111111111111" const AUTHOR = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/feed-repost-count-fanout.test.ts b/services/api/test/unit/feed-repost-count-fanout.test.ts index dcaa5dcf..90fe793f 100644 --- a/services/api/test/unit/feed-repost-count-fanout.test.ts +++ b/services/api/test/unit/feed-repost-count-fanout.test.ts @@ -10,7 +10,7 @@ import type { FeedCandidateRow, PostBrief, PostRepository, -} from "../../src/services/post-repository.drizzle.js" +} from "../../src/services/post-repository.js" const VIEWER = "11111111-1111-1111-1111-111111111111" const LIKER = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/host-analytics.test.ts b/services/api/test/unit/host-analytics.test.ts index 8ba2a99b..ddc2a1dc 100644 --- a/services/api/test/unit/host-analytics.test.ts +++ b/services/api/test/unit/host-analytics.test.ts @@ -11,8 +11,8 @@ import { makeHostAnalyticsCache, } from "../../src/services/host/host-analytics-cache.js" import { makeAnalyticsService } from "../../src/services/host/analytics-service.js" -import type { AnalyticsRepository } from "../../src/services/host/analytics-repository.drizzle.js" -import type { MetricsRepository } from "../../src/services/host/metrics-repository.drizzle.js" +import type { AnalyticsRepository } from "../../src/services/host/analytics-repository.js" +import type { MetricsRepository } from "../../src/services/host/metrics-repository.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" const EVENT_A = "11111111-1111-4111-8111-111111111111" diff --git a/services/api/test/unit/host-event-metrics.test.ts b/services/api/test/unit/host-event-metrics.test.ts index 921b0671..2567c56c 100644 --- a/services/api/test/unit/host-event-metrics.test.ts +++ b/services/api/test/unit/host-event-metrics.test.ts @@ -7,10 +7,7 @@ import { makeMetricsService, parseCounterKey, } from "../../src/services/host/metrics-service.js" -import type { - MetricUpsert, - MetricsRepository, -} from "../../src/services/host/metrics-repository.drizzle.js" +import type { MetricUpsert, MetricsRepository } from "../../src/services/host/metrics-repository.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" diff --git a/services/api/test/unit/host-export-csv.test.ts b/services/api/test/unit/host-export-csv.test.ts index 67daba05..861d2c75 100644 --- a/services/api/test/unit/host-export-csv.test.ts +++ b/services/api/test/unit/host-export-csv.test.ts @@ -12,7 +12,7 @@ import { registerEventExportBuilders } from "../../src/services/host/host-export import type { HostExportRecord, HostExportRepository, -} from "../../src/services/host/export-repository.drizzle.js" +} from "../../src/services/host/export-repository.js" describe("csv cells", () => { it("quotes commas, quotes and newlines", () => { diff --git a/services/api/test/unit/host-export-lifecycle.test.ts b/services/api/test/unit/host-export-lifecycle.test.ts index b945a35e..56f7ef95 100644 --- a/services/api/test/unit/host-export-lifecycle.test.ts +++ b/services/api/test/unit/host-export-lifecycle.test.ts @@ -8,11 +8,11 @@ import { registerHostExportBuilder, resetHostExportBuildersForTests, } from "../../src/services/host/export-builders.js" -import { - makeDrizzleHostExportRepository, - type HostExportRecord, - type HostExportRepository, -} from "../../src/services/host/export-repository.drizzle.js" +import { makeDrizzleHostExportRepository } from "../../src/services/host/export-repository.drizzle.js" +import type { + HostExportRecord, + HostExportRepository, +} from "../../src/services/host/export-repository.js" import type { Sql } from "../../src/db/client.js" import { makeFakeSql } from "../helpers/fake-sql.js" diff --git a/services/api/test/unit/host-insights.test.ts b/services/api/test/unit/host-insights.test.ts index 454cd699..4e93f654 100644 --- a/services/api/test/unit/host-insights.test.ts +++ b/services/api/test/unit/host-insights.test.ts @@ -19,7 +19,7 @@ import type { SeatDraft } from "../../src/services/host/registration-repository. import type { AnalyticsRepository, EventClockRecord, -} from "../../src/services/host/analytics-repository.drizzle.js" +} from "../../src/services/host/analytics-repository.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" const OTHER_EVENT = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" diff --git a/services/api/test/unit/host-metrics-rollup-correctness.test.ts b/services/api/test/unit/host-metrics-rollup-correctness.test.ts index a45fab42..3b61f36c 100644 --- a/services/api/test/unit/host-metrics-rollup-correctness.test.ts +++ b/services/api/test/unit/host-metrics-rollup-correctness.test.ts @@ -1,15 +1,12 @@ import { describe, expect, it } from "vitest" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeMetricsService } from "../../src/services/host/metrics-service.js" -import { - makeDrizzleMetricsRepository, - type MetricUpsert, - type MetricsRepository, -} from "../../src/services/host/metrics-repository.drizzle.js" +import { makeDrizzleMetricsRepository } from "../../src/services/host/metrics-repository.drizzle.js" +import type { MetricUpsert, MetricsRepository } from "../../src/services/host/metrics-repository.js" +import type { AnalyticsRepository } from "../../src/services/host/analytics-repository.js" import { makeInsightsService } from "../../src/services/host/insights-service.js" import { makeHostAnalyticsCache } from "../../src/services/host/host-analytics-cache.js" import { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" -import type { AnalyticsRepository } from "../../src/services/host/analytics-repository.drizzle.js" import { DEFAULT_EVENT_TIME_ZONE } from "../../src/services/host/event-fields.js" import type { Sql } from "../../src/db/client.js" import { makeFakeSql } from "../helpers/fake-sql.js" diff --git a/services/api/test/unit/host/announcement-send-failure.test.ts b/services/api/test/unit/host/announcement-send-failure.test.ts index caeb088b..0d602cc2 100644 --- a/services/api/test/unit/host/announcement-send-failure.test.ts +++ b/services/api/test/unit/host/announcement-send-failure.test.ts @@ -7,7 +7,7 @@ import { type BroadcastConfig, } from "../../../src/services/host/broadcast-service.js" import { makeAnnouncementService } from "../../../src/services/host/announcement-service.js" -import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-identity-repository.drizzle.js" +import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-identity-repository.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" const HOST = "00000000-0000-0000-0000-0000000000aa" diff --git a/services/api/test/unit/host/announcement-service.test.ts b/services/api/test/unit/host/announcement-service.test.ts index 4aaf1f0a..873f98df 100644 --- a/services/api/test/unit/host/announcement-service.test.ts +++ b/services/api/test/unit/host/announcement-service.test.ts @@ -19,7 +19,7 @@ import { makeAnnouncementService, type AnnouncementService, } from "../../../src/services/host/announcement-service.js" -import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-identity-repository.drizzle.js" +import type { AnnouncementIdentityRepository } from "../../../src/services/host/announcement-identity-repository.js" import type { EventBroadcastContext } from "../../../src/services/host/broadcast-types.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" diff --git a/services/api/test/unit/host/event-analytics-service.test.ts b/services/api/test/unit/host/event-analytics-service.test.ts index 57ed1552..91104a38 100644 --- a/services/api/test/unit/host/event-analytics-service.test.ts +++ b/services/api/test/unit/host/event-analytics-service.test.ts @@ -15,9 +15,9 @@ import { import type { AnalyticsRepository, EventClockRecord, -} from "../../../src/services/host/analytics-repository.drizzle.js" -import type { EventAnalyticsRepository } from "../../../src/services/host/event-analytics-repository.drizzle.js" -import type { MetricsRepository } from "../../../src/services/host/metrics-repository.drizzle.js" +} from "../../../src/services/host/analytics-repository.js" +import type { EventAnalyticsRepository } from "../../../src/services/host/event-analytics-repository.js" +import type { MetricsRepository } from "../../../src/services/host/metrics-repository.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" const HOST = "00000000-0000-0000-0000-0000000000aa" diff --git a/services/api/test/unit/host/host-portfolio-correctness.test.ts b/services/api/test/unit/host/host-portfolio-correctness.test.ts index e9367446..2e3bc10e 100644 --- a/services/api/test/unit/host/host-portfolio-correctness.test.ts +++ b/services/api/test/unit/host/host-portfolio-correctness.test.ts @@ -3,8 +3,8 @@ import { makeHostPortfolioService } from "../../../src/services/host/host-portfo import { hostedRegistrationTotals, makeDrizzleHostPortfolioRepository, - type HostedEventRecord, } from "../../../src/services/host/host-portfolio-repository.drizzle.js" +import type { HostedEventRecord } from "../../../src/services/host/host-portfolio-repository.js" import { makeFakeSql } from "../../helpers/fake-sql.js" import type { Sql } from "../../../src/db/client.js" diff --git a/services/api/test/unit/host/host-portfolio-service.test.ts b/services/api/test/unit/host/host-portfolio-service.test.ts index 4d87383c..ca7fb14b 100644 --- a/services/api/test/unit/host/host-portfolio-service.test.ts +++ b/services/api/test/unit/host/host-portfolio-service.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest" import { makeHostPortfolioService } from "../../../src/services/host/host-portfolio-service.js" import type { - HostedEventRecord, HostPortfolioRepository, -} from "../../../src/services/host/host-portfolio-repository.drizzle.js" + HostedEventRecord, +} from "../../../src/services/host/host-portfolio-repository.js" import type { HostedEventCounts } from "../../../src/services/host/portfolio-counts.js" import { makeFakeSql } from "../../helpers/fake-sql.js" import { hostedEventCounts } from "../../../src/services/host/portfolio-counts.js" diff --git a/services/api/test/unit/host/host-team-service-security.test.ts b/services/api/test/unit/host/host-team-service-security.test.ts index 7948c6ba..91e9c200 100644 --- a/services/api/test/unit/host/host-team-service-security.test.ts +++ b/services/api/test/unit/host/host-team-service-security.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest" import type { HostCapability } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.drizzle.js" +import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.js" import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" import { makeDrizzleHostTeamRepository } from "../../../src/services/host/host-team-repository.drizzle.js" import { diff --git a/services/api/test/unit/host/host-team-service.test.ts b/services/api/test/unit/host/host-team-service.test.ts index 6273bba9..33180017 100644 --- a/services/api/test/unit/host/host-team-service.test.ts +++ b/services/api/test/unit/host/host-team-service.test.ts @@ -3,7 +3,7 @@ import { AppError, MAX_TEAM_INVITES_PER_EVENT, type HostCapability } from "@civf import { can, NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { hostForbiddenCopy } from "../../../src/services/host/authz.js" -import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.drizzle.js" +import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.js" import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" import { fakeCleanupDTO } from "../../helpers/host-team.js" import { diff --git a/services/api/test/unit/host/org-team-routes.test.ts b/services/api/test/unit/host/org-team-routes.test.ts index 2d48e24e..0e910cc4 100644 --- a/services/api/test/unit/host/org-team-routes.test.ts +++ b/services/api/test/unit/host/org-team-routes.test.ts @@ -16,7 +16,7 @@ import { InMemoryOrganizationRepository } from "../../../src/services/host/organ import { fakeCleanupReader } from "../../helpers/host-team.js" import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" import { hostForbiddenCopy } from "../../../src/services/host/authz.js" -import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.drizzle.js" +import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" const OTHER_EVENT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" diff --git a/services/api/test/unit/media-attach-claim-security.test.ts b/services/api/test/unit/media-attach-claim-security.test.ts index cb2116f6..3121f4e6 100644 --- a/services/api/test/unit/media-attach-claim-security.test.ts +++ b/services/api/test/unit/media-attach-claim-security.test.ts @@ -2,10 +2,8 @@ import { describe, expect, it } from "vitest" import type { Queryable, Sql } from "../../src/db/client.js" import { attachChatMedia } from "../../src/services/chat-attachments-repository.drizzle.js" import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" -import { - makeDrizzlePostRepository, - type CreatePostArgs, -} from "../../src/services/post-repository.drizzle.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import type { CreatePostArgs } from "../../src/services/post-repository.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" const AUTHOR = "11111111-1111-4111-8111-111111111111" diff --git a/services/api/test/unit/post-repository-writes-sql.test.ts b/services/api/test/unit/post-repository-writes-sql.test.ts index 3204a5c9..a847afcf 100644 --- a/services/api/test/unit/post-repository-writes-sql.test.ts +++ b/services/api/test/unit/post-repository-writes-sql.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest" import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" -import { - makeDrizzlePostRepository, - type CreatePostArgs, -} from "../../src/services/post-repository.drizzle.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import type { CreatePostArgs } from "../../src/services/post-repository.js" const AUTHOR = "11111111-1111-1111-1111-111111111111" const TARGET = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/post-service-security.test.ts b/services/api/test/unit/post-service-security.test.ts index c8bb9050..ed67eb7e 100644 --- a/services/api/test/unit/post-service-security.test.ts +++ b/services/api/test/unit/post-service-security.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../src/db/client.js" import { makePostService } from "../../src/services/post-service.js" -import type { PostBrief, PostRepository } from "../../src/services/post-repository.drizzle.js" +import type { PostBrief, PostRepository } from "../../src/services/post-repository.js" const AUTHOR = "author" const STRANGER = "stranger" diff --git a/services/api/test/unit/post-service.test.ts b/services/api/test/unit/post-service.test.ts index 3b7fd97e..d92a06af 100644 --- a/services/api/test/unit/post-service.test.ts +++ b/services/api/test/unit/post-service.test.ts @@ -7,12 +7,14 @@ import { makeFakeSql } from "../helpers/fake-sql.js" import { makeDrizzlePostRepository, NIL_VIEWER_ID, - type CreatePostArgs, - type FeedCandidateArgs, - type PostBrief, - type PostRepository, - type ReplyListArgs, } from "../../src/services/post-repository.drizzle.js" +import type { + CreatePostArgs, + FeedCandidateArgs, + PostBrief, + PostRepository, + ReplyListArgs, +} from "../../src/services/post-repository.js" import type { PostNotifier } from "../../src/services/notification-service.js" const LEGACY_CURSOR_ID = "33333333-3333-3333-3333-333333333333" diff --git a/services/api/test/unit/post-unrepost-unreadable.test.ts b/services/api/test/unit/post-unrepost-unreadable.test.ts index be5fa9f5..c227475b 100644 --- a/services/api/test/unit/post-unrepost-unreadable.test.ts +++ b/services/api/test/unit/post-unrepost-unreadable.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { RepostResponseSchema, type PostDTO } from "@civfix/shared" import type { Sql } from "../../src/db/client.js" import { makePostService } from "../../src/services/post-service.js" -import type { PostBrief, PostRepository } from "../../src/services/post-repository.drizzle.js" +import type { PostBrief, PostRepository } from "../../src/services/post-repository.js" const REPOSTER = "11111111-1111-1111-1111-111111111111" const AUTHOR = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/posts-routes.test.ts b/services/api/test/unit/posts-routes.test.ts index 6976aebd..36d18d72 100644 --- a/services/api/test/unit/posts-routes.test.ts +++ b/services/api/test/unit/posts-routes.test.ts @@ -38,7 +38,7 @@ import type { PostRepository, RepliesPage, ReplyListArgs, -} from "../../src/services/post-repository.drizzle.js" +} from "../../src/services/post-repository.js" const throwingSql = (() => { throw new Error("posts-routes.test: no SQL may be issued on these paths") diff --git a/services/api/test/unit/report-chat-bell-wiring.test.ts b/services/api/test/unit/report-chat-bell-wiring.test.ts index f66baa22..71a1e2d6 100644 --- a/services/api/test/unit/report-chat-bell-wiring.test.ts +++ b/services/api/test/unit/report-chat-bell-wiring.test.ts @@ -3,7 +3,7 @@ import { FakePushSender } from "@civfix/shared/fakes" import type { FastifyInstance } from "fastify" import type { Container } from "../../src/di.js" import type { GatewayReportChat } from "../../src/ws/gateway.js" -import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" +import type { ReportChatRepository } from "../../src/services/report-chat-repository.js" import { makeNotificationService, type NotificationService, diff --git a/services/api/test/unit/report-chat-membership.test.ts b/services/api/test/unit/report-chat-membership.test.ts index dcb93b0f..4f044ecf 100644 --- a/services/api/test/unit/report-chat-membership.test.ts +++ b/services/api/test/unit/report-chat-membership.test.ts @@ -14,7 +14,7 @@ import { InMemoryBlocksRepository, InMemoryDmRepository, } from "../../src/services/dm-repository.memory.js" -import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" +import type { ReportChatRepository } from "../../src/services/report-chat-repository.js" const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/report-chat-notifications.test.ts b/services/api/test/unit/report-chat-notifications.test.ts index dff252b5..59988fb8 100644 --- a/services/api/test/unit/report-chat-notifications.test.ts +++ b/services/api/test/unit/report-chat-notifications.test.ts @@ -21,7 +21,7 @@ import { type NotificationService, } from "../../src/services/notification-service.js" import type { PushSender } from "@civfix/shared/interfaces" -import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" +import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.js" import type { ConversationMuteRoomKind } from "../../src/db/schema/conversation_mutes.js" class InMemoryConversationMutes implements ConversationMutesRepository { diff --git a/services/api/test/unit/report-forward-audit.test.ts b/services/api/test/unit/report-forward-audit.test.ts index ca23fa35..b4e11420 100644 --- a/services/api/test/unit/report-forward-audit.test.ts +++ b/services/api/test/unit/report-forward-audit.test.ts @@ -6,7 +6,7 @@ import { CITY_FORWARD_PER_GEOID_PER_HOUR, } from "../../src/services/report-city-forward.js" import { InMemoryCounterStore, type CounterStore } from "../../src/abuse/counter-store.js" -import type { ReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.drizzle.js" +import type { ReportForwardAuditRepository } from "../../src/services/report-forward-audit-repository.js" import type { AppendOutboundInput, OutboundMailService, diff --git a/services/api/test/unit/thread-read-routes.test.ts b/services/api/test/unit/thread-read-routes.test.ts index 81383b0f..41c6a205 100644 --- a/services/api/test/unit/thread-read-routes.test.ts +++ b/services/api/test/unit/thread-read-routes.test.ts @@ -18,9 +18,9 @@ import { THREAD_READ_RATE_LIMIT, type ConversationRoutesOverrides, } from "../../src/routes/conversations.routes.js" -import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" -import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" -import type { ChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import type { ConversationMutesRepository } from "../../src/services/conversation-mutes-repository.js" +import type { ReportChatRepository } from "../../src/services/report-chat-repository.js" +import type { ChatGroupRepository } from "../../src/services/chat-group-repository.js" const ROOM = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const OTHER = "99999999-9999-9999-9999-999999999999" diff --git a/services/api/test/unit/web-base-url-fallback.test.ts b/services/api/test/unit/web-base-url-fallback.test.ts index 1d2d2d89..e0d01d38 100644 --- a/services/api/test/unit/web-base-url-fallback.test.ts +++ b/services/api/test/unit/web-base-url-fallback.test.ts @@ -7,7 +7,7 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { makeServer } from "../../src/server.js" -import type { HostStandingResolution } from "../../src/services/host/host-standing-repository.drizzle.js" +import type { HostStandingResolution } from "../../src/services/host/host-standing-repository.js" import { InMemoryHostTeamRepository } from "../../src/services/host/host-team-repository.memory.js" import { makeHostTeamService, From e0e150d2bef36788f5128cadb6f362d256ea6bac Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 17:09:01 +0000 Subject: [PATCH 16/20] worker-facing repository contracts get type-only subpaths --- README.md | 8 +- services/api/package.json | 3 + .../inbound-retention-repository.drizzle.ts | 14 +-- .../admin/inbound-retention-repository.ts | 9 ++ .../anon-hold-release-repository.drizzle.ts | 2 +- .../services/anon-hold-release-repository.ts | 26 +++++ .../api/src/services/anon-hold-release.ts | 26 +---- .../media-worker-repository.drizzle.ts | 103 ++---------------- .../src/services/media-worker-repository.ts | 94 ++++++++++++++++ .../services/retention-repository.drizzle.ts | 10 +- .../api/src/services/retention-repository.ts | 8 ++ services/api/test/helpers/anon.ts | 2 +- .../integration/anon-hold-release-pg.test.ts | 2 +- .../src/jobs/hold-release-sweep.ts | 6 +- .../media-worker/src/jobs/media-checks.ts | 2 +- .../media-worker/src/jobs/media-pipeline.ts | 2 +- .../media-worker/src/jobs/orphan-sweep.ts | 6 +- .../media-worker/src/jobs/reject-cleanup.ts | 2 +- .../media-worker/src/jobs/retention-sweep.ts | 2 +- services/media-worker/src/jobs/stuck-sweep.ts | 3 +- services/media-worker/src/jobs/upload-reap.ts | 2 +- services/media-worker/src/seams.ts | 8 +- services/media-worker/src/worker.ts | 3 +- .../in-memory-media-worker-repository.ts | 2 +- .../test/integration/media-checks-pg.test.ts | 2 +- .../served-key-and-orphan-pg.test.ts | 6 +- .../test/unit/anon-hold-release.test.ts | 2 +- .../test/unit/retention-sweep.test.ts | 2 +- .../media-worker/test/unit/worker.test.ts | 5 +- 29 files changed, 194 insertions(+), 168 deletions(-) create mode 100644 services/api/src/services/admin/inbound-retention-repository.ts create mode 100644 services/api/src/services/anon-hold-release-repository.ts create mode 100644 services/api/src/services/media-worker-repository.ts create mode 100644 services/api/src/services/retention-repository.ts diff --git a/README.md b/README.md index 64eebfee..6847c196 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,14 @@ postgres-js client, the R2 adapter, or the GlitchTip reporter. It depends on `@c `services/api/package.json`: - `@civfix/api/db` - the schema barrel + `makeDb` + `Db`/`Sql` types. -- `@civfix/api/media-repo` - the richer media-worker persistence seam (`MediaWorkerRepository`: - find / applyResult / insertAbuseFlag / findOrphans / deleteById), its Drizzle impl, and +- `@civfix/api/media-repo` - the Drizzle impl of the richer media-worker persistence seam and `ensureNextMonthChatPartition` (so partition bounds/naming have one source shared with the migrations). +- `@civfix/api/media-worker-repository`, `@civfix/api/anon-hold-release-repository`, + `@civfix/api/inbound-retention-repository` - the type-only repository contracts the worker's jobs + depend on (`MediaWorkerRepository`: find / applyResult / insertAbuseFlag / findOrphans / + deleteById; `AnonHoldReleaseRepository`; `InboundRetentionRepository`). Their Drizzle impls stay + on `media-repo`, `anon-hold-repo` and `inbound-retention-repo`. - `@civfix/api/queue-names` - every pg-boss queue name and the shared queue policy. - `@civfix/api/adapters/storage`, `@civfix/api/adapters/abuse-checks`, `@civfix/api/errors`, `@civfix/api/migrate` - the R2 adapter, the real AbuseChecks adapter, the GlitchTip helper, and the diff --git a/services/api/package.json b/services/api/package.json index a5ae4558..83773a26 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -11,11 +11,14 @@ "./server": "./dist/server.js", "./db": "./src/db/worker-db.ts", "./media-repo": "./src/services/media-worker-repository.drizzle.ts", + "./media-worker-repository": "./src/services/media-worker-repository.ts", "./inbound-retention-repo": "./src/services/admin/inbound-retention-repository.drizzle.ts", + "./inbound-retention-repository": "./src/services/admin/inbound-retention-repository.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-repository.drizzle.ts", + "./anon-hold-release-repository": "./src/services/anon-hold-release-repository.ts", "./adapters/storage": "./src/adapters/storage.r2.ts", "./adapters/storage-local": "./src/adapters/storage.local.ts", "./adapters/abuse-checks": "./src/adapters/abuse-checks.ts", diff --git a/services/api/src/services/admin/inbound-retention-repository.drizzle.ts b/services/api/src/services/admin/inbound-retention-repository.drizzle.ts index 2a5e64f4..75513b5f 100644 --- a/services/api/src/services/admin/inbound-retention-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-retention-repository.drizzle.ts @@ -1,5 +1,9 @@ import type { Sql } from "../../db/client.js" import { MS_PER_DAY } from "../../lib/time.js" +import type { + InboundRetentionRepository, + ReapedInboundEmail, +} from "./inbound-retention-repository.js" const INBOUND_EMAIL_RETENTION_DAYS = 180 @@ -7,21 +11,11 @@ export const INBOUND_EMAIL_RETENTION_MS = INBOUND_EMAIL_RETENTION_DAYS * MS_PER_ export const INBOUND_EMAIL_RETENTION_BATCH = 200 -export interface ReapedInboundEmail { - id: string - attachmentKeys: string[] -} - interface ReapedRow { id: string attachments: { key?: unknown }[] | null } -export interface InboundRetentionRepository { - findArchivedBefore(input: { before: Date; limit: number }): Promise - deleteByIds(ids: string[]): Promise -} - export function makeDrizzleInboundRetentionRepository(sql: Sql): InboundRetentionRepository { return { async findArchivedBefore(input: { diff --git a/services/api/src/services/admin/inbound-retention-repository.ts b/services/api/src/services/admin/inbound-retention-repository.ts new file mode 100644 index 00000000..5ec3d478 --- /dev/null +++ b/services/api/src/services/admin/inbound-retention-repository.ts @@ -0,0 +1,9 @@ +export interface ReapedInboundEmail { + id: string + attachmentKeys: string[] +} + +export interface InboundRetentionRepository { + findArchivedBefore(input: { before: Date; limit: number }): Promise + deleteByIds(ids: string[]): Promise +} diff --git a/services/api/src/services/anon-hold-release-repository.drizzle.ts b/services/api/src/services/anon-hold-release-repository.drizzle.ts index 05fa3f30..b1a8dc30 100644 --- a/services/api/src/services/anon-hold-release-repository.drizzle.ts +++ b/services/api/src/services/anon-hold-release-repository.drizzle.ts @@ -3,7 +3,7 @@ import type { AnonHoldReleaseRepository, HeldReportView, ReleaseMediaView, -} from "./anon-hold-release.js" +} from "./anon-hold-release-repository.js" const RELEASE_TIMELINE_NOTE = "Released after automated review" diff --git a/services/api/src/services/anon-hold-release-repository.ts b/services/api/src/services/anon-hold-release-repository.ts new file mode 100644 index 00000000..303b0b36 --- /dev/null +++ b/services/api/src/services/anon-hold-release-repository.ts @@ -0,0 +1,26 @@ +import type { LatLng } from "@civfix/shared" + +export interface HeldReportView { + id: string + reporterUserId: string | null + anonSessionId: string | null + status: string + visibility: string + lat: number + lng: number + deletedAt: Date | null +} + +export interface ReleaseMediaView { + id: string + status: "validating" | "ready" | "rejected" | "held" + exifGeo?: LatLng | null +} + +export interface AnonHoldReleaseRepository { + findReport(reportId: string): Promise + findMedia(reportId: string): Promise + countOpenAbuseFlags(reportId: string, mediaIds: string[]): Promise + publishHeldReport(reportId: string, publishedAt: Date, mediaIds?: string[]): Promise + findHeldAnonReportIds(limit: number): Promise +} diff --git a/services/api/src/services/anon-hold-release.ts b/services/api/src/services/anon-hold-release.ts index 4e0ec2d4..4200c196 100644 --- a/services/api/src/services/anon-hold-release.ts +++ b/services/api/src/services/anon-hold-release.ts @@ -1,30 +1,6 @@ import type { AbuseChecks } from "@civfix/shared/interfaces" import type { LatLng } from "@civfix/shared" - -export interface HeldReportView { - id: string - reporterUserId: string | null - anonSessionId: string | null - status: string - visibility: string - lat: number - lng: number - deletedAt: Date | null -} - -export interface ReleaseMediaView { - id: string - status: "validating" | "ready" | "rejected" | "held" - exifGeo?: LatLng | null -} - -export interface AnonHoldReleaseRepository { - findReport(reportId: string): Promise - findMedia(reportId: string): Promise - countOpenAbuseFlags(reportId: string, mediaIds: string[]): Promise - publishHeldReport(reportId: string, publishedAt: Date, mediaIds?: string[]): Promise - findHeldAnonReportIds(limit: number): Promise -} +import type { AnonHoldReleaseRepository } from "./anon-hold-release-repository.js" export interface AnonHoldReleaseDeps { repo: AnonHoldReleaseRepository diff --git a/services/api/src/services/media-worker-repository.drizzle.ts b/services/api/src/services/media-worker-repository.drizzle.ts index 91f38ae7..389ac04b 100644 --- a/services/api/src/services/media-worker-repository.drizzle.ts +++ b/services/api/src/services/media-worker-repository.drizzle.ts @@ -8,14 +8,21 @@ import { reports } from "../db/schema/reports.js" import { jurisdictions } from "../db/schema/jurisdictions.js" import { users } from "../db/schema/users.js" import type { Db, Queryable, Sql } from "../db/client.js" -import type { MediaKind, MediaStatus } from "@civfix/shared" import type { NearDuplicateResult } from "@civfix/shared/interfaces" +import type { + LeakedObjectRow, + LegacyServedKeyAdoption, + MediaResultPatch, + MediaWorkerAsset, + MediaWorkerRepository, + NewAbuseFlag, + OrphanRow, + StuckMediaRow, +} from "./media-worker-repository.js" export { normalizeEtag, readEtag } from "./media-etag.js" export type { StorageHeadWithEtag } from "./media-etag.js" -export type WorkerAbuseReason = "nsfw" | "phash_dup" | "gps" - const ANONYMOUS_REPORTER = "Anonymous" const DUPLICATE_MODERATION_FLAG = "Near-duplicate media" const HELD_MODERATION_FLAG = "Held media (NSFW)" @@ -23,96 +30,6 @@ const HELD_MODERATION_AUTO_ACTION = "Hidden pending review" export const PARTITION_MONTHS_AHEAD = 2 -export interface MediaWorkerAsset { - id: string - uploadId: string - reportId: string | null - kind: MediaKind - r2Key: string - servedKey: string | null - thumbKey: string | null - status: MediaStatus - byteSize: number | null -} - -export interface MediaResultPatch { - status: MediaStatus - codec?: string | null - width?: number | null - height?: number | null - phash?: string | null - servedKey?: string | null - thumbKey?: string | null - byteSize?: number | null -} - -export interface NewAbuseFlag { - subjectId: string - reason: WorkerAbuseReason - source?: "worker" | "api" | "user_report" -} - -export interface OrphanRow { - id: string - r2Key: string - servedKey: string | null - thumbKey: string | null -} - -export interface StuckMediaRow { - id: string - uploadId: string - r2Key: string - servedKey: string | null - thumbKey: string | null - kind: MediaKind - checkCount: number - uploadEtag: string | null -} - -export interface LeakedObjectRow { - r2Key: string - mediaId: string | null - attempts: number -} - -export interface LegacyServedKeyAdoption { - adopted: number - remaining: number -} - -export interface MediaWorkerRepository { - findById(id: string): Promise - findByUploadId(uploadId: string): Promise - applyResult(id: string, patch: MediaResultPatch): Promise - insertAbuseFlag(flag: NewAbuseFlag): Promise - findOrphans(olderThan: Date, limit: number): Promise - findStuckValidating(olderThan: Date, limit: number): Promise - terminalizeStuck(id: string): Promise - deleteOrphan(id: string, olderThan: Date): Promise - adoptLegacyServedKeys(olderThan: Date, limit: number): Promise - r2KeyReferencedByOthers(id: string, r2Key: string): Promise - findPhashDuplicate?( - hash: string, - opts?: { excludeAssetId?: string; excludeReportId?: string }, - ): Promise - enqueueHeldModerationItem?(input: { - reportId: string - reason: string - kind?: "image" | "duplicate" - note?: string | null - }): Promise - refreshAvatarUrls?(mediaId: string, avatarUrl: string): Promise - - recordLeakedObjects?(input: { - mediaId: string | null - keys: string[] - error?: string | null - }): Promise - listLeakedObjects?(limit: number, maxAttempts: number): Promise - clearLeakedObject?(r2Key: string): Promise -} - function toAsset(row: typeof mediaAssets.$inferSelect): MediaWorkerAsset { return { id: row.id, diff --git a/services/api/src/services/media-worker-repository.ts b/services/api/src/services/media-worker-repository.ts new file mode 100644 index 00000000..2566cc32 --- /dev/null +++ b/services/api/src/services/media-worker-repository.ts @@ -0,0 +1,94 @@ +import type { MediaKind, MediaStatus } from "@civfix/shared" +import type { NearDuplicateResult } from "@civfix/shared/interfaces" + +export type WorkerAbuseReason = "nsfw" | "phash_dup" | "gps" + +export interface MediaWorkerAsset { + id: string + uploadId: string + reportId: string | null + kind: MediaKind + r2Key: string + servedKey: string | null + thumbKey: string | null + status: MediaStatus + byteSize: number | null +} + +export interface MediaResultPatch { + status: MediaStatus + codec?: string | null + width?: number | null + height?: number | null + phash?: string | null + servedKey?: string | null + thumbKey?: string | null + byteSize?: number | null +} + +export interface NewAbuseFlag { + subjectId: string + reason: WorkerAbuseReason + source?: "worker" | "api" | "user_report" +} + +export interface OrphanRow { + id: string + r2Key: string + servedKey: string | null + thumbKey: string | null +} + +export interface StuckMediaRow { + id: string + uploadId: string + r2Key: string + servedKey: string | null + thumbKey: string | null + kind: MediaKind + checkCount: number + uploadEtag: string | null +} + +export interface LeakedObjectRow { + r2Key: string + mediaId: string | null + attempts: number +} + +export interface LegacyServedKeyAdoption { + adopted: number + remaining: number +} + +export interface MediaWorkerRepository { + findById(id: string): Promise + findByUploadId(uploadId: string): Promise + applyResult(id: string, patch: MediaResultPatch): Promise + insertAbuseFlag(flag: NewAbuseFlag): Promise + findOrphans(olderThan: Date, limit: number): Promise + findStuckValidating(olderThan: Date, limit: number): Promise + terminalizeStuck(id: string): Promise + deleteOrphan(id: string, olderThan: Date): Promise + adoptLegacyServedKeys(olderThan: Date, limit: number): Promise + r2KeyReferencedByOthers(id: string, r2Key: string): Promise + findPhashDuplicate?( + hash: string, + opts?: { excludeAssetId?: string; excludeReportId?: string }, + ): Promise + enqueueHeldModerationItem?(input: { + reportId: string + reason: string + kind?: "image" | "duplicate" + note?: string | null + }): Promise + refreshAvatarUrls?(mediaId: string, avatarUrl: string): Promise + + recordLeakedObjects?(input: { + mediaId: string | null + keys: string[] + error?: string | null + }): Promise + listLeakedObjects?(limit: number, maxAttempts: number): Promise + clearLeakedObject?(r2Key: string): Promise +} diff --git a/services/api/src/services/retention-repository.drizzle.ts b/services/api/src/services/retention-repository.drizzle.ts index cf57351c..27bd1cc5 100644 --- a/services/api/src/services/retention-repository.drizzle.ts +++ b/services/api/src/services/retention-repository.drizzle.ts @@ -1,13 +1,5 @@ import type { Sql } from "../db/client.js" - -export interface RetentionRepository { - deleteExpiredOtps(cutoff: Date, limit: number): Promise<{ id: string }[]> - deleteExpiredAnonTokens(cutoff: Date, limit: number): Promise<{ id: string }[]> - deleteExpiredSessions(cutoff: Date, limit: number): Promise<{ id: string }[]> - deleteOldIdempotencyKeys(cutoff: Date, limit: number): Promise<{ key: string }[]> - deleteOldNotifications(cutoff: Date, limit: number): Promise<{ id: string }[]> - deleteStaleGeocodeCache(cutoff: Date, limit: number): Promise<{ point_key: string }[]> -} +import type { RetentionRepository } from "./retention-repository.js" export function makeDrizzleRetentionRepository(sql: Sql): RetentionRepository { return { diff --git a/services/api/src/services/retention-repository.ts b/services/api/src/services/retention-repository.ts new file mode 100644 index 00000000..7a54b1ac --- /dev/null +++ b/services/api/src/services/retention-repository.ts @@ -0,0 +1,8 @@ +export interface RetentionRepository { + deleteExpiredOtps(cutoff: Date, limit: number): Promise<{ id: string }[]> + deleteExpiredAnonTokens(cutoff: Date, limit: number): Promise<{ id: string }[]> + deleteExpiredSessions(cutoff: Date, limit: number): Promise<{ id: string }[]> + deleteOldIdempotencyKeys(cutoff: Date, limit: number): Promise<{ key: string }[]> + deleteOldNotifications(cutoff: Date, limit: number): Promise<{ id: string }[]> + deleteStaleGeocodeCache(cutoff: Date, limit: number): Promise<{ point_key: string }[]> +} diff --git a/services/api/test/helpers/anon.ts b/services/api/test/helpers/anon.ts index d743967f..acbef871 100644 --- a/services/api/test/helpers/anon.ts +++ b/services/api/test/helpers/anon.ts @@ -34,7 +34,7 @@ import type { AnonHoldReleaseRepository, HeldReportView, ReleaseMediaView, -} from "../../src/services/anon-hold-release.js" +} from "../../src/services/anon-hold-release-repository.js" import { formatReferenceCode, reportScopeKey, typeCodeFor } from "../../src/db/reference-code.js" import type { ReportType } from "@civfix/shared" diff --git a/services/api/test/integration/anon-hold-release-pg.test.ts b/services/api/test/integration/anon-hold-release-pg.test.ts index 19aaa294..32bbfdbc 100644 --- a/services/api/test/integration/anon-hold-release-pg.test.ts +++ b/services/api/test/integration/anon-hold-release-pg.test.ts @@ -15,7 +15,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleAnonHoldReleaseRepository } from "../../src/services/anon-hold-release-repository.drizzle.js" -import type { AnonHoldReleaseRepository } from "../../src/services/anon-hold-release.js" +import type { AnonHoldReleaseRepository } from "../../src/services/anon-hold-release-repository.js" const pg = await withPg() diff --git a/services/media-worker/src/jobs/hold-release-sweep.ts b/services/media-worker/src/jobs/hold-release-sweep.ts index 653c6b30..253f6137 100644 --- a/services/media-worker/src/jobs/hold-release-sweep.ts +++ b/services/media-worker/src/jobs/hold-release-sweep.ts @@ -21,10 +21,8 @@ */ import type { AbuseChecks } from "@civfix/shared/interfaces" -import { - releaseAnonHoldIfReady, - type AnonHoldReleaseRepository, -} from "@civfix/api/anon-hold-release" +import { releaseAnonHoldIfReady } from "@civfix/api/anon-hold-release" +import type { AnonHoldReleaseRepository } from "@civfix/api/anon-hold-release-repository" import { ANON_HOLD_RELEASE_SWEEP_JOB } from "@civfix/api/queue-names" import { resolveJobObs, type JobObsDeps } from "./obs.js" diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index 647a817b..45d491c4 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -4,7 +4,7 @@ import type { MediaResultPatch, MediaWorkerAsset, MediaWorkerRepository, -} from "@civfix/api/media-repo" +} from "@civfix/api/media-worker-repository" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" import { settleWithin } from "@civfix/api/timeout" import type { WorkerLimits } from "../config.js" diff --git a/services/media-worker/src/jobs/media-pipeline.ts b/services/media-worker/src/jobs/media-pipeline.ts index 142cda26..764dbf1c 100644 --- a/services/media-worker/src/jobs/media-pipeline.ts +++ b/services/media-worker/src/jobs/media-pipeline.ts @@ -1,6 +1,6 @@ import type { AbuseChecks } from "@civfix/shared/interfaces" import type { MediaKind, MediaStatus } from "@civfix/shared" -import type { WorkerAbuseReason } from "@civfix/api/media-repo" +import type { WorkerAbuseReason } from "@civfix/api/media-worker-repository" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" import type { WorkerLimits } from "../config.js" import { ALLOWED_VIDEO_CODECS } from "../config.js" diff --git a/services/media-worker/src/jobs/orphan-sweep.ts b/services/media-worker/src/jobs/orphan-sweep.ts index 681aa4cf..7fd9527e 100644 --- a/services/media-worker/src/jobs/orphan-sweep.ts +++ b/services/media-worker/src/jobs/orphan-sweep.ts @@ -1,5 +1,9 @@ import type { Storage } from "@civfix/shared/interfaces" -import type { LeakedObjectRow, MediaWorkerRepository, OrphanRow } from "@civfix/api/media-repo" +import type { + LeakedObjectRow, + MediaWorkerRepository, + OrphanRow, +} from "@civfix/api/media-worker-repository" import type { WorkerLimits } from "../config.js" import { drainPages } from "./drain.js" import { resolveJobObs, type JobObsDeps, type JobLogFn, type JobReportFn } from "./obs.js" diff --git a/services/media-worker/src/jobs/reject-cleanup.ts b/services/media-worker/src/jobs/reject-cleanup.ts index 4e5a28e0..a167bcec 100644 --- a/services/media-worker/src/jobs/reject-cleanup.ts +++ b/services/media-worker/src/jobs/reject-cleanup.ts @@ -1,5 +1,5 @@ import type { Storage } from "@civfix/shared/interfaces" -import type { MediaWorkerRepository } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-worker-repository" import { MEDIA_CHECKS_JOB } from "@civfix/api/queue-names" import type { JobLogFn, JobReportFn } from "./obs.js" import { servedKey, thumbnailKey } from "./media-keys.js" diff --git a/services/media-worker/src/jobs/retention-sweep.ts b/services/media-worker/src/jobs/retention-sweep.ts index d9212666..9328b78c 100644 --- a/services/media-worker/src/jobs/retention-sweep.ts +++ b/services/media-worker/src/jobs/retention-sweep.ts @@ -4,8 +4,8 @@ import { INBOUND_EMAIL_RETENTION_BATCH, INBOUND_EMAIL_RETENTION_MS, makeDrizzleInboundRetentionRepository, - type InboundRetentionRepository, } from "@civfix/api/inbound-retention-repo" +import type { InboundRetentionRepository } from "@civfix/api/inbound-retention-repository" import { GEOCODE_CACHE_TTL_MS } from "@civfix/api/geocode-cache" import { makeDrizzleRetentionRepository } from "@civfix/api/retention-repo" import { drainPages } from "./drain.js" diff --git a/services/media-worker/src/jobs/stuck-sweep.ts b/services/media-worker/src/jobs/stuck-sweep.ts index 2d1e7edb..2e74f247 100644 --- a/services/media-worker/src/jobs/stuck-sweep.ts +++ b/services/media-worker/src/jobs/stuck-sweep.ts @@ -1,5 +1,6 @@ import type { Jobs, Storage } from "@civfix/shared/interfaces" -import type { MediaChecksJob, MediaWorkerRepository, StuckMediaRow } from "@civfix/api/media-repo" +import type { MediaChecksJob } from "@civfix/api/media-repo" +import type { MediaWorkerRepository, StuckMediaRow } from "@civfix/api/media-worker-repository" import { MEDIA_CHECKS_JOB, MEDIA_STUCK_SWEEP_JOB, diff --git a/services/media-worker/src/jobs/upload-reap.ts b/services/media-worker/src/jobs/upload-reap.ts index 07a0a5be..f87079b3 100644 --- a/services/media-worker/src/jobs/upload-reap.ts +++ b/services/media-worker/src/jobs/upload-reap.ts @@ -1,5 +1,5 @@ import type { Storage } from "@civfix/shared/interfaces" -import type { MediaWorkerRepository } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-worker-repository" import { R2_PUT_TTL_SEC } from "@civfix/api/adapters/storage" import { MEDIA_UPLOAD_REAP_JOB } from "@civfix/api/queue-names" import { resolveJobObs, type JobObsDeps } from "./obs.js" diff --git a/services/media-worker/src/seams.ts b/services/media-worker/src/seams.ts index ca96dd6a..b060d113 100644 --- a/services/media-worker/src/seams.ts +++ b/services/media-worker/src/seams.ts @@ -2,12 +2,10 @@ import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import type { AbuseChecks, Storage } from "@civfix/shared/interfaces" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" import { makeDb, type DbHandle } from "@civfix/api/db" -import { - makeDrizzleMediaWorkerRepository, - type MediaWorkerRepository, -} from "@civfix/api/media-repo" +import { makeDrizzleMediaWorkerRepository } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-worker-repository" +import type { AnonHoldReleaseRepository } from "@civfix/api/anon-hold-release-repository" import { makeDrizzleAnonHoldReleaseRepository } from "@civfix/api/anon-hold-repo" -import type { AnonHoldReleaseRepository } from "@civfix/api/anon-hold-release" import { R2Storage } from "@civfix/api/adapters/storage" import { LOCAL_STORAGE_DEV_SIGNING_KEY, LocalDiskStorage } from "@civfix/api/adapters/storage-local" import { captureError, initErrorReporting, flushErrorReporting } from "@civfix/api/errors" diff --git a/services/media-worker/src/worker.ts b/services/media-worker/src/worker.ts index 686b92bd..513bf46d 100644 --- a/services/media-worker/src/worker.ts +++ b/services/media-worker/src/worker.ts @@ -10,7 +10,8 @@ import { RETENTION_SWEEP_JOB, SHARED_QUEUE_POLICY, } from "@civfix/api/queue-names" -import { releaseAnonHoldIfReady, type HeldReportView } from "@civfix/api/anon-hold-release" +import { releaseAnonHoldIfReady } from "@civfix/api/anon-hold-release" +import type { HeldReportView } from "@civfix/api/anon-hold-release-repository" import { makeJobs, stopGraceMsFor, diff --git a/services/media-worker/test/helpers/in-memory-media-worker-repository.ts b/services/media-worker/test/helpers/in-memory-media-worker-repository.ts index 4beb5268..338fe256 100644 --- a/services/media-worker/test/helpers/in-memory-media-worker-repository.ts +++ b/services/media-worker/test/helpers/in-memory-media-worker-repository.ts @@ -7,7 +7,7 @@ import type { NewAbuseFlag, OrphanRow, StuckMediaRow, -} from "@civfix/api/media-repo" +} from "@civfix/api/media-worker-repository" export interface StoredWorkerMedia extends MediaWorkerAsset { createdAt: Date diff --git a/services/media-worker/test/integration/media-checks-pg.test.ts b/services/media-worker/test/integration/media-checks-pg.test.ts index e3e64afb..b86a249d 100644 --- a/services/media-worker/test/integration/media-checks-pg.test.ts +++ b/services/media-worker/test/integration/media-checks-pg.test.ts @@ -13,8 +13,8 @@ import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import { makeDrizzleMediaWorkerRepository, ensureNextMonthChatPartition, - type MediaWorkerRepository, } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-worker-repository" import { loadLimits } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" import { runMediaChecksJob } from "../../src/jobs/media-checks.js" diff --git a/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts b/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts index ca8845df..16b9960c 100644 --- a/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts +++ b/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts @@ -1,10 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { FakeStorage } from "@civfix/shared/fakes" -import { - makeDrizzleMediaWorkerRepository, - type MediaWorkerRepository, -} from "@civfix/api/media-repo" +import { makeDrizzleMediaWorkerRepository } from "@civfix/api/media-repo" +import type { MediaWorkerRepository } from "@civfix/api/media-worker-repository" import { loadLimits } from "../../src/config.js" import { runOrphanSweep } from "../../src/jobs/orphan-sweep.js" import { servedKey, thumbnailKey } from "../../src/jobs/media-keys.js" diff --git a/services/media-worker/test/unit/anon-hold-release.test.ts b/services/media-worker/test/unit/anon-hold-release.test.ts index aa21c009..85d087ab 100644 --- a/services/media-worker/test/unit/anon-hold-release.test.ts +++ b/services/media-worker/test/unit/anon-hold-release.test.ts @@ -12,7 +12,7 @@ import type { AnonHoldReleaseRepository, HeldReportView, ReleaseMediaView, -} from "@civfix/api/anon-hold-release" +} from "@civfix/api/anon-hold-release-repository" import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" diff --git a/services/media-worker/test/unit/retention-sweep.test.ts b/services/media-worker/test/unit/retention-sweep.test.ts index 79091f55..a1ecaafb 100644 --- a/services/media-worker/test/unit/retention-sweep.test.ts +++ b/services/media-worker/test/unit/retention-sweep.test.ts @@ -10,7 +10,7 @@ import { GEOCODE_CACHE_TTL_MS } from "@civfix/api/geocode-cache" import type { InboundRetentionRepository, ReapedInboundEmail, -} from "@civfix/api/inbound-retention-repo" +} from "@civfix/api/inbound-retention-repository" import type { Sql } from "@civfix/api/db" function makeSqlSpy(results: Array): { diff --git a/services/media-worker/test/unit/worker.test.ts b/services/media-worker/test/unit/worker.test.ts index e0edccbe..f4099669 100644 --- a/services/media-worker/test/unit/worker.test.ts +++ b/services/media-worker/test/unit/worker.test.ts @@ -21,7 +21,10 @@ import { loadLimits, } from "../../src/config.js" import { makeDownloader } from "../../src/download.js" -import type { AnonHoldReleaseRepository, HeldReportView } from "@civfix/api/anon-hold-release" +import type { + AnonHoldReleaseRepository, + HeldReportView, +} from "@civfix/api/anon-hold-release-repository" import { FakeJobs, FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import { InMemoryMediaWorkerRepository } from "../helpers/in-memory-media-worker-repository.js" import * as fx from "../fixtures/make.js" From 0bcdc08cedf428cdf18c3407bdf17033ff8054e2 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 17:18:06 +0000 Subject: [PATCH 17/20] test-only in-memory repositories move to test helpers --- .../admin/activity-repository.memory.ts | 8 +++--- .../admin/admin-event-repository.memory.ts | 25 ++++++++++-------- .../admin-report-chat-repository.memory.ts | 2 +- .../admin/admin-report-repository.memory.ts | 17 +++++++----- .../admin/admin-user-repository.memory.ts | 4 +-- .../admin/analytics-repository.memory.ts | 2 +- .../helpers}/admin/audit-repository.memory.ts | 8 ++++-- .../admin/discovery-repository.memory.ts | 6 ++--- .../forward-template-repository.memory.ts | 2 +- .../admin/gov-claims-repository.memory.ts | 9 ++++--- .../helpers}/admin/home-repository.memory.ts | 2 +- .../admin/inbound-repository.memory.ts | 13 ++++++---- ...jurisdiction-contacts-repository.memory.ts | 10 +++---- .../helpers}/admin/mail-repository.memory.ts | 18 ++++++++----- .../admin/moderation-repository.memory.ts | 10 +++---- .../admin/outreach-repository.memory.ts | 9 ++++--- .../helpers}/certificate-repository.memory.ts | 9 +++---- services/api/test/helpers/guest-rsvp.ts | 2 +- .../host/broadcast-repository.memory.ts | 14 +++++----- .../host/host-team-repository.memory.ts | 12 ++++----- .../host/organization-repository.memory.ts | 8 +++--- .../host/registration-repository.memory.ts | 16 ++++++------ .../volunteer-hours-repository.memory.ts | 12 ++++----- services/api/test/unit/admin-activity.test.ts | 2 +- .../api/test/unit/admin-analytics.test.ts | 2 +- services/api/test/unit/admin-audit.test.ts | 2 +- .../api/test/unit/admin-discovery.test.ts | 2 +- .../test/unit/admin-event-correctness.test.ts | 2 +- services/api/test/unit/admin-events.test.ts | 2 +- .../api/test/unit/admin-gov-claims.test.ts | 2 +- .../api/test/unit/admin-guard-cache.test.ts | 2 +- services/api/test/unit/admin-home.test.ts | 4 +-- .../unit/admin-host-platform-routes.test.ts | 6 ++--- .../unit/admin-inbound-bounce-verdict.test.ts | 4 +-- services/api/test/unit/admin-inbox.test.ts | 2 +- .../unit/admin-jurisdiction-contacts.test.ts | 6 ++--- .../test/unit/admin-mail-repository.test.ts | 2 +- services/api/test/unit/admin-mail.test.ts | 2 +- .../test/unit/admin-message-broadcast.test.ts | 6 ++--- .../unit/admin-moderation-correctness.test.ts | 2 +- .../api/test/unit/admin-moderation.test.ts | 2 +- .../test/unit/admin-outreach-claim.test.ts | 4 +-- .../api/test/unit/admin-report-chat.test.ts | 2 +- .../unit/admin-report-correctness.test.ts | 4 +-- services/api/test/unit/admin-reports.test.ts | 6 ++--- .../api/test/unit/admin-routes-http.test.ts | 26 +++++++++---------- services/api/test/unit/admin-users.test.ts | 2 +- .../unit/broadcast-pipeline-security.test.ts | 2 +- .../broadcast-repository-security.test.ts | 2 +- .../unit/broadcast-service-security.test.ts | 2 +- .../api/test/unit/certificate-routes.test.ts | 4 +-- .../test/unit/certificate-verify-url.test.ts | 2 +- services/api/test/unit/discovery-flag.test.ts | 2 +- .../api/test/unit/exposed-5xx-copy.test.ts | 2 +- .../unit/forward-template-service.test.ts | 2 +- services/api/test/unit/guest-jobs.test.ts | 2 +- .../api/test/unit/guest-rsvp-service.test.ts | 2 +- .../test/unit/host-broadcast-audience.test.ts | 2 +- .../api/test/unit/host-broadcast-caps.test.ts | 2 +- .../test/unit/host-broadcast-pipeline.test.ts | 2 +- .../host-broadcast-send-correctness.test.ts | 2 +- services/api/test/unit/host-insights.test.ts | 2 +- .../host-metrics-rollup-correctness.test.ts | 2 +- services/api/test/unit/host-reminders.test.ts | 2 +- .../test/unit/host-unsubscribe-route.test.ts | 2 +- .../unit/host/announcement-pipeline.test.ts | 2 +- .../host/announcement-send-failure.test.ts | 2 +- .../unit/host/announcement-service.test.ts | 2 +- .../test/unit/host/checkin-service.test.ts | 2 +- .../unit/host/event-ban-consistency.test.ts | 2 +- .../api/test/unit/host/host-routes.test.ts | 2 +- .../host/host-service-correctness.test.ts | 2 +- .../host/host-team-service-security.test.ts | 2 +- .../test/unit/host/host-team-service.test.ts | 2 +- .../host/insights-invalidation-wiring.test.ts | 2 +- .../test/unit/host/org-invite-inbox.test.ts | 2 +- .../test/unit/host/org-team-routes.test.ts | 4 +-- ...rganization-repository-correctness.test.ts | 2 +- .../organization-service-security.test.ts | 2 +- .../unit/host/organization-service.test.ts | 2 +- .../host/page-service-correctness.test.ts | 2 +- .../api/test/unit/host/page-service.test.ts | 2 +- .../registration-repository-security.test.ts | 2 +- .../unit/host/registration-service.test.ts | 2 +- .../api/test/unit/host/signup-seats.test.ts | 2 +- .../unit/host/ticket-type-service.test.ts | 2 +- .../test/unit/host/waitlist-service.test.ts | 2 +- .../test/unit/inbound-bounce-redrive.test.ts | 4 +-- .../unit/inbound-event-reply-note.test.ts | 2 +- .../unit/inbound-mail-authentication.test.ts | 2 +- .../test/unit/inbound-mail-webhook.test.ts | 4 +-- .../unit/inbound-processor-hardening.test.ts | 4 +-- .../unit/inbound-processor-security.test.ts | 6 ++--- .../api/test/unit/inbound-processor.test.ts | 6 ++--- services/api/test/unit/inbound-sweep.test.ts | 6 ++--- ...nbound-thread-correlation-security.test.ts | 6 ++--- .../unit/moderation-operator-guard.test.ts | 2 +- .../test/unit/official-account-guards.test.ts | 4 +-- .../test/unit/outbound-mail-service.test.ts | 2 +- .../api/test/unit/outreach-pipeline.test.ts | 4 +-- .../api/test/unit/packet-media-links.test.ts | 4 +-- .../test/unit/report-content-routes.test.ts | 2 +- .../api/test/unit/report-verification.test.ts | 6 ++--- services/api/test/unit/route-coverage.test.ts | 8 +++--- .../unit/unsubscribe-routes-security.test.ts | 2 +- .../test/unit/volunteer-hours-entries.test.ts | 2 +- ...olunteer-hours-leaderboard-ceiling.test.ts | 2 +- .../volunteer-hours-service-security.test.ts | 2 +- .../test/unit/volunteer-hours-service.test.ts | 2 +- .../test/unit/web-base-url-fallback.test.ts | 4 +-- 110 files changed, 253 insertions(+), 231 deletions(-) rename services/api/{src/services => test/helpers}/admin/activity-repository.memory.ts (88%) rename services/api/{src/services => test/helpers}/admin/admin-event-repository.memory.ts (95%) rename services/api/{src/services => test/helpers}/admin/admin-report-chat-repository.memory.ts (96%) rename services/api/{src/services => test/helpers}/admin/admin-report-repository.memory.ts (96%) rename services/api/{src/services => test/helpers}/admin/admin-user-repository.memory.ts (98%) rename services/api/{src/services => test/helpers}/admin/analytics-repository.memory.ts (97%) rename services/api/{src/services => test/helpers}/admin/audit-repository.memory.ts (90%) rename services/api/{src/services => test/helpers}/admin/discovery-repository.memory.ts (97%) rename services/api/{src/services => test/helpers}/admin/forward-template-repository.memory.ts (94%) rename services/api/{src/services => test/helpers}/admin/gov-claims-repository.memory.ts (95%) rename services/api/{src/services => test/helpers}/admin/home-repository.memory.ts (97%) rename services/api/{src/services => test/helpers}/admin/inbound-repository.memory.ts (90%) rename services/api/{src/services => test/helpers}/admin/jurisdiction-contacts-repository.memory.ts (97%) rename services/api/{src/services => test/helpers}/admin/mail-repository.memory.ts (97%) rename services/api/{src/services => test/helpers}/admin/moderation-repository.memory.ts (96%) rename services/api/{src/services => test/helpers}/admin/outreach-repository.memory.ts (94%) rename services/api/{src/services => test/helpers}/certificate-repository.memory.ts (94%) rename services/api/{src/services => test/helpers}/host/broadcast-repository.memory.ts (98%) rename services/api/{src/services => test/helpers}/host/host-team-repository.memory.ts (97%) rename services/api/{src/services => test/helpers}/host/organization-repository.memory.ts (99%) rename services/api/{src/services => test/helpers}/host/registration-repository.memory.ts (98%) rename services/api/{src/services => test/helpers}/volunteer-hours-repository.memory.ts (98%) diff --git a/services/api/src/services/admin/activity-repository.memory.ts b/services/api/test/helpers/admin/activity-repository.memory.ts similarity index 88% rename from services/api/src/services/admin/activity-repository.memory.ts rename to services/api/test/helpers/admin/activity-repository.memory.ts index d8ae1099..3a8357e7 100644 --- a/services/api/src/services/admin/activity-repository.memory.ts +++ b/services/api/test/helpers/admin/activity-repository.memory.ts @@ -7,14 +7,14 @@ * and the service's classifier ever disagree. */ -import { AUDIT_READ_ACTIONS } from "./audit.js" -import { pageInMemoryById } from "./pagination.js" -import { classifyActivity } from "./activity-service.js" +import { AUDIT_READ_ACTIONS } from "../../../src/services/admin/audit.js" +import { pageInMemoryById } from "../../../src/services/admin/pagination.js" +import { classifyActivity } from "../../../src/services/admin/activity-service.js" import type { ActivityRepository, ActivitySourceRecord, ListActivityArgs, -} from "./activity-repository.js" +} from "../../../src/services/admin/activity-repository.js" const READ_ACTIONS: ReadonlySet = new Set(AUDIT_READ_ACTIONS) diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/test/helpers/admin/admin-event-repository.memory.ts similarity index 95% rename from services/api/src/services/admin/admin-event-repository.memory.ts rename to services/api/test/helpers/admin/admin-event-repository.memory.ts index 2ef35abc..6378ff8b 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/test/helpers/admin/admin-event-repository.memory.ts @@ -6,8 +6,8 @@ */ import { randomUUID } from "node:crypto" -import { isUuid } from "../../db/cursor-helpers.js" -import { pageInMemoryById } from "./pagination.js" +import { isUuid } from "../../../src/db/cursor-helpers.js" +import { pageInMemoryById } from "../../../src/services/admin/pagination.js" import { ADMIN_EVENT_MESSAGE_CAP, EVENT_NOTE_FLAGGED, @@ -15,13 +15,16 @@ import { EVENT_NOTE_UNFLAGGED, eventOutcomeNote, flaggedFromTimeline, -} from "./admin-event-helpers.js" -import { isPubliclyVisibleStatus } from "../report-visibility.js" -import { toEventStatus } from "./event-status.js" -import { CIVFIX_OFFICIAL_DISPLAY_NAME } from "../../auth/official-account.js" -import { DEFAULT_EVENT_DURATION_MS, deriveCleanupStatus } from "../cleanup-rules.js" -import { MS_PER_HOUR } from "../../lib/time.js" -import type { EventMemberRef } from "./admin-event-service.js" +} from "../../../src/services/admin/admin-event-helpers.js" +import { isPubliclyVisibleStatus } from "../../../src/services/report-visibility.js" +import { toEventStatus } from "../../../src/services/admin/event-status.js" +import { CIVFIX_OFFICIAL_DISPLAY_NAME } from "../../../src/auth/official-account.js" +import { + DEFAULT_EVENT_DURATION_MS, + deriveCleanupStatus, +} from "../../../src/services/cleanup-rules.js" +import { MS_PER_HOUR } from "../../../src/lib/time.js" +import type { EventMemberRef } from "../../../src/services/admin/admin-event-service.js" import type { AdminEventMessageRecord, AdminEventRecord, @@ -29,8 +32,8 @@ import type { AdminEventTimelineRecord, AdminOrganizerRecord, ListEventsArgs, -} from "./admin-event-repository.js" -import type { LinkedReportView } from "../cleanup-service.js" +} from "../../../src/services/admin/admin-event-repository.js" +import type { LinkedReportView } from "../../../src/services/cleanup-service.js" import type { AdminEventCounts, EventKind, EventStatus, ReportCategory } from "@civfix/shared" const IN_PROGRESS_SEED_OFFSET_MS = -MS_PER_HOUR diff --git a/services/api/src/services/admin/admin-report-chat-repository.memory.ts b/services/api/test/helpers/admin/admin-report-chat-repository.memory.ts similarity index 96% rename from services/api/src/services/admin/admin-report-chat-repository.memory.ts rename to services/api/test/helpers/admin/admin-report-chat-repository.memory.ts index e3a65526..3d1a9028 100644 --- a/services/api/src/services/admin/admin-report-chat-repository.memory.ts +++ b/services/api/test/helpers/admin/admin-report-chat-repository.memory.ts @@ -1,7 +1,7 @@ import type { AdminReportChatRepository, RemoveReportMessageInput, -} from "./admin-report-chat-repository.js" +} from "../../../src/services/admin/admin-report-chat-repository.js" export interface MemoryReportChatMessage { id: string diff --git a/services/api/src/services/admin/admin-report-repository.memory.ts b/services/api/test/helpers/admin/admin-report-repository.memory.ts similarity index 96% rename from services/api/src/services/admin/admin-report-repository.memory.ts rename to services/api/test/helpers/admin/admin-report-repository.memory.ts index 2e10a59f..4ed1cdea 100644 --- a/services/api/src/services/admin/admin-report-repository.memory.ts +++ b/services/api/test/helpers/admin/admin-report-repository.memory.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" -import { isUuid } from "../../db/cursor-helpers.js" -import { pageInMemoryById } from "./pagination.js" +import { isUuid } from "../../../src/db/cursor-helpers.js" +import { pageInMemoryById } from "../../../src/services/admin/pagination.js" import type { AdminReportMediaRecord, AdminReportRecord, @@ -10,7 +10,7 @@ import type { AdminReporterRecord, ListReportsArgs, ReportOutreachState, -} from "./admin-report-repository.js" +} from "../../../src/services/admin/admin-report-repository.js" import type { AdminReportCounts, AdminReportStatus, @@ -18,10 +18,13 @@ import type { ReportTimelineItem, ReportVisibility, } from "@civfix/shared" -import { mapOutreachStatus } from "./admin-report-repository.drizzle.js" -import { isPacketKind, type MailMessageKind } from "./mail-repository.js" -import { pickPreviewMedia, REPORT_VERIFIED_THRESHOLD } from "./admin-report-types.js" -import { STATUS_BUCKETS } from "./admin-report-status.js" +import { mapOutreachStatus } from "../../../src/services/admin/admin-report-repository.drizzle.js" +import { isPacketKind, type MailMessageKind } from "../../../src/services/admin/mail-repository.js" +import { + pickPreviewMedia, + REPORT_VERIFIED_THRESHOLD, +} from "../../../src/services/admin/admin-report-types.js" +import { STATUS_BUCKETS } from "../../../src/services/admin/admin-report-status.js" export interface RecordedAudit { action: string diff --git a/services/api/src/services/admin/admin-user-repository.memory.ts b/services/api/test/helpers/admin/admin-user-repository.memory.ts similarity index 98% rename from services/api/src/services/admin/admin-user-repository.memory.ts rename to services/api/test/helpers/admin/admin-user-repository.memory.ts index 883c00a3..d086b285 100644 --- a/services/api/src/services/admin/admin-user-repository.memory.ts +++ b/services/api/test/helpers/admin/admin-user-repository.memory.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto" -import { pageInMemoryById } from "./pagination.js" +import { pageInMemoryById } from "../../../src/services/admin/pagination.js" import type { AdminUserOrganizationRecord, AdminUserRecord, @@ -8,7 +8,7 @@ import type { UserEventRecord, UserMessageRecord, UserReportRecord, -} from "./admin-user-repository.js" +} from "../../../src/services/admin/admin-user-repository.js" import type { AdminUserCounts, Role, UserStatus } from "@civfix/shared" export interface RecordedUserAudit { diff --git a/services/api/src/services/admin/analytics-repository.memory.ts b/services/api/test/helpers/admin/analytics-repository.memory.ts similarity index 97% rename from services/api/src/services/admin/analytics-repository.memory.ts rename to services/api/test/helpers/admin/analytics-repository.memory.ts index f96b2036..70bab473 100644 --- a/services/api/src/services/admin/analytics-repository.memory.ts +++ b/services/api/test/helpers/admin/analytics-repository.memory.ts @@ -17,7 +17,7 @@ import type { TopContributorRow, TopJurisdictionRow, WeekBucket, -} from "./analytics-repository.js" +} from "../../../src/services/admin/analytics-repository.js" /** All zero, so a test only sets what it asserts. */ function zeroKpis(): KpiAggregates { diff --git a/services/api/src/services/admin/audit-repository.memory.ts b/services/api/test/helpers/admin/audit-repository.memory.ts similarity index 90% rename from services/api/src/services/admin/audit-repository.memory.ts rename to services/api/test/helpers/admin/audit-repository.memory.ts index e6111e97..a78fd748 100644 --- a/services/api/src/services/admin/audit-repository.memory.ts +++ b/services/api/test/helpers/admin/audit-repository.memory.ts @@ -1,5 +1,9 @@ -import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-repository.js" -import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" +import type { + AuditRecord, + AuditRepository, + ListAuditArgs, +} from "../../../src/services/admin/audit-repository.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../../src/db/cursor-helpers.js" export interface SeedAuditInput { id?: string diff --git a/services/api/src/services/admin/discovery-repository.memory.ts b/services/api/test/helpers/admin/discovery-repository.memory.ts similarity index 97% rename from services/api/src/services/admin/discovery-repository.memory.ts rename to services/api/test/helpers/admin/discovery-repository.memory.ts index 8fb36a76..fbdef9ae 100644 --- a/services/api/src/services/admin/discovery-repository.memory.ts +++ b/services/api/test/helpers/admin/discovery-repository.memory.ts @@ -2,8 +2,8 @@ // task record rather than recomputed, since the Drizzle repository derives them in SQL. import { randomUUID } from "node:crypto" -import { pageInMemoryById } from "./pagination.js" -import { computeContactState } from "./discovery-service.js" +import { pageInMemoryById } from "../../../src/services/admin/pagination.js" +import { computeContactState } from "../../../src/services/admin/discovery-service.js" import type { DiscoveryContactRecord, DiscoveryContactSuggestionRecord, @@ -13,7 +13,7 @@ import type { DiscoverySamplePinRecord, DiscoveryTaskRecord, ListDiscoveryArgs, -} from "./discovery-repository.js" +} from "../../../src/services/admin/discovery-repository.js" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" export interface SeededDiscoveryTask { diff --git a/services/api/src/services/admin/forward-template-repository.memory.ts b/services/api/test/helpers/admin/forward-template-repository.memory.ts similarity index 94% rename from services/api/src/services/admin/forward-template-repository.memory.ts rename to services/api/test/helpers/admin/forward-template-repository.memory.ts index 0ed3b919..8f17b8a7 100644 --- a/services/api/src/services/admin/forward-template-repository.memory.ts +++ b/services/api/test/helpers/admin/forward-template-repository.memory.ts @@ -2,7 +2,7 @@ import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, SetForwardTemplateInput, -} from "./forward-template-repository.js" +} from "../../../src/services/admin/forward-template-repository.js" export class InMemoryForwardTemplateRepository implements ForwardTemplateRepository { record: ForwardTemplateSettingsRecord | null = null diff --git a/services/api/src/services/admin/gov-claims-repository.memory.ts b/services/api/test/helpers/admin/gov-claims-repository.memory.ts similarity index 95% rename from services/api/src/services/admin/gov-claims-repository.memory.ts rename to services/api/test/helpers/admin/gov-claims-repository.memory.ts index b9d7488b..d58b291e 100644 --- a/services/api/src/services/admin/gov-claims-repository.memory.ts +++ b/services/api/test/helpers/admin/gov-claims-repository.memory.ts @@ -1,14 +1,17 @@ // Mirrors the Drizzle repository's observable behavior. import { randomUUID } from "node:crypto" -import { pageInMemoryById } from "./pagination.js" -import { type ProvisionedUser, type UserProvisioner } from "./gov-claims-service.js" +import { pageInMemoryById } from "../../../src/services/admin/pagination.js" +import { + type ProvisionedUser, + type UserProvisioner, +} from "../../../src/services/admin/gov-claims-service.js" import type { GovCheckRecord, GovClaimRecord, GovClaimsRepository, ListGovClaimsArgs, -} from "./gov-claims-repository.js" +} from "../../../src/services/admin/gov-claims-repository.js" import type { GovCheckStatus, GovVerificationCheck, Role } from "@civfix/shared" export class InMemoryGovClaimsRepository implements GovClaimsRepository { diff --git a/services/api/src/services/admin/home-repository.memory.ts b/services/api/test/helpers/admin/home-repository.memory.ts similarity index 97% rename from services/api/src/services/admin/home-repository.memory.ts rename to services/api/test/helpers/admin/home-repository.memory.ts index 4d745391..1626435c 100644 --- a/services/api/src/services/admin/home-repository.memory.ts +++ b/services/api/test/helpers/admin/home-repository.memory.ts @@ -6,7 +6,7 @@ import type { MailSectionCounts, ReportsSectionCounts, UsersSectionCounts, -} from "./home-repository.js" +} from "../../../src/services/admin/home-repository.js" export class InMemoryHomeRepository implements HomeRepository { discoveryValue: DiscoverySectionCounts = { queue: 0, reportsWaiting: 0, overSla: 0 } diff --git a/services/api/src/services/admin/inbound-repository.memory.ts b/services/api/test/helpers/admin/inbound-repository.memory.ts similarity index 90% rename from services/api/src/services/admin/inbound-repository.memory.ts rename to services/api/test/helpers/admin/inbound-repository.memory.ts index 871752bc..3295a826 100644 --- a/services/api/src/services/admin/inbound-repository.memory.ts +++ b/services/api/test/helpers/admin/inbound-repository.memory.ts @@ -1,9 +1,12 @@ import { randomUUID } from "node:crypto" -import { clampLimit } from "./pagination.js" -import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" -import { localPartOf } from "./inbound-repository.drizzle.js" -import type { InboundEmailInsert, InboundRepository } from "./inbound-repository.js" -import { toPreview } from "./mail-preview.js" +import { clampLimit } from "../../../src/services/admin/pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../../src/db/cursor-helpers.js" +import { localPartOf } from "../../../src/services/admin/inbound-repository.drizzle.js" +import type { + InboundEmailInsert, + InboundRepository, +} from "../../../src/services/admin/inbound-repository.js" +import { toPreview } from "../../../src/services/admin/mail-preview.js" import type { InboundEmailDTO, InboundEmailListItemDTO, diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts b/services/api/test/helpers/admin/jurisdiction-contacts-repository.memory.ts similarity index 97% rename from services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts rename to services/api/test/helpers/admin/jurisdiction-contacts-repository.memory.ts index f4c9cf83..651225ec 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts +++ b/services/api/test/helpers/admin/jurisdiction-contacts-repository.memory.ts @@ -1,12 +1,12 @@ import { randomUUID } from "node:crypto" -import { clampLimit } from "./pagination.js" -import { decodeOffsetCursor, encodeOffsetCursor } from "../../db/cursor-helpers.js" -import type { OutreachStateRecord } from "./mail-repository.drizzle.js" +import { clampLimit } from "../../../src/services/admin/pagination.js" +import { decodeOffsetCursor, encodeOffsetCursor } from "../../../src/db/cursor-helpers.js" +import type { OutreachStateRecord } from "../../../src/services/admin/mail-repository.drizzle.js" import { buildUnmappedRecord, directoryMethod, shouldIncludeUnmapped, -} from "./jurisdiction-directory-projection.js" +} from "../../../src/services/admin/jurisdiction-directory-projection.js" import type { JurisdictionContactsRepository, JurisdictionDirectoryRecord, @@ -15,7 +15,7 @@ import type { ListDirectoryResult, PatchContactsInput, SaveContactsInput, -} from "./jurisdiction-contacts-repository.js" +} from "../../../src/services/admin/jurisdiction-contacts-repository.js" import { AppError } from "@civfix/shared" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" diff --git a/services/api/src/services/admin/mail-repository.memory.ts b/services/api/test/helpers/admin/mail-repository.memory.ts similarity index 97% rename from services/api/src/services/admin/mail-repository.memory.ts rename to services/api/test/helpers/admin/mail-repository.memory.ts index 876e3d59..c2fa8fe0 100644 --- a/services/api/src/services/admin/mail-repository.memory.ts +++ b/services/api/test/helpers/admin/mail-repository.memory.ts @@ -22,16 +22,20 @@ import { type RecordEventInput, type RecordSendFailureInput, type ThreadInit, -} from "./mail-repository.js" -import { mintThreadToken, toMessageDTO, toThreadListItem } from "./mail-mappers.js" -import { buildMailStats } from "./mail-stats.js" +} from "../../../src/services/admin/mail-repository.js" +import { + mintThreadToken, + toMessageDTO, + toThreadListItem, +} from "../../../src/services/admin/mail-mappers.js" +import { buildMailStats } from "../../../src/services/admin/mail-stats.js" import { ROUTE_CLAIM_STALE_SECONDS, ROUTE_DEADLINE_INFLIGHT_SECONDS, -} from "./outbound-send-policy.js" -import { clampLimit } from "./pagination.js" -import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" -import { MS_PER_DAY } from "../../lib/time.js" +} from "../../../src/services/admin/outbound-send-policy.js" +import { clampLimit } from "../../../src/services/admin/pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../../src/db/cursor-helpers.js" +import { MS_PER_DAY } from "../../../src/lib/time.js" import type { MailDelivery, MailStatsResponse, MailStatus, MailThreadDTO } from "@civfix/shared" export interface StoredMailEvent { diff --git a/services/api/src/services/admin/moderation-repository.memory.ts b/services/api/test/helpers/admin/moderation-repository.memory.ts similarity index 96% rename from services/api/src/services/admin/moderation-repository.memory.ts rename to services/api/test/helpers/admin/moderation-repository.memory.ts index 90609f54..e0916052 100644 --- a/services/api/src/services/admin/moderation-repository.memory.ts +++ b/services/api/test/helpers/admin/moderation-repository.memory.ts @@ -1,17 +1,17 @@ import { randomUUID } from "node:crypto" -import { clampLimit } from "./pagination.js" -import { pageBeforeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" +import { clampLimit } from "../../../src/services/admin/pagination.js" +import { pageBeforeTimeCursor, parseKeysetCursor } from "../../../src/db/cursor-helpers.js" import { assertTargetIsNotOfficialAccount, assertTargetIsNotOperatorRole, -} from "../../auth/operator-target.js" -import { isMessageSubject, isUserSubject } from "./moderation-service.js" +} from "../../../src/auth/operator-target.js" +import { isMessageSubject, isUserSubject } from "../../../src/services/admin/moderation-service.js" import type { CreateModerationItemInput, ListModerationArgs, ModerationItemRecord, ModerationRepository, -} from "./moderation-repository.js" +} from "../../../src/services/admin/moderation-repository.js" export interface SeededHeldReport { id: string diff --git a/services/api/src/services/admin/outreach-repository.memory.ts b/services/api/test/helpers/admin/outreach-repository.memory.ts similarity index 94% rename from services/api/src/services/admin/outreach-repository.memory.ts rename to services/api/test/helpers/admin/outreach-repository.memory.ts index f71105bd..8fa7d76e 100644 --- a/services/api/src/services/admin/outreach-repository.memory.ts +++ b/services/api/test/helpers/admin/outreach-repository.memory.ts @@ -1,7 +1,10 @@ import { randomUUID } from "node:crypto" -import { OUTREACH_CATEGORIES } from "./outreach-service.js" -import type { OutreachDigest, OutreachRepository } from "./outreach-repository.js" -import type { OutreachStateRecord } from "./mail-repository.js" +import { OUTREACH_CATEGORIES } from "../../../src/services/admin/outreach-service.js" +import type { + OutreachDigest, + OutreachRepository, +} from "../../../src/services/admin/outreach-repository.js" +import type { OutreachStateRecord } from "../../../src/services/admin/mail-repository.js" import type { ReportCategory } from "@civfix/shared" interface SeededOutreachJurisdiction { diff --git a/services/api/src/services/certificate-repository.memory.ts b/services/api/test/helpers/certificate-repository.memory.ts similarity index 94% rename from services/api/src/services/certificate-repository.memory.ts rename to services/api/test/helpers/certificate-repository.memory.ts index 7655d59d..d8633e76 100644 --- a/services/api/src/services/certificate-repository.memory.ts +++ b/services/api/test/helpers/certificate-repository.memory.ts @@ -1,8 +1,7 @@ /** * Behavioural in-memory twin of the Drizzle `CertificateRepository`. * - * It lives beside its Drizzle counterpart (the same placement as - * `volunteer-hours-repository.memory.ts`) because the ONLY thing keeping the two honest is that they are + * Keep it in step with its Drizzle counterpart: the ONLY thing keeping the two honest is that they are * read side by side: `certificate-routes.test.ts` runs the whole route surface against this class with no * Docker, so any rule the SQL enforces and this class does not is invisible to CI until production. * @@ -18,15 +17,15 @@ * `snapshot` is stored but never read back, exactly as in production. */ -import { CertificateConflictError } from "./certificate-service.js" +import { CertificateConflictError } from "../../src/services/certificate-service.js" import type { CertificateHolder, CertificateInsert, CertificateRepository, CertificateRow, CertificateVerifyRow, -} from "./certificate-repository.js" -import type { TranscriptModel } from "./certificate-model.js" +} from "../../src/services/certificate-repository.js" +import type { TranscriptModel } from "../../src/services/certificate-model.js" /** Mirrors the `users.locale` column default. */ const DEFAULT_HOLDER_LOCALE = "en" diff --git a/services/api/test/helpers/guest-rsvp.ts b/services/api/test/helpers/guest-rsvp.ts index bdc1f113..42332473 100644 --- a/services/api/test/helpers/guest-rsvp.ts +++ b/services/api/test/helpers/guest-rsvp.ts @@ -11,7 +11,7 @@ import type { InsertGuestOtpArgs, UpsertGuestArgs, } from "../../src/services/guest-rsvp-repository.js" -import type { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" +import type { InMemoryHostRegistrationRepository } from "./host/registration-repository.memory.js" import type { GuestCountSource } from "./cleanups.js" export interface StoredGuest { diff --git a/services/api/src/services/host/broadcast-repository.memory.ts b/services/api/test/helpers/host/broadcast-repository.memory.ts similarity index 98% rename from services/api/src/services/host/broadcast-repository.memory.ts rename to services/api/test/helpers/host/broadcast-repository.memory.ts index cc8d5ddb..eab87b3b 100644 --- a/services/api/src/services/host/broadcast-repository.memory.ts +++ b/services/api/test/helpers/host/broadcast-repository.memory.ts @@ -11,11 +11,11 @@ import type { DeliveryListQuery, DeliveryListRow, KeysetRow, -} from "./broadcast-repository.js" -import { DEFAULT_PREFS } from "../notification-helpers.js" -import type { NotificationPrefsRecord } from "../notification-repository.js" -import type { WriteAuditInput } from "../admin/audit.js" -import type { KeysetCursor } from "../../db/cursor-helpers.js" +} from "../../../src/services/host/broadcast-repository.js" +import { DEFAULT_PREFS } from "../../../src/services/notification-helpers.js" +import type { NotificationPrefsRecord } from "../../../src/services/notification-repository.js" +import type { WriteAuditInput } from "../../../src/services/admin/audit.js" +import type { KeysetCursor } from "../../../src/db/cursor-helpers.js" import type { AdminBroadcastRow, AdminHostListParams, @@ -32,13 +32,13 @@ import type { GuestContact, HostMessagingState, MemberContact, -} from "./broadcast-types.js" +} from "../../../src/services/host/broadcast-types.js" import { ANNOUNCEMENT_VISIBLE_STATUSES, CRITICAL_BROADCAST_KINDS, DEFAULT_BROADCAST_CHUNK_SIZE, HOST_COMPOSED_BROADCAST_KINDS, -} from "./broadcast-types.js" +} from "../../../src/services/host/broadcast-types.js" interface DeliveryRow extends DeliveryRowInput { id: string diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/test/helpers/host/host-team-repository.memory.ts similarity index 97% rename from services/api/src/services/host/host-team-repository.memory.ts rename to services/api/test/helpers/host/host-team-repository.memory.ts index 4b29a50b..e60f89a4 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/test/helpers/host/host-team-repository.memory.ts @@ -12,9 +12,9 @@ import { isBeforeTimeCursor, pageWith, parseTimeCursor, -} from "../../db/cursor-helpers.js" -import { eventWindowOf, hasEventEnded } from "../cleanup-rules.js" -import type { CleanupPersonView } from "../cleanup-repository.js" +} from "../../../src/db/cursor-helpers.js" +import { eventWindowOf, hasEventEnded } from "../../../src/services/cleanup-rules.js" +import type { CleanupPersonView } from "../../../src/services/cleanup-repository.js" import type { AcceptTeamInviteByIdOutcome, AcceptTeamInviteOutcome, @@ -29,9 +29,9 @@ import type { OpenTeamInviteQuery, PendingInviteForUserRecord, RevokeTeamInviteOutcome, -} from "./host-team-repository.js" -import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.js" -import { MS_PER_DAY } from "../../lib/time.js" +} from "../../../src/services/host/host-team-repository.js" +import { TEAM_INVITE_CAP_MESSAGE } from "../../../src/services/host/host-team-repository.js" +import { MS_PER_DAY } from "../../../src/lib/time.js" interface StoredInvite { id: string diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/test/helpers/host/organization-repository.memory.ts similarity index 99% rename from services/api/src/services/host/organization-repository.memory.ts rename to services/api/test/helpers/host/organization-repository.memory.ts index 851f4be8..2b302c34 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/test/helpers/host/organization-repository.memory.ts @@ -47,14 +47,14 @@ import type { UpdateOrganizationPatch, InviterRevocationReason, InviterStanding, -} from "./organization-repository.js" +} from "../../../src/services/host/organization-repository.js" import { canManageOrgMembers, inviterRevocationReason, roleChangeWithdrawsInvites, -} from "./organization-repository.js" -import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.js" -import { encodeTimeCursor } from "../../db/cursor-helpers.js" +} from "../../../src/services/host/organization-repository.js" +import { ORG_INVITE_CAP_MESSAGE } from "../../../src/services/host/organization-repository.js" +import { encodeTimeCursor } from "../../../src/db/cursor-helpers.js" const SEED_DISPLAY_NAME = "Member" diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/test/helpers/host/registration-repository.memory.ts similarity index 98% rename from services/api/src/services/host/registration-repository.memory.ts rename to services/api/test/helpers/host/registration-repository.memory.ts index 33016fc2..b5fbb5ab 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/test/helpers/host/registration-repository.memory.ts @@ -5,27 +5,27 @@ import { pageBeforeTimeCursor, pageWith, parseTimeCursor, -} from "../../db/cursor-helpers.js" +} from "../../../src/db/cursor-helpers.js" import { DEFAULT_EVENT_DURATION_MS, eventEndsAtMs, eventWindowOf, hasEventEnded, -} from "../cleanup-rules.js" +} from "../../../src/services/cleanup-rules.js" import { LIVE_TAIL_MS } from "@civfix/shared/host" import { ARRIVAL_BUCKET_MINUTES, buildCheckinResult, emptyCheckinResult, -} from "./registration-repository-checkin.drizzle.js" +} from "../../../src/services/host/registration-repository-checkin.drizzle.js" import { guestSelfRegistrationOnPrivateEvent, subjectOwner, withinSalesWindow, -} from "./registration-repository-register.drizzle.js" -import type { AppliedBan } from "./registration-repository-roster.drizzle.js" -import { MAX_TICKET_TYPES } from "./registration-repository-ticket-types.drizzle.js" -import { waitlistEntryAsRegistration } from "./registration-repository-waitlist.drizzle.js" +} from "../../../src/services/host/registration-repository-register.drizzle.js" +import type { AppliedBan } from "../../../src/services/host/registration-repository-roster.drizzle.js" +import { MAX_TICKET_TYPES } from "../../../src/services/host/registration-repository-ticket-types.drizzle.js" +import { waitlistEntryAsRegistration } from "../../../src/services/host/registration-repository-waitlist.drizzle.js" import type { AnswerRecord, CancelRegistrationOutcome, @@ -61,7 +61,7 @@ import type { WalkupRegisterArgs, WaitlistOffer, WaitlistRecord, -} from "./registration-repository.js" +} from "../../../src/services/host/registration-repository.js" interface MemoryRegistration extends RegistrationRecord { answers: AnswerRecord[] diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/test/helpers/volunteer-hours-repository.memory.ts similarity index 98% rename from services/api/src/services/volunteer-hours-repository.memory.ts rename to services/api/test/helpers/volunteer-hours-repository.memory.ts index 2c824dcc..2fb85ceb 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/test/helpers/volunteer-hours-repository.memory.ts @@ -6,16 +6,16 @@ import type { OrganizationRefDTO, VolunteerHoursSource, } from "@civfix/shared" -import { encodeTimeCursor, isBeforeTimeCursor, pageWith } from "../db/cursor-helpers.js" -import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" -import { eventDayKey } from "./host/event-day.js" +import { encodeTimeCursor, isBeforeTimeCursor, pageWith } from "../../src/db/cursor-helpers.js" +import { DEFAULT_EVENT_TIME_ZONE } from "../../src/services/host/event-fields.js" +import { eventDayKey } from "../../src/services/host/event-day.js" import { DAILY_HOURS_CAP, ITEMISED_SOURCES, MAX_ORG_CHIPS_FETCH, RECIPROCAL_LOOKBACK_MS, WEEKLY_HOURS_FLAG_DEFAULT, -} from "./volunteer-hours-service.js" +} from "../../src/services/volunteer-hours-service.js" import type { CertificateEntriesPage, EntriesForCertificateArgs, @@ -30,8 +30,8 @@ import type { VolunteerHoursAnomaly, VolunteerHoursEntryView, VolunteerHoursRepository, -} from "./volunteer-hours-repository.js" -import { MS_PER_WEEK } from "../lib/time.js" +} from "../../src/services/volunteer-hours-repository.js" +import { MS_PER_WEEK } from "../../src/lib/time.js" export interface MemoryLeaderboardUser { name: string diff --git a/services/api/test/unit/admin-activity.test.ts b/services/api/test/unit/admin-activity.test.ts index ecb43082..7629b1f5 100644 --- a/services/api/test/unit/admin-activity.test.ts +++ b/services/api/test/unit/admin-activity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { InMemoryActivityRepository } from "../../src/services/admin/activity-repository.memory.js" +import { InMemoryActivityRepository } from "../helpers/admin/activity-repository.memory.js" import { classifyActivity, classifyAuditAction, diff --git a/services/api/test/unit/admin-analytics.test.ts b/services/api/test/unit/admin-analytics.test.ts index 566f5c53..5e7ff53d 100644 --- a/services/api/test/unit/admin-analytics.test.ts +++ b/services/api/test/unit/admin-analytics.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest" -import { InMemoryAnalyticsRepository } from "../../src/services/admin/analytics-repository.memory.js" +import { InMemoryAnalyticsRepository } from "../helpers/admin/analytics-repository.memory.js" import { makeDrizzleAnalyticsRepository } from "../../src/services/admin/analytics-repository.drizzle.js" import type { Sql } from "../../src/db/client.js" import { diff --git a/services/api/test/unit/admin-audit.test.ts b/services/api/test/unit/admin-audit.test.ts index bd4eeba8..aab81b3d 100644 --- a/services/api/test/unit/admin-audit.test.ts +++ b/services/api/test/unit/admin-audit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { InMemoryAuditRepository } from "../../src/services/admin/audit-repository.memory.js" +import { InMemoryAuditRepository } from "../helpers/admin/audit-repository.memory.js" import { makeAuditService, toAuditEntryDTO, diff --git a/services/api/test/unit/admin-discovery.test.ts b/services/api/test/unit/admin-discovery.test.ts index a445c0f3..85115e26 100644 --- a/services/api/test/unit/admin-discovery.test.ts +++ b/services/api/test/unit/admin-discovery.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" import { randomUUID } from "node:crypto" -import { InMemoryDiscoveryRepository } from "../../src/services/admin/discovery-repository.memory.js" +import { InMemoryDiscoveryRepository } from "../helpers/admin/discovery-repository.memory.js" import { makeDiscoveryService, computeContactState, diff --git a/services/api/test/unit/admin-event-correctness.test.ts b/services/api/test/unit/admin-event-correctness.test.ts index 4647ef1e..aaf4f75f 100644 --- a/services/api/test/unit/admin-event-correctness.test.ts +++ b/services/api/test/unit/admin-event-correctness.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest" import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleAdminEventRepository } from "../../src/services/admin/admin-event-repository.drizzle.js" -import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" +import { InMemoryAdminEventRepository } from "../helpers/admin/admin-event-repository.memory.js" import { ADMIN_EVENT_MESSAGE_CAP } from "../../src/services/admin/admin-event-helpers.js" const EVENT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e41" diff --git a/services/api/test/unit/admin-events.test.ts b/services/api/test/unit/admin-events.test.ts index e590daba..d7e9eaf7 100644 --- a/services/api/test/unit/admin-events.test.ts +++ b/services/api/test/unit/admin-events.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" +import { InMemoryAdminEventRepository } from "../helpers/admin/admin-event-repository.memory.js" import { makeAdminEventService, resolveEventFilter, diff --git a/services/api/test/unit/admin-gov-claims.test.ts b/services/api/test/unit/admin-gov-claims.test.ts index 0e8d5ff2..d0327ca7 100644 --- a/services/api/test/unit/admin-gov-claims.test.ts +++ b/services/api/test/unit/admin-gov-claims.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest" import { InMemoryGovClaimsRepository, InMemoryUserProvisioner, -} from "../../src/services/admin/gov-claims-repository.memory.js" +} from "../helpers/admin/gov-claims-repository.memory.js" import { makeGovClaimsService, pendingChecks, diff --git a/services/api/test/unit/admin-guard-cache.test.ts b/services/api/test/unit/admin-guard-cache.test.ts index 5214ca06..82e26b31 100644 --- a/services/api/test/unit/admin-guard-cache.test.ts +++ b/services/api/test/unit/admin-guard-cache.test.ts @@ -8,7 +8,7 @@ import { makeInMemoryStores } from "../../src/auth/stores.js" import type { UserRecord, UserStore } from "../../src/auth/stores.js" import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { OPERATOR_ALLOWLIST_TTL_SECONDS } from "../../src/auth/admin-guard.js" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" const ALLOWED = "ops@civfix.org" const NOT_ALLOWED = "stranger@example.com" diff --git a/services/api/test/unit/admin-home.test.ts b/services/api/test/unit/admin-home.test.ts index e69fca55..c4a2d29c 100644 --- a/services/api/test/unit/admin-home.test.ts +++ b/services/api/test/unit/admin-home.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" -import { InMemoryHomeRepository } from "../../src/services/admin/home-repository.memory.js" -import { InMemoryAnalyticsRepository } from "../../src/services/admin/analytics-repository.memory.js" +import { InMemoryHomeRepository } from "../helpers/admin/home-repository.memory.js" +import { InMemoryAnalyticsRepository } from "../helpers/admin/analytics-repository.memory.js" import { HOME_SUMMARY_CONCURRENCY, makeHomeService, diff --git a/services/api/test/unit/admin-host-platform-routes.test.ts b/services/api/test/unit/admin-host-platform-routes.test.ts index 51ec1edd..b61ac411 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -9,9 +9,9 @@ import { registerAdminLegalRoutes } from "../../src/routes/admin/legal.routes.js import { registerAdminBroadcastRoutes } from "../../src/routes/admin/broadcasts.routes.js" import { registerAdminOrgRoutes } from "../../src/routes/admin/orgs.routes.js" import type { AdminEventPageRow } from "../../src/services/host/admin-pages-repository.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" -import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" -import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" +import { InMemoryOrganizationRepository } from "../helpers/host/organization-repository.memory.js" +import { InMemoryAdminEventRepository } from "../helpers/admin/admin-event-repository.memory.js" import type { MediaAssetView, MediaRepository } from "../../src/services/media-repository.js" const OPERATOR = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/admin-inbound-bounce-verdict.test.ts b/services/api/test/unit/admin-inbound-bounce-verdict.test.ts index bf1b6107..e1b7af87 100644 --- a/services/api/test/unit/admin-inbound-bounce-verdict.test.ts +++ b/services/api/test/unit/admin-inbound-bounce-verdict.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { processInboundObject, INBOUND_PENDING_PREFIX, diff --git a/services/api/test/unit/admin-inbox.test.ts b/services/api/test/unit/admin-inbox.test.ts index 97371104..58e8b555 100644 --- a/services/api/test/unit/admin-inbox.test.ts +++ b/services/api/test/unit/admin-inbox.test.ts @@ -8,7 +8,7 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { MEDIA_GET_URL_TTL_SEC } from "../../src/services/media-intake-service.js" // The operator uses a bearer token, which is CSRF-exempt by transport, so the mutation is reachable diff --git a/services/api/test/unit/admin-jurisdiction-contacts.test.ts b/services/api/test/unit/admin-jurisdiction-contacts.test.ts index 2c1c4ec2..04a5b36d 100644 --- a/services/api/test/unit/admin-jurisdiction-contacts.test.ts +++ b/services/api/test/unit/admin-jurisdiction-contacts.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from "vitest" import { FakeJobs, FakeMailer } from "@civfix/shared/fakes" -import { InMemoryJurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.memory.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryOutreachRepository } from "../../src/services/admin/outreach-repository.memory.js" +import { InMemoryJurisdictionContactsRepository } from "../helpers/admin/jurisdiction-contacts-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryOutreachRepository } from "../helpers/admin/outreach-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import { makeOutreachService } from "../../src/services/admin/outreach-service.js" import type { OutreachStateRecord } from "../../src/services/admin/mail-repository.drizzle.js" diff --git a/services/api/test/unit/admin-mail-repository.test.ts b/services/api/test/unit/admin-mail-repository.test.ts index 50e77e91..d522819a 100644 --- a/services/api/test/unit/admin-mail-repository.test.ts +++ b/services/api/test/unit/admin-mail-repository.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import type { MailThreadDTO } from "@civfix/shared" import type { Storage } from "@civfix/shared/interfaces" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import { presignThreadAttachments } from "../../src/routes/admin/mail.routes.js" import { buildMailStats, diff --git a/services/api/test/unit/admin-mail.test.ts b/services/api/test/unit/admin-mail.test.ts index c2617fac..d28fdb1a 100644 --- a/services/api/test/unit/admin-mail.test.ts +++ b/services/api/test/unit/admin-mail.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import type { InsertMessageInput } from "../../src/services/admin/mail-repository.js" import { ROUTE_CLAIM_STALE_SECONDS } from "../../src/services/admin/outbound-send-policy.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" diff --git a/services/api/test/unit/admin-message-broadcast.test.ts b/services/api/test/unit/admin-message-broadcast.test.ts index 3d27fee1..3099889f 100644 --- a/services/api/test/unit/admin-message-broadcast.test.ts +++ b/services/api/test/unit/admin-message-broadcast.test.ts @@ -10,11 +10,11 @@ import { makeMessageUpdateAnnouncer, } from "../../src/services/admin/admin-report-chat-service.js" import { findMessageRoom } from "../../src/services/admin/admin-report-chat-repository.drizzle.js" -import { InMemoryAdminReportChatRepository } from "../../src/services/admin/admin-report-chat-repository.memory.js" +import { InMemoryAdminReportChatRepository } from "../helpers/admin/admin-report-chat-repository.memory.js" import { makeAdminUserService } from "../../src/services/admin/admin-user-service.js" -import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { InMemoryAdminUserRepository } from "../helpers/admin/admin-user-repository.memory.js" import { makeModerationService } from "../../src/services/admin/moderation-service.js" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" import type { ReportChatSendDeps } from "../../src/services/report-chat-send.js" const REPORT_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e61" diff --git a/services/api/test/unit/admin-moderation-correctness.test.ts b/services/api/test/unit/admin-moderation-correctness.test.ts index ab188934..1da6e597 100644 --- a/services/api/test/unit/admin-moderation-correctness.test.ts +++ b/services/api/test/unit/admin-moderation-correctness.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest" import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" import type { Sql } from "../../src/db/client.js" import { makeDrizzleModerationRepository } from "../../src/services/admin/moderation-repository.drizzle.js" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" const ITEM_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e31" const USER_ID = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e32" diff --git a/services/api/test/unit/admin-moderation.test.ts b/services/api/test/unit/admin-moderation.test.ts index 666c08bf..e535f4c2 100644 --- a/services/api/test/unit/admin-moderation.test.ts +++ b/services/api/test/unit/admin-moderation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" import { makeModerationService, type ModerationService, diff --git a/services/api/test/unit/admin-outreach-claim.test.ts b/services/api/test/unit/admin-outreach-claim.test.ts index ed50a67c..67e9be49 100644 --- a/services/api/test/unit/admin-outreach-claim.test.ts +++ b/services/api/test/unit/admin-outreach-claim.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import type { OutboundEmail } from "@civfix/shared/interfaces" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryOutreachRepository } from "../../src/services/admin/outreach-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryOutreachRepository } from "../helpers/admin/outreach-repository.memory.js" import { makeOutboundMailService, OutboundSendDeadlineError, diff --git a/services/api/test/unit/admin-report-chat.test.ts b/services/api/test/unit/admin-report-chat.test.ts index 6c63efa5..c7f10a18 100644 --- a/services/api/test/unit/admin-report-chat.test.ts +++ b/services/api/test/unit/admin-report-chat.test.ts @@ -7,7 +7,7 @@ import { } from "@civfix/shared" import type { PersistChatInput } from "@civfix/shared/interfaces" import { makeAdminReportChatService } from "../../src/services/admin/admin-report-chat-service.js" -import { InMemoryAdminReportChatRepository } from "../../src/services/admin/admin-report-chat-repository.memory.js" +import { InMemoryAdminReportChatRepository } from "../helpers/admin/admin-report-chat-repository.memory.js" import { sendReportChatMessage, type ReportChatPersistContext, diff --git a/services/api/test/unit/admin-report-correctness.test.ts b/services/api/test/unit/admin-report-correctness.test.ts index 1d69b449..8001065d 100644 --- a/services/api/test/unit/admin-report-correctness.test.ts +++ b/services/api/test/unit/admin-report-correctness.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import { makeDrizzleAdminReportRepository } from "../../src/services/admin/admin-report-repository.drizzle.js" import { makeAdminReportService } from "../../src/services/admin/admin-report-service.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import { RecordingNotifier } from "../helpers/notifications.js" import { makeFakeSql } from "../helpers/fake-sql.js" diff --git a/services/api/test/unit/admin-reports.test.ts b/services/api/test/unit/admin-reports.test.ts index 14744ce9..f41956f0 100644 --- a/services/api/test/unit/admin-reports.test.ts +++ b/services/api/test/unit/admin-reports.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest" import { AppError, DEFAULT_FORWARD_SUBJECT_TEMPLATE, templateUsesToken } from "@civfix/shared" import { FakeMailer } from "@civfix/shared/fakes" import { runAutoForwardWith } from "../../src/services/admin/autoforward-jobs.js" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import type { MailMessageKind } from "../../src/services/admin/mail-repository.js" import { makeAdminReportService, @@ -11,8 +11,8 @@ import { timelineKindForStatus, type AdminReportService, } from "../../src/services/admin/admin-report-service.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryForwardTemplateRepository } from "../../src/services/admin/forward-template-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryForwardTemplateRepository } from "../helpers/admin/forward-template-repository.memory.js" import { RecordingNotifier } from "../helpers/notifications.js" import { MAX_PACKET_TOTAL_BYTES } from "../../src/services/admin/mail-format.js" import { diff --git a/services/api/test/unit/admin-routes-http.test.ts b/services/api/test/unit/admin-routes-http.test.ts index 7f63c2cf..de595602 100644 --- a/services/api/test/unit/admin-routes-http.test.ts +++ b/services/api/test/unit/admin-routes-http.test.ts @@ -8,22 +8,22 @@ import { makeInMemoryStores } from "../../src/auth/stores.js" import { makeAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import type { WriteAuditInput } from "../../src/services/admin/audit.js" -import { InMemoryHomeRepository } from "../../src/services/admin/home-repository.memory.js" -import { InMemoryAnalyticsRepository } from "../../src/services/admin/analytics-repository.memory.js" -import { InMemoryActivityRepository } from "../../src/services/admin/activity-repository.memory.js" -import { InMemoryAuditRepository } from "../../src/services/admin/audit-repository.memory.js" -import { InMemoryDiscoveryRepository } from "../../src/services/admin/discovery-repository.memory.js" -import { InMemoryJurisdictionContactsRepository } from "../../src/services/admin/jurisdiction-contacts-repository.memory.js" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" -import { InMemoryAdminEventRepository } from "../../src/services/admin/admin-event-repository.memory.js" -import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { InMemoryHomeRepository } from "../helpers/admin/home-repository.memory.js" +import { InMemoryAnalyticsRepository } from "../helpers/admin/analytics-repository.memory.js" +import { InMemoryActivityRepository } from "../helpers/admin/activity-repository.memory.js" +import { InMemoryAuditRepository } from "../helpers/admin/audit-repository.memory.js" +import { InMemoryDiscoveryRepository } from "../helpers/admin/discovery-repository.memory.js" +import { InMemoryJurisdictionContactsRepository } from "../helpers/admin/jurisdiction-contacts-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" +import { InMemoryAdminEventRepository } from "../helpers/admin/admin-event-repository.memory.js" +import { InMemoryAdminUserRepository } from "../helpers/admin/admin-user-repository.memory.js" import { InMemoryGovClaimsRepository, InMemoryUserProvisioner, -} from "../../src/services/admin/gov-claims-repository.memory.js" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +} from "../helpers/admin/gov-claims-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" const OPERATOR = "ops@civfix.org" diff --git a/services/api/test/unit/admin-users.test.ts b/services/api/test/unit/admin-users.test.ts index a5b4262d..881b4b8b 100644 --- a/services/api/test/unit/admin-users.test.ts +++ b/services/api/test/unit/admin-users.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { InMemoryAdminUserRepository } from "../helpers/admin/admin-user-repository.memory.js" import { makeAdminUserService, resolveUserFilter, diff --git a/services/api/test/unit/broadcast-pipeline-security.test.ts b/services/api/test/unit/broadcast-pipeline-security.test.ts index 74d19563..641399d8 100644 --- a/services/api/test/unit/broadcast-pipeline-security.test.ts +++ b/services/api/test/unit/broadcast-pipeline-security.test.ts @@ -4,7 +4,7 @@ import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { insightsGenerationKey } from "../../src/services/host/host-analytics-cache.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeBroadcastService, type BroadcastConfig, diff --git a/services/api/test/unit/broadcast-repository-security.test.ts b/services/api/test/unit/broadcast-repository-security.test.ts index de92747f..a42ae23a 100644 --- a/services/api/test/unit/broadcast-repository-security.test.ts +++ b/services/api/test/unit/broadcast-repository-security.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../src/db/client.js" import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeFakeSql } from "../helpers/fake-sql.js" describe("operator host search", () => { diff --git a/services/api/test/unit/broadcast-service-security.test.ts b/services/api/test/unit/broadcast-service-security.test.ts index 7aa4bcd3..9b884e30 100644 --- a/services/api/test/unit/broadcast-service-security.test.ts +++ b/services/api/test/unit/broadcast-service-security.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import type { BroadcastKind } from "@civfix/shared" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeBroadcastService, type BroadcastConfig, diff --git a/services/api/test/unit/certificate-routes.test.ts b/services/api/test/unit/certificate-routes.test.ts index 4d9e6a71..6f7b4c19 100644 --- a/services/api/test/unit/certificate-routes.test.ts +++ b/services/api/test/unit/certificate-routes.test.ts @@ -25,8 +25,8 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" -import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" -import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../helpers/volunteer-hours-repository.memory.js" +import { InMemoryCertificateRepository } from "../helpers/certificate-repository.memory.js" import { makeCertificateService, type CertificateStorage, diff --git a/services/api/test/unit/certificate-verify-url.test.ts b/services/api/test/unit/certificate-verify-url.test.ts index 7110ff0b..2dc8962c 100644 --- a/services/api/test/unit/certificate-verify-url.test.ts +++ b/services/api/test/unit/certificate-verify-url.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest" import PDFDocument from "pdfkit" import { FakeStorage } from "@civfix/shared/fakes" -import { InMemoryCertificateRepository } from "../../src/services/certificate-repository.memory.js" +import { InMemoryCertificateRepository } from "../helpers/certificate-repository.memory.js" import { makeCertificateService } from "../../src/services/certificate-service.js" import type { VolunteerHoursEntryView } from "../../src/services/volunteer-hours-repository.js" diff --git a/services/api/test/unit/discovery-flag.test.ts b/services/api/test/unit/discovery-flag.test.ts index 3f938a05..7543a6ed 100644 --- a/services/api/test/unit/discovery-flag.test.ts +++ b/services/api/test/unit/discovery-flag.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { makeFakeSql } from "../helpers/fake-sql.js" import { makeDrizzleDiscoveryRepository } from "../../src/services/admin/discovery-repository.drizzle.js" -import { InMemoryDiscoveryRepository } from "../../src/services/admin/discovery-repository.memory.js" +import { InMemoryDiscoveryRepository } from "../helpers/admin/discovery-repository.memory.js" import type { Sql } from "../../src/db/client.js" const TASK_ID = "11111111-1111-1111-1111-111111111111" diff --git a/services/api/test/unit/exposed-5xx-copy.test.ts b/services/api/test/unit/exposed-5xx-copy.test.ts index 98ae9ea5..daac542d 100644 --- a/services/api/test/unit/exposed-5xx-copy.test.ts +++ b/services/api/test/unit/exposed-5xx-copy.test.ts @@ -11,7 +11,7 @@ import { mintUnsubscribeToken, unsubscribeExpiryFrom, } from "../../src/services/host/broadcast-capability-token.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeBroadcastService, type BroadcastConfig, diff --git a/services/api/test/unit/forward-template-service.test.ts b/services/api/test/unit/forward-template-service.test.ts index 56a138a1..ed5f1971 100644 --- a/services/api/test/unit/forward-template-service.test.ts +++ b/services/api/test/unit/forward-template-service.test.ts @@ -4,7 +4,7 @@ import { FORWARD_TEMPLATE_SAMPLE_VALUES, templateUsesToken, } from "@civfix/shared" -import { InMemoryForwardTemplateRepository } from "../../src/services/admin/forward-template-repository.memory.js" +import { InMemoryForwardTemplateRepository } from "../helpers/admin/forward-template-repository.memory.js" import { makeForwardTemplateService, type ForwardTemplateService, diff --git a/services/api/test/unit/guest-jobs.test.ts b/services/api/test/unit/guest-jobs.test.ts index f5fb6ca2..534c22dd 100644 --- a/services/api/test/unit/guest-jobs.test.ts +++ b/services/api/test/unit/guest-jobs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeBroadcastLanes } from "../../src/services/host/broadcast-lanes.js" import type { EventBroadcastContext } from "../../src/services/host/broadcast-types.js" import { runGuestUpdateFanout } from "../../src/services/guest-jobs.js" diff --git a/services/api/test/unit/guest-rsvp-service.test.ts b/services/api/test/unit/guest-rsvp-service.test.ts index dc51941c..2c56bcab 100644 --- a/services/api/test/unit/guest-rsvp-service.test.ts +++ b/services/api/test/unit/guest-rsvp-service.test.ts @@ -14,7 +14,7 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { InMemoryCounterStore, type CounterStore } from "../../src/abuse/counter-store.js" import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" -import { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../helpers/host/registration-repository.memory.js" import { smsFailure } from "../../src/errors/sms-failure.js" import { formatEventWhen } from "../../src/services/host/broadcast-render.js" import { generateToken } from "../../src/auth/crypto.js" diff --git a/services/api/test/unit/host-broadcast-audience.test.ts b/services/api/test/unit/host-broadcast-audience.test.ts index ed756293..a2bbb869 100644 --- a/services/api/test/unit/host-broadcast-audience.test.ts +++ b/services/api/test/unit/host-broadcast-audience.test.ts @@ -1,6 +1,6 @@ import type { BroadcastKind } from "@civfix/shared" import { describe, expect, it } from "vitest" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" const EVENT = "00000000-0000-0000-0000-0000000000ee" const TYPE_A = "00000000-0000-0000-0000-00000000aaaa" diff --git a/services/api/test/unit/host-broadcast-caps.test.ts b/services/api/test/unit/host-broadcast-caps.test.ts index 36ae7c96..9ef524b4 100644 --- a/services/api/test/unit/host-broadcast-caps.test.ts +++ b/services/api/test/unit/host-broadcast-caps.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCounterStore, type CounterStore } from "../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { BroadcastCapError, capError, diff --git a/services/api/test/unit/host-broadcast-pipeline.test.ts b/services/api/test/unit/host-broadcast-pipeline.test.ts index b38b1686..a8cc0014 100644 --- a/services/api/test/unit/host-broadcast-pipeline.test.ts +++ b/services/api/test/unit/host-broadcast-pipeline.test.ts @@ -5,7 +5,7 @@ import type { Mailer, OutboundEmail, SentMail } from "@civfix/shared/interfaces" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { insightsGenerationKey } from "../../src/services/host/host-analytics-cache.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { emailHashOf, makeBroadcastService, diff --git a/services/api/test/unit/host-broadcast-send-correctness.test.ts b/services/api/test/unit/host-broadcast-send-correctness.test.ts index 03166f87..55d6ffbd 100644 --- a/services/api/test/unit/host-broadcast-send-correctness.test.ts +++ b/services/api/test/unit/host-broadcast-send-correctness.test.ts @@ -3,7 +3,7 @@ import { FakeMailer } from "@civfix/shared/fakes" import type { Mailer } from "@civfix/shared/interfaces" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" import { BroadcastCapError, diff --git a/services/api/test/unit/host-insights.test.ts b/services/api/test/unit/host-insights.test.ts index 4e93f654..1cb48780 100644 --- a/services/api/test/unit/host-insights.test.ts +++ b/services/api/test/unit/host-insights.test.ts @@ -6,7 +6,7 @@ import { makeHostAnalyticsCache, type HostAnalyticsCache, } from "../../src/services/host/host-analytics-cache.js" -import { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../helpers/host/registration-repository.memory.js" import { INSIGHTS_CACHE_TTL_SEC, INSIGHTS_LIVE_CACHE_TTL_SEC, diff --git a/services/api/test/unit/host-metrics-rollup-correctness.test.ts b/services/api/test/unit/host-metrics-rollup-correctness.test.ts index 3b61f36c..1240e948 100644 --- a/services/api/test/unit/host-metrics-rollup-correctness.test.ts +++ b/services/api/test/unit/host-metrics-rollup-correctness.test.ts @@ -6,7 +6,7 @@ import type { MetricUpsert, MetricsRepository } from "../../src/services/host/me import type { AnalyticsRepository } from "../../src/services/host/analytics-repository.js" import { makeInsightsService } from "../../src/services/host/insights-service.js" import { makeHostAnalyticsCache } from "../../src/services/host/host-analytics-cache.js" -import { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../helpers/host/registration-repository.memory.js" import { DEFAULT_EVENT_TIME_ZONE } from "../../src/services/host/event-fields.js" import type { Sql } from "../../src/db/client.js" import { makeFakeSql } from "../helpers/fake-sql.js" diff --git a/services/api/test/unit/host-reminders.test.ts b/services/api/test/unit/host-reminders.test.ts index 702982ad..c71b060b 100644 --- a/services/api/test/unit/host-reminders.test.ts +++ b/services/api/test/unit/host-reminders.test.ts @@ -3,7 +3,7 @@ import { makeFakeSql } from "../helpers/fake-sql.js" import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" import type { Sql } from "../../src/db/client.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { DEFAULT_REMINDER_OFFSETS_MIN, makeBroadcastLanes, diff --git a/services/api/test/unit/host-unsubscribe-route.test.ts b/services/api/test/unit/host-unsubscribe-route.test.ts index 80d3bdf8..31899ae8 100644 --- a/services/api/test/unit/host-unsubscribe-route.test.ts +++ b/services/api/test/unit/host-unsubscribe-route.test.ts @@ -4,7 +4,7 @@ import { FakeMailer } from "@civfix/shared/fakes" import type { Container } from "../../src/di.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { registerUnsubscribeRoutes } from "../../src/routes/host/unsubscribe.routes.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeBroadcastService, type BroadcastConfig, diff --git a/services/api/test/unit/host/announcement-pipeline.test.ts b/services/api/test/unit/host/announcement-pipeline.test.ts index fe72e513..44a60b4c 100644 --- a/services/api/test/unit/host/announcement-pipeline.test.ts +++ b/services/api/test/unit/host/announcement-pipeline.test.ts @@ -3,7 +3,7 @@ import { ANNOUNCEMENT_BROADCAST_KIND } from "@civfix/shared" import { makeFakeSql } from "../../helpers/fake-sql.js" import type { Sql } from "../../../src/db/client.js" import { makeDrizzleBroadcastRepository } from "../../../src/services/host/broadcast-repository.drizzle.js" -import { InMemoryBroadcastRepository } from "../../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../../helpers/host/broadcast-repository.memory.js" import { KIND_NOTIFICATION_TYPE, announcementPath, diff --git a/services/api/test/unit/host/announcement-send-failure.test.ts b/services/api/test/unit/host/announcement-send-failure.test.ts index 0d602cc2..562837ae 100644 --- a/services/api/test/unit/host/announcement-send-failure.test.ts +++ b/services/api/test/unit/host/announcement-send-failure.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../../helpers/host/broadcast-repository.memory.js" import { makeBroadcastService, type BroadcastConfig, diff --git a/services/api/test/unit/host/announcement-service.test.ts b/services/api/test/unit/host/announcement-service.test.ts index 873f98df..7e0525f1 100644 --- a/services/api/test/unit/host/announcement-service.test.ts +++ b/services/api/test/unit/host/announcement-service.test.ts @@ -7,7 +7,7 @@ import { type PersonDTO, } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryBroadcastRepository } from "../../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../../helpers/host/broadcast-repository.memory.js" import { BroadcastCapError, makeBroadcastService, diff --git a/services/api/test/unit/host/checkin-service.test.ts b/services/api/test/unit/host/checkin-service.test.ts index fbdbe37a..56b207fe 100644 --- a/services/api/test/unit/host/checkin-service.test.ts +++ b/services/api/test/unit/host/checkin-service.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { AppError } from "@civfix/shared" import { sha256Hex } from "../../../src/auth/crypto.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeCheckinService, type CheckinService, diff --git a/services/api/test/unit/host/event-ban-consistency.test.ts b/services/api/test/unit/host/event-ban-consistency.test.ts index a94fd5a7..684ab352 100644 --- a/services/api/test/unit/host/event-ban-consistency.test.ts +++ b/services/api/test/unit/host/event-ban-consistency.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../../src/db/client.js" import { makeDrizzleCleanupRepository } from "../../../src/services/cleanup-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeFakeSql, type RecordedStatement } from "../../helpers/fake-sql.js" const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" diff --git a/services/api/test/unit/host/host-routes.test.ts b/services/api/test/unit/host/host-routes.test.ts index f2658858..ee763bfa 100644 --- a/services/api/test/unit/host/host-routes.test.ts +++ b/services/api/test/unit/host/host-routes.test.ts @@ -11,7 +11,7 @@ import { InMemoryCacheClient } from "../../../src/auth/cache.js" import { makeInMemoryStores } from "../../../src/auth/stores.js" import { makeAuthServices } from "../../../src/auth/auth-services.js" import { StubJwksVerifier } from "../../helpers/auth.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { OPEN_HOST_GUARDS, ORGANIZER_STANDING, diff --git a/services/api/test/unit/host/host-service-correctness.test.ts b/services/api/test/unit/host/host-service-correctness.test.ts index 603cb8f7..ef34e779 100644 --- a/services/api/test/unit/host/host-service-correctness.test.ts +++ b/services/api/test/unit/host/host-service-correctness.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import type { Jobs } from "@civfix/shared/interfaces" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeRegistrationService } from "../../../src/services/host/registration-service.js" import { makeTicketTypeService } from "../../../src/services/host/ticket-type-service.js" import { makeQuestionService } from "../../../src/services/host/question-service.js" diff --git a/services/api/test/unit/host/host-team-service-security.test.ts b/services/api/test/unit/host/host-team-service-security.test.ts index 91e9c200..b66b2dd4 100644 --- a/services/api/test/unit/host/host-team-service-security.test.ts +++ b/services/api/test/unit/host/host-team-service-security.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest" import type { HostCapability } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.js" -import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" +import { InMemoryHostTeamRepository } from "../../helpers/host/host-team-repository.memory.js" import { makeDrizzleHostTeamRepository } from "../../../src/services/host/host-team-repository.drizzle.js" import { makeHostTeamService, diff --git a/services/api/test/unit/host/host-team-service.test.ts b/services/api/test/unit/host/host-team-service.test.ts index 33180017..aa54e8a6 100644 --- a/services/api/test/unit/host/host-team-service.test.ts +++ b/services/api/test/unit/host/host-team-service.test.ts @@ -4,7 +4,7 @@ import { can, NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { hostForbiddenCopy } from "../../../src/services/host/authz.js" import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.js" -import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" +import { InMemoryHostTeamRepository } from "../../helpers/host/host-team-repository.memory.js" import { fakeCleanupDTO } from "../../helpers/host-team.js" import { makeHostTeamService, diff --git a/services/api/test/unit/host/insights-invalidation-wiring.test.ts b/services/api/test/unit/host/insights-invalidation-wiring.test.ts index 346f44c7..83db8fd8 100644 --- a/services/api/test/unit/host/insights-invalidation-wiring.test.ts +++ b/services/api/test/unit/host/insights-invalidation-wiring.test.ts @@ -5,7 +5,7 @@ import { makeContainer } from "../../../src/di.js" import { loadEnv } from "../../../src/env.js" import { InMemoryCacheClient } from "../../../src/auth/cache.js" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeContainerRegistrationServices, type HostRegistrationServices, diff --git a/services/api/test/unit/host/org-invite-inbox.test.ts b/services/api/test/unit/host/org-invite-inbox.test.ts index ae1dd13c..839907c1 100644 --- a/services/api/test/unit/host/org-invite-inbox.test.ts +++ b/services/api/test/unit/host/org-invite-inbox.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { InMemoryOrganizationRepository } from "../../helpers/host/organization-repository.memory.js" import { makeOrganizationService, type OrganizationService, diff --git a/services/api/test/unit/host/org-team-routes.test.ts b/services/api/test/unit/host/org-team-routes.test.ts index 0e910cc4..cfbc1829 100644 --- a/services/api/test/unit/host/org-team-routes.test.ts +++ b/services/api/test/unit/host/org-team-routes.test.ts @@ -12,9 +12,9 @@ import { InMemoryCacheClient } from "../../../src/auth/cache.js" import { makeInMemoryStores } from "../../../src/auth/stores.js" import { makeAuthServices } from "../../../src/auth/auth-services.js" import { StubJwksVerifier } from "../../helpers/auth.js" -import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { InMemoryOrganizationRepository } from "../../helpers/host/organization-repository.memory.js" import { fakeCleanupReader } from "../../helpers/host-team.js" -import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" +import { InMemoryHostTeamRepository } from "../../helpers/host/host-team-repository.memory.js" import { hostForbiddenCopy } from "../../../src/services/host/authz.js" import type { HostStandingResolution } from "../../../src/services/host/host-standing-repository.js" diff --git a/services/api/test/unit/host/organization-repository-correctness.test.ts b/services/api/test/unit/host/organization-repository-correctness.test.ts index 3f915df1..85a16a70 100644 --- a/services/api/test/unit/host/organization-repository-correctness.test.ts +++ b/services/api/test/unit/host/organization-repository-correctness.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" -import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { InMemoryOrganizationRepository } from "../../helpers/host/organization-repository.memory.js" import { makeFakeSql } from "../../helpers/fake-sql.js" import type { Sql } from "../../../src/db/client.js" diff --git a/services/api/test/unit/host/organization-service-security.test.ts b/services/api/test/unit/host/organization-service-security.test.ts index 832e0dc9..ca0ceccd 100644 --- a/services/api/test/unit/host/organization-service-security.test.ts +++ b/services/api/test/unit/host/organization-service-security.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { InMemoryOrganizationRepository } from "../../helpers/host/organization-repository.memory.js" import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" import { makeOrganizationService, diff --git a/services/api/test/unit/host/organization-service.test.ts b/services/api/test/unit/host/organization-service.test.ts index 2e64e688..0ebff881 100644 --- a/services/api/test/unit/host/organization-service.test.ts +++ b/services/api/test/unit/host/organization-service.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { InMemoryOrganizationRepository } from "../../helpers/host/organization-repository.memory.js" import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" import { makeFakeSql, type SqlHandler } from "../../helpers/fake-sql.js" import type { Sql } from "../../../src/db/client.js" diff --git a/services/api/test/unit/host/page-service-correctness.test.ts b/services/api/test/unit/host/page-service-correctness.test.ts index dda751e4..47cbd524 100644 --- a/services/api/test/unit/host/page-service-correctness.test.ts +++ b/services/api/test/unit/host/page-service-correctness.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { PAGE_SLUG_MAX, PageSlugSchema } from "@civfix/shared" import type { EventPageBlock } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { HOST_PAGE_PUBLISH_PER_DAY, makePageService, diff --git a/services/api/test/unit/host/page-service.test.ts b/services/api/test/unit/host/page-service.test.ts index 483cf9b7..e536f8c7 100644 --- a/services/api/test/unit/host/page-service.test.ts +++ b/services/api/test/unit/host/page-service.test.ts @@ -3,7 +3,7 @@ import { AppError, currentVersion } from "@civfix/shared" import { parseMarkdownSubset } from "@civfix/shared/markdown" import type { EventPageBlock } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makePageService, validatePageBlocks, diff --git a/services/api/test/unit/host/registration-repository-security.test.ts b/services/api/test/unit/host/registration-repository-security.test.ts index 817cbdcf..33bac0f3 100644 --- a/services/api/test/unit/host/registration-repository-security.test.ts +++ b/services/api/test/unit/host/registration-repository-security.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import type { RegisterForEventRequest } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" import type { QuestionRecord, diff --git a/services/api/test/unit/host/registration-service.test.ts b/services/api/test/unit/host/registration-service.test.ts index f955c0da..ae3775ab 100644 --- a/services/api/test/unit/host/registration-service.test.ts +++ b/services/api/test/unit/host/registration-service.test.ts @@ -4,7 +4,7 @@ import { AppError, currentVersion } from "@civfix/shared" import type { RegisterForEventRequest } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { sha256Hex } from "../../../src/auth/crypto.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeRegistrationService, walkupIdempotencyKey, diff --git a/services/api/test/unit/host/signup-seats.test.ts b/services/api/test/unit/host/signup-seats.test.ts index fe2174c6..840dff00 100644 --- a/services/api/test/unit/host/signup-seats.test.ts +++ b/services/api/test/unit/host/signup-seats.test.ts @@ -21,7 +21,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { makeCleanupService, type CleanupService } from "../../../src/services/cleanup-service.js" import { InMemoryCleanupRepository } from "../../helpers/cleanups.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import type { TicketTypeRecord } from "../../../src/services/host/registration-repository.js" import { makeCheckinService, diff --git a/services/api/test/unit/host/ticket-type-service.test.ts b/services/api/test/unit/host/ticket-type-service.test.ts index 47effd15..0d15ae74 100644 --- a/services/api/test/unit/host/ticket-type-service.test.ts +++ b/services/api/test/unit/host/ticket-type-service.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { AppError } from "@civfix/shared" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" import { sha256Hex } from "../../../src/auth/crypto.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeTicketTypeService, type TicketTypeService, diff --git a/services/api/test/unit/host/waitlist-service.test.ts b/services/api/test/unit/host/waitlist-service.test.ts index 42ce7e45..18a2e039 100644 --- a/services/api/test/unit/host/waitlist-service.test.ts +++ b/services/api/test/unit/host/waitlist-service.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { AppError } from "@civfix/shared" import { sha256Hex } from "../../../src/auth/crypto.js" -import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../../helpers/host/registration-repository.memory.js" import { makeWaitlistService, WAITLIST_CLAIM_WINDOW_MS, diff --git a/services/api/test/unit/inbound-bounce-redrive.test.ts b/services/api/test/unit/inbound-bounce-redrive.test.ts index 76052b26..9c102073 100644 --- a/services/api/test/unit/inbound-bounce-redrive.test.ts +++ b/services/api/test/unit/inbound-bounce-redrive.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { processInboundObject, INBOUND_BOUNCE_MAX_ATTEMPTS, diff --git a/services/api/test/unit/inbound-event-reply-note.test.ts b/services/api/test/unit/inbound-event-reply-note.test.ts index d8210902..e15e83c3 100644 --- a/services/api/test/unit/inbound-event-reply-note.test.ts +++ b/services/api/test/unit/inbound-event-reply-note.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import { onEventReply } from "../../src/services/admin/inbound-thread-correlation.js" import { MESSAGE_BODY_MAX } from "@civfix/shared" import type { CleanupRepository } from "../../src/services/cleanup-service.js" diff --git a/services/api/test/unit/inbound-mail-authentication.test.ts b/services/api/test/unit/inbound-mail-authentication.test.ts index 9af5b089..befa7d6f 100644 --- a/services/api/test/unit/inbound-mail-authentication.test.ts +++ b/services/api/test/unit/inbound-mail-authentication.test.ts @@ -8,7 +8,7 @@ import { } from "../../src/adapters/inbound-mail.cf.js" import { sanitizeInboundHtml } from "../../src/services/admin/inbound-html-sanitizer.js" import { isJurisdictionSender } from "../../src/services/admin/inbound-thread-correlation.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" /** * Forged inbound email could impersonate a jurisdiction: there was NO message authentication at all, diff --git a/services/api/test/unit/inbound-mail-webhook.test.ts b/services/api/test/unit/inbound-mail-webhook.test.ts index ab46208d..944de10e 100644 --- a/services/api/test/unit/inbound-mail-webhook.test.ts +++ b/services/api/test/unit/inbound-mail-webhook.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest" import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify" import { FakeInboundMail, FakeStorage } from "@civfix/shared/fakes" import type { InboundMail, ParsedMail } from "@civfix/shared/interfaces" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { assertSignature, registerInboundMailWebhook, diff --git a/services/api/test/unit/inbound-processor-hardening.test.ts b/services/api/test/unit/inbound-processor-hardening.test.ts index d1c42b85..a4cfc38c 100644 --- a/services/api/test/unit/inbound-processor-hardening.test.ts +++ b/services/api/test/unit/inbound-processor-hardening.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" import type { InboundMail } from "@civfix/shared/interfaces" import { CfInboundMail } from "../../src/adapters/inbound-mail.cf.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { processInboundObject, INBOUND_PENDING_PREFIX, diff --git a/services/api/test/unit/inbound-processor-security.test.ts b/services/api/test/unit/inbound-processor-security.test.ts index e6cbab2f..dcd6d1f6 100644 --- a/services/api/test/unit/inbound-processor-security.test.ts +++ b/services/api/test/unit/inbound-processor-security.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest" import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" import { CfInboundMail } from "../../src/adapters/inbound-mail.cf.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import { RecordingNotifier } from "../helpers/notifications.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { diff --git a/services/api/test/unit/inbound-processor.test.ts b/services/api/test/unit/inbound-processor.test.ts index 17352cd0..3b0d4033 100644 --- a/services/api/test/unit/inbound-processor.test.ts +++ b/services/api/test/unit/inbound-processor.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest" import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" import type { InboundMail } from "@civfix/shared/interfaces" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import { RecordingNotifier } from "../helpers/notifications.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { diff --git a/services/api/test/unit/inbound-sweep.test.ts b/services/api/test/unit/inbound-sweep.test.ts index 8f5e3779..98062b34 100644 --- a/services/api/test/unit/inbound-sweep.test.ts +++ b/services/api/test/unit/inbound-sweep.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest" import { FakeInboundMail, FakeStorage } from "@civfix/shared/fakes" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" import { runInboundSweep } from "../../src/services/admin/inbound-sweep.js" import { INBOUND_PENDING_PREFIX, type InboundProcessorDeps, } from "../../src/services/admin/inbound-processor.js" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import { RecordingNotifier } from "../helpers/notifications.js" import { JURISDICTION_REPLY_NOTE } from "../../src/services/admin/inbound-thread-correlation.js" import type { Container } from "../../src/di.js" diff --git a/services/api/test/unit/inbound-thread-correlation-security.test.ts b/services/api/test/unit/inbound-thread-correlation-security.test.ts index 1eed953d..9257b3e4 100644 --- a/services/api/test/unit/inbound-thread-correlation-security.test.ts +++ b/services/api/test/unit/inbound-thread-correlation-security.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest" import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" import type { ParsedMail } from "@civfix/shared/interfaces" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../helpers/admin/inbound-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import { isJurisdictionSender } from "../../src/services/admin/inbound-thread-correlation.js" import { processInboundObject, diff --git a/services/api/test/unit/moderation-operator-guard.test.ts b/services/api/test/unit/moderation-operator-guard.test.ts index 1bbb796e..247ef4fb 100644 --- a/services/api/test/unit/moderation-operator-guard.test.ts +++ b/services/api/test/unit/moderation-operator-guard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" import { makeModerationService } from "../../src/services/admin/moderation-service.js" import type { UserStatus } from "@civfix/shared" diff --git a/services/api/test/unit/official-account-guards.test.ts b/services/api/test/unit/official-account-guards.test.ts index 34da3066..5b9d3fbe 100644 --- a/services/api/test/unit/official-account-guards.test.ts +++ b/services/api/test/unit/official-account-guards.test.ts @@ -4,9 +4,9 @@ import { CIVFIX_OFFICIAL_USER_ID, impersonatesOfficialName, } from "../../src/auth/official-account.js" -import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { InMemoryAdminUserRepository } from "../helpers/admin/admin-user-repository.memory.js" import { makeAdminUserService } from "../../src/services/admin/admin-user-service.js" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" import { makeModerationService } from "../../src/services/admin/moderation-service.js" const OFFICIAL = CIVFIX_OFFICIAL_USER_ID diff --git a/services/api/test/unit/outbound-mail-service.test.ts b/services/api/test/unit/outbound-mail-service.test.ts index f62ac6c7..541219d9 100644 --- a/services/api/test/unit/outbound-mail-service.test.ts +++ b/services/api/test/unit/outbound-mail-service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import type { Mailer, OutboundAttachment, SentMail } from "@civfix/shared/interfaces" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import { assertOutboundSendPolicy, base64Bytes, diff --git a/services/api/test/unit/outreach-pipeline.test.ts b/services/api/test/unit/outreach-pipeline.test.ts index 9243c6cc..2b61a330 100644 --- a/services/api/test/unit/outreach-pipeline.test.ts +++ b/services/api/test/unit/outreach-pipeline.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" -import { InMemoryOutreachRepository } from "../../src/services/admin/outreach-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" +import { InMemoryOutreachRepository } from "../helpers/admin/outreach-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import { makeOutreachService, diff --git a/services/api/test/unit/packet-media-links.test.ts b/services/api/test/unit/packet-media-links.test.ts index 45e69b6d..d56730df 100644 --- a/services/api/test/unit/packet-media-links.test.ts +++ b/services/api/test/unit/packet-media-links.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import type { AdminReportStatus, ReportVisibility } from "@civfix/shared" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" import type { AdminReportMediaRecord } from "../../src/services/admin/admin-report-repository.js" import { makeAdminReportService } from "../../src/services/admin/admin-report-service.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import { makePacketMediaPresigner, diff --git a/services/api/test/unit/report-content-routes.test.ts b/services/api/test/unit/report-content-routes.test.ts index f8694e5c..eb60be41 100644 --- a/services/api/test/unit/report-content-routes.test.ts +++ b/services/api/test/unit/report-content-routes.test.ts @@ -7,7 +7,7 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import { makeAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" -import { InMemoryModerationRepository } from "../../src/services/admin/moderation-repository.memory.js" +import { InMemoryModerationRepository } from "../helpers/admin/moderation-repository.memory.js" import type { ContentSubjectGate } from "../../src/services/content-report-subject.js" import { AppError } from "@civfix/shared" diff --git a/services/api/test/unit/report-verification.test.ts b/services/api/test/unit/report-verification.test.ts index d85e3ad6..94bcae0a 100644 --- a/services/api/test/unit/report-verification.test.ts +++ b/services/api/test/unit/report-verification.test.ts @@ -3,8 +3,8 @@ import { randomUUID } from "node:crypto" import { FakeJobs, FakeMailer } from "@civfix/shared/fakes" import { AppError } from "@civfix/shared" import type { CreateReportRequest } from "@civfix/shared" -import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" -import { InMemoryAdminUserRepository } from "../../src/services/admin/admin-user-repository.memory.js" +import { InMemoryAdminReportRepository } from "../helpers/admin/admin-report-repository.memory.js" +import { InMemoryAdminUserRepository } from "../helpers/admin/admin-user-repository.memory.js" import { makeAdminReportService, REPORT_VERIFIED_THRESHOLD, @@ -14,7 +14,7 @@ import { makeAdminUserService, type AdminUserService, } from "../../src/services/admin/admin-user-service.js" -import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryMailRepository } from "../helpers/admin/mail-repository.memory.js" import { makeOutboundMailService, OutboundSendDeadlineError, diff --git a/services/api/test/unit/route-coverage.test.ts b/services/api/test/unit/route-coverage.test.ts index 9cad18a4..8d8f6273 100644 --- a/services/api/test/unit/route-coverage.test.ts +++ b/services/api/test/unit/route-coverage.test.ts @@ -18,16 +18,16 @@ import { InMemoryAnonStore } from "../helpers/anon.js" import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" import { InMemorySocialRepository } from "../helpers/social.js" -import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../helpers/volunteer-hours-repository.memory.js" import { InMemoryNotificationRepository } from "../helpers/notifications.js" import { InMemoryMediaRepository } from "../helpers/media.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" import type { ReportServiceOverrides } from "../../src/routes/reports.routes.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" import type { ReportOwner } from "../../src/services/report-service.js" -import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" -import { InMemoryHostTeamRepository } from "../../src/services/host/host-team-repository.memory.js" -import { InMemoryHostRegistrationRepository } from "../../src/services/host/registration-repository.memory.js" +import { InMemoryOrganizationRepository } from "../helpers/host/organization-repository.memory.js" +import { InMemoryHostTeamRepository } from "../helpers/host/host-team-repository.memory.js" +import { InMemoryHostRegistrationRepository } from "../helpers/host/registration-repository.memory.js" import { OPEN_HOST_GUARDS, ORGANIZER_STANDING, diff --git a/services/api/test/unit/unsubscribe-routes-security.test.ts b/services/api/test/unit/unsubscribe-routes-security.test.ts index c710040e..5ead7191 100644 --- a/services/api/test/unit/unsubscribe-routes-security.test.ts +++ b/services/api/test/unit/unsubscribe-routes-security.test.ts @@ -5,7 +5,7 @@ import type { Container } from "../../src/di.js" import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import { registerUnsubscribeRoutes } from "../../src/routes/host/unsubscribe.routes.js" -import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { InMemoryBroadcastRepository } from "../helpers/host/broadcast-repository.memory.js" import { makeBroadcastService, type BroadcastConfig, diff --git a/services/api/test/unit/volunteer-hours-entries.test.ts b/services/api/test/unit/volunteer-hours-entries.test.ts index 6b91ebe3..547e5bdd 100644 --- a/services/api/test/unit/volunteer-hours-entries.test.ts +++ b/services/api/test/unit/volunteer-hours-entries.test.ts @@ -5,7 +5,7 @@ import { type CleanupHoursView, type VolunteerHoursService, } from "../../src/services/volunteer-hours-service.js" -import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../helpers/volunteer-hours-repository.memory.js" const HOST = "11111111-1111-1111-1111-111111111111" const BOB = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts b/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts index 73ec580b..e154e680 100644 --- a/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts +++ b/services/api/test/unit/volunteer-hours-leaderboard-ceiling.test.ts @@ -5,7 +5,7 @@ import { makeVolunteerHoursService, type CleanupHoursLookup, } from "../../src/services/volunteer-hours-service.js" -import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../helpers/volunteer-hours-repository.memory.js" const GEOID = "0667000" diff --git a/services/api/test/unit/volunteer-hours-service-security.test.ts b/services/api/test/unit/volunteer-hours-service-security.test.ts index 99d0c7b4..0d2ba028 100644 --- a/services/api/test/unit/volunteer-hours-service-security.test.ts +++ b/services/api/test/unit/volunteer-hours-service-security.test.ts @@ -5,7 +5,7 @@ import { type CleanupHoursView, type VolunteerHoursService, } from "../../src/services/volunteer-hours-service.js" -import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../helpers/volunteer-hours-repository.memory.js" const HOST = "11111111-1111-1111-1111-111111111111" const MEMBER = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index e917bb17..51c6f3df 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -8,7 +8,7 @@ import { type HoursModerationSink, type VolunteerHoursService, } from "../../src/services/volunteer-hours-service.js" -import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" +import { InMemoryVolunteerHoursRepository } from "../helpers/volunteer-hours-repository.memory.js" import type { InsightsInvalidator } from "../../src/services/host/host-analytics-cache.js" import type { NotificationService } from "../../src/services/notification-service.js" import { makeServer } from "../../src/server.js" diff --git a/services/api/test/unit/web-base-url-fallback.test.ts b/services/api/test/unit/web-base-url-fallback.test.ts index e0d01d38..971d1d17 100644 --- a/services/api/test/unit/web-base-url-fallback.test.ts +++ b/services/api/test/unit/web-base-url-fallback.test.ts @@ -8,12 +8,12 @@ import { makeContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import { makeServer } from "../../src/server.js" import type { HostStandingResolution } from "../../src/services/host/host-standing-repository.js" -import { InMemoryHostTeamRepository } from "../../src/services/host/host-team-repository.memory.js" +import { InMemoryHostTeamRepository } from "../helpers/host/host-team-repository.memory.js" import { makeHostTeamService, type HostTeamServiceDeps, } from "../../src/services/host/host-team-service.js" -import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" +import { InMemoryOrganizationRepository } from "../helpers/host/organization-repository.memory.js" import { makeOrganizationService, type OrganizationServiceDeps, From f951124ca0b9d4fdb9bc558dce0267d08607cf1e Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 17:18:06 +0000 Subject: [PATCH 18/20] worker repository methods every implementation has are required --- services/api/src/services/media-worker-repository.ts | 8 ++++---- services/media-worker/src/jobs/media-checks.ts | 2 +- services/media-worker/src/jobs/orphan-sweep.ts | 8 +++----- services/media-worker/src/jobs/reject-cleanup.ts | 4 ++-- services/media-worker/src/jobs/upload-reap.ts | 2 +- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/services/api/src/services/media-worker-repository.ts b/services/api/src/services/media-worker-repository.ts index 2566cc32..de147975 100644 --- a/services/api/src/services/media-worker-repository.ts +++ b/services/api/src/services/media-worker-repository.ts @@ -76,7 +76,7 @@ export interface MediaWorkerRepository { hash: string, opts?: { excludeAssetId?: string; excludeReportId?: string }, ): Promise - enqueueHeldModerationItem?(input: { + enqueueHeldModerationItem(input: { reportId: string reason: string kind?: "image" | "duplicate" @@ -84,11 +84,11 @@ export interface MediaWorkerRepository { }): Promise refreshAvatarUrls?(mediaId: string, avatarUrl: string): Promise - recordLeakedObjects?(input: { + recordLeakedObjects(input: { mediaId: string | null keys: string[] error?: string | null }): Promise - listLeakedObjects?(limit: number, maxAttempts: number): Promise - clearLeakedObject?(r2Key: string): Promise + listLeakedObjects(limit: number, maxAttempts: number): Promise + clearLeakedObject(r2Key: string): Promise } diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index 45d491c4..415b56be 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -394,7 +394,7 @@ async function onHeld( note: result.note, flags: result.flags.map((f) => f.reason), }) - if (!asset.reportId || !deps.repo.enqueueHeldModerationItem) return + if (!asset.reportId) return await deps.repo .enqueueHeldModerationItem({ reportId: asset.reportId, diff --git a/services/media-worker/src/jobs/orphan-sweep.ts b/services/media-worker/src/jobs/orphan-sweep.ts index 7fd9527e..a8f58a8c 100644 --- a/services/media-worker/src/jobs/orphan-sweep.ts +++ b/services/media-worker/src/jobs/orphan-sweep.ts @@ -127,8 +127,6 @@ async function retryTombstonedLeaks( report: JobReportFn, ): Promise<{ retried: number; reclaimed: number; errors: number }> { const { repo } = deps - const clear = repo.clearLeakedObject?.bind(repo) - if (!repo.listLeakedObjects || !clear) return { retried: 0, reclaimed: 0, errors: 0 } let rows: LeakedObjectRow[] try { @@ -147,7 +145,7 @@ async function retryTombstonedLeaks( retried++ try { await deps.storage.delete(row.r2Key) - await clear(row.r2Key) + await repo.clearLeakedObject(row.r2Key) reclaimed++ } catch (err) { errors++ @@ -166,7 +164,7 @@ async function recordLeakRetryFailure( ): Promise { const attempts = row.attempts + 1 await repo - .recordLeakedObjects?.({ mediaId: row.mediaId, keys: [row.r2Key], error: String(err) }) + .recordLeakedObjects({ mediaId: row.mediaId, keys: [row.r2Key], error: String(err) }) .catch((bumpErr: unknown) => log("orphan.sweep: tombstone bump failed", { key: row.r2Key, err: String(bumpErr) }), ) @@ -246,7 +244,7 @@ async function sweepPage( hooks.onDeleted() if (leaked.length > 0) { await deps.repo - .recordLeakedObjects?.({ mediaId: o.id, keys: leaked, error: "storage delete failed" }) + .recordLeakedObjects({ mediaId: o.id, keys: leaked, error: "storage delete failed" }) .catch((err: unknown) => log("orphan.sweep: tombstone write failed (leak is now unrecoverable)", { mediaId: o.id, diff --git a/services/media-worker/src/jobs/reject-cleanup.ts b/services/media-worker/src/jobs/reject-cleanup.ts index a167bcec..3350f0a0 100644 --- a/services/media-worker/src/jobs/reject-cleanup.ts +++ b/services/media-worker/src/jobs/reject-cleanup.ts @@ -58,7 +58,7 @@ export async function deleteSupersededUpload( } await deps.repo - .recordLeakedObjects?.({ + .recordLeakedObjects({ mediaId: asset.id, keys: [asset.r2Key], error: "superseded-upload delete failed", @@ -110,7 +110,7 @@ export async function deleteRejectedObjects( if (leaked.length === 0) return await deps.repo - .recordLeakedObjects?.({ + .recordLeakedObjects({ mediaId: asset.id, keys: leaked, error: "rejected-media delete failed", diff --git a/services/media-worker/src/jobs/upload-reap.ts b/services/media-worker/src/jobs/upload-reap.ts index f87079b3..a464a429 100644 --- a/services/media-worker/src/jobs/upload-reap.ts +++ b/services/media-worker/src/jobs/upload-reap.ts @@ -98,7 +98,7 @@ export async function runUploadReapJob( return "deleted" } catch (err) { await deps.repo - .recordLeakedObjects?.({ + .recordLeakedObjects({ mediaId: payload.mediaId, keys: [r2Key], error: "delayed upload reap delete failed", From 0ec5bd658d9e651b4713bd9a8ff7e9f68e8ac7e5 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 17:35:48 +0000 Subject: [PATCH 19/20] comments follow the consolidated helpers; ADMIN_DEFAULT_LIMIT is module-private --- services/api/src/auth/reserved-handles.ts | 4 ++-- services/api/src/db/like.ts | 2 +- services/api/src/routes/admin/orgs.routes.ts | 2 +- services/api/src/services/admin/pagination.ts | 2 +- .../api/test/helpers/admin/admin-event-repository.memory.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/api/src/auth/reserved-handles.ts b/services/api/src/auth/reserved-handles.ts index cebb47dc..3934b380 100644 --- a/services/api/src/auth/reserved-handles.ts +++ b/services/api/src/auth/reserved-handles.ts @@ -3,8 +3,8 @@ * availability check (GET /me/handle-available), and both report `reserved` (distinct from `taken` and * `invalid`): * 1) RESERVED_HANDLES, system and role names a citizen must not impersonate; - * 2) every existing jurisdictions.handle, checked live, because jurisdiction handles are @mentionable in - * report discussions. + * 2) every existing jurisdictions.handle, checked live (the jurisdiction repository's handleExists, called + * from the auth routes), because jurisdiction handles are @mentionable in report discussions. */ import { TOMBSTONE_HANDLE_RE } from "./stores.js" diff --git a/services/api/src/db/like.ts b/services/api/src/db/like.ts index 96e42fed..e064db12 100644 --- a/services/api/src/db/like.ts +++ b/services/api/src/db/like.ts @@ -1,5 +1,5 @@ /** - * LIKE/ILIKE escaping for operator search terms. Unescaped, `%` and `_` act as wildcards (and `\` as the + * LIKE/ILIKE escaping for search terms. Unescaped, `%` and `_` act as wildcards (and `\` as the * escape): a term can broaden a match arbitrarily and defeat the gin_trgm_ops indexes * (0014_search_trgm.sql), since a leading-wildcard `%%%` term forces an unindexed full scan, a cheap DoS * lever for any operator. diff --git a/services/api/src/routes/admin/orgs.routes.ts b/services/api/src/routes/admin/orgs.routes.ts index 1d5a4b70..35842405 100644 --- a/services/api/src/routes/admin/orgs.routes.ts +++ b/services/api/src/routes/admin/orgs.routes.ts @@ -207,7 +207,7 @@ export async function registerAdminOrgRoutes( reply.status(200).send(payload) }) - // Unlike broadcasts/pages, which call insertAuditRow in the route after the effect, this decision is + // Unlike pages, which call insertAuditRow in the route, this decision is // audited inside the repository transaction (decideVerificationTx), so the audit row and the state change // commit or roll back together. A route-level insertAuditRow here would double-write the row. route(app, "adminDecideOrgVerification", { preHandler: csrfProtect }, async (request, reply) => { diff --git a/services/api/src/services/admin/pagination.ts b/services/api/src/services/admin/pagination.ts index da880e09..ae01aa28 100644 --- a/services/api/src/services/admin/pagination.ts +++ b/services/api/src/services/admin/pagination.ts @@ -1,7 +1,7 @@ import { encodeTimeCursor, parseKeysetCursor, type TimeCursor } from "../../db/cursor-helpers.js" import { clampPageLimit } from "../../lib/page-limit.js" -export const ADMIN_DEFAULT_LIMIT = 25 +const ADMIN_DEFAULT_LIMIT = 25 /** Mirrors the shared AdminListQuery limit ceiling. */ const ADMIN_MAX_LIMIT = 100 diff --git a/services/api/test/helpers/admin/admin-event-repository.memory.ts b/services/api/test/helpers/admin/admin-event-repository.memory.ts index 6378ff8b..a546d5eb 100644 --- a/services/api/test/helpers/admin/admin-event-repository.memory.ts +++ b/services/api/test/helpers/admin/admin-event-repository.memory.ts @@ -251,7 +251,7 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 }) - // The anchor is the row's scheduled_at (the list's sort key), carried in the cursor's createdAt slot + // The anchor is the row's scheduled_at (the list's sort key), carried in the cursor's `at` slot // exactly as the Drizzle repo does. return pageInMemoryById(rows, args.cursor, args.limit, (r) => ({ at: r.scheduledAt, From 6ed89bdc614f30f9f7f1bbecb38866cff87a1edb Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 17:45:01 +0000 Subject: [PATCH 20/20] org and team rules leave the contract files; comments and README follow the moves --- README.md | 3 ++ .../src/services/data-export-repository.ts | 3 +- .../host/host-team-repository.drizzle.ts | 2 +- .../src/services/host/host-team-repository.ts | 3 -- .../api/src/services/host/host-team-rules.ts | 2 + ...organization-repository-invites.drizzle.ts | 4 +- ...organization-repository-members.drizzle.ts | 4 +- .../services/host/organization-repository.ts | 41 ------------------ .../src/services/host/organization-rules.ts | 42 +++++++++++++++++++ .../api/src/services/social-repository.ts | 2 +- .../host/host-team-repository.memory.ts | 2 +- .../host/organization-repository.memory.ts | 8 ++-- 12 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 services/api/src/services/host/host-team-rules.ts create mode 100644 services/api/src/services/host/organization-rules.ts diff --git a/README.md b/README.md index 6847c196..075aed56 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ postgres-js client, the R2 adapter, or the GlitchTip reporter. It depends on `@c deleteById; `AnonHoldReleaseRepository`; `InboundRetentionRepository`). Their Drizzle impls stay on `media-repo`, `anon-hold-repo` and `inbound-retention-repo`. - `@civfix/api/queue-names` - every pg-boss queue name and the shared queue policy. +- `@civfix/api/time`, `@civfix/api/concurrency`, `@civfix/api/env-parsers`, `@civfix/api/timeout`, + `@civfix/api/capped-body` - import-free helpers the worker shares with the api (time units, + `mapWithLimit`, `parseBool`, `settleWithin`, the size-capped stream reader). - `@civfix/api/adapters/storage`, `@civfix/api/adapters/abuse-checks`, `@civfix/api/errors`, `@civfix/api/migrate` - the R2 adapter, the real AbuseChecks adapter, the GlitchTip helper, and the migration runner (the last reused only by the Docker-gated worker integration harness). diff --git a/services/api/src/services/data-export-repository.ts b/services/api/src/services/data-export-repository.ts index 4e25d4f4..dbe806a3 100644 --- a/services/api/src/services/data-export-repository.ts +++ b/services/api/src/services/data-export-repository.ts @@ -169,7 +169,8 @@ export interface EventCheckinExportRow { } /** - * Every column a personal data export can carry: a column that is not selected here cannot leak into one. + * Every column a personal data export can carry, as the row types of the projections in + * data-export-repository.drizzle.ts: a column those SELECTs do not name cannot leak into an export. * Section methods return the pending query unawaited, so the service decides when each statement is sent, * and fetch one row past `rowCap` so a clipped section is detectable. */ diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 6e0ae43e..7f014d4b 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -32,7 +32,7 @@ import type { PendingInviteForUserRecord, RevokeTeamInviteOutcome, } from "./host-team-repository.js" -import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.js" +import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-rules.js" import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-repository-rows.drizzle.js" const TEAM_INVITE_PENDING_CONSTRAINTS = [ diff --git a/services/api/src/services/host/host-team-repository.ts b/services/api/src/services/host/host-team-repository.ts index ab2f2f9b..4d6292bb 100644 --- a/services/api/src/services/host/host-team-repository.ts +++ b/services/api/src/services/host/host-team-repository.ts @@ -7,9 +7,6 @@ import type { } from "@civfix/shared" import type { CleanupPersonView } from "../cleanup-repository.js" -export const TEAM_INVITE_CAP_MESSAGE = - "This event already has the maximum number of open invitations." - export interface EventTeamMemberRecord { person: CleanupPersonView role: CleanupMemberRole diff --git a/services/api/src/services/host/host-team-rules.ts b/services/api/src/services/host/host-team-rules.ts new file mode 100644 index 00000000..1a5b67e6 --- /dev/null +++ b/services/api/src/services/host/host-team-rules.ts @@ -0,0 +1,2 @@ +export const TEAM_INVITE_CAP_MESSAGE = + "This event already has the maximum number of open invitations." diff --git a/services/api/src/services/host/organization-repository-invites.drizzle.ts b/services/api/src/services/host/organization-repository-invites.drizzle.ts index f5b66ff0..cb23abb3 100644 --- a/services/api/src/services/host/organization-repository-invites.drizzle.ts +++ b/services/api/src/services/host/organization-repository-invites.drizzle.ts @@ -17,13 +17,13 @@ import type { CreateOrganizationInviteArgs, CreateOrganizationInviteOutcome, DeclineOrganizationInviteOutcome, - InviterStanding, OrganizationInviteRecord, OrganizationRepository, PendingOrganizationInviteRecord, RevokeOrganizationInviteOutcome, } from "./organization-repository.js" -import { inviterRevocationReason, ORG_INVITE_CAP_MESSAGE } from "./organization-repository.js" +import type { InviterStanding } from "./organization-rules.js" +import { inviterRevocationReason, ORG_INVITE_CAP_MESSAGE } from "./organization-rules.js" type InviteLookup = { tokenHash: string } | { inviteId: string } diff --git a/services/api/src/services/host/organization-repository-members.drizzle.ts b/services/api/src/services/host/organization-repository-members.drizzle.ts index e2af0b34..8a81c323 100644 --- a/services/api/src/services/host/organization-repository-members.drizzle.ts +++ b/services/api/src/services/host/organization-repository-members.drizzle.ts @@ -15,7 +15,6 @@ import type { AdminOrgMemberRecord, AdminSetMemberRoleArgs, AdminSetMemberRoleOutcome, - InviterRevocationReason, OrganizationMemberRecord, OrganizationOwnerRecord, OrganizationRepository, @@ -23,7 +22,8 @@ import type { RemoveOrganizationMemberOutcome, SetOrganizationMemberRoleOutcome, } from "./organization-repository.js" -import { canManageOrgMembers, roleChangeWithdrawsInvites } from "./organization-repository.js" +import type { InviterRevocationReason } from "./organization-rules.js" +import { canManageOrgMembers, roleChangeWithdrawsInvites } from "./organization-rules.js" interface MemberRowSelect { user_id: string diff --git a/services/api/src/services/host/organization-repository.ts b/services/api/src/services/host/organization-repository.ts index 6eeea89f..74c5c681 100644 --- a/services/api/src/services/host/organization-repository.ts +++ b/services/api/src/services/host/organization-repository.ts @@ -6,49 +6,8 @@ import type { OrgVerificationStatus, SocialLinks, } from "@civfix/shared" -import { can } from "@civfix/shared/host" import type { CleanupOrganizationView, CleanupPersonView } from "../cleanup-repository.js" -export type InviterRevocationReason = "inviter_removed" | "inviter_demoted" | "account_deleted" - -export function canManageOrgMembers(role: OrganizationMemberRole | null): boolean { - return role !== null && can({ eventRole: null, orgRole: role }, "manage_org_members") -} - -export const ORG_INVITE_CAP_MESSAGE = - "This organization already has the maximum number of open invitations." - -/** - * A role change that takes away the power to invite withdraws the invites already sent with it, so - * they stop showing up as open in the inviter's org and the invitee's inbox. - */ -export function roleChangeWithdrawsInvites( - from: OrganizationMemberRole, - to: OrganizationMemberRole, -): boolean { - return canManageOrgMembers(from) && !canManageOrgMembers(to) -} - -/** The inviter as the organization knows them at accept time; `null` when the account is gone. */ -export interface InviterStanding { - role: OrganizationMemberRole | null - deleted: boolean -} - -/** - * An invite seats the role it names on the inviter's authority, so accepting re-checks that authority: - * an invite that outlived it (sent concurrently with the demotion, or before revocation on demotion - * existed) must not seat anyone. `null` means the inviter still holds it. - */ -export function inviterRevocationReason( - inviter: InviterStanding | null, -): InviterRevocationReason | null { - if (inviter === null) return "inviter_removed" - if (inviter.deleted) return "account_deleted" - if (inviter.role === null) return "inviter_removed" - return canManageOrgMembers(inviter.role) ? null : "inviter_demoted" -} - export interface OrganizationBaseRecord { id: string slug: string diff --git a/services/api/src/services/host/organization-rules.ts b/services/api/src/services/host/organization-rules.ts new file mode 100644 index 00000000..4513a4a6 --- /dev/null +++ b/services/api/src/services/host/organization-rules.ts @@ -0,0 +1,42 @@ +import type { OrganizationMemberRole } from "@civfix/shared" +import { can } from "@civfix/shared/host" + +export type InviterRevocationReason = "inviter_removed" | "inviter_demoted" | "account_deleted" + +export function canManageOrgMembers(role: OrganizationMemberRole | null): boolean { + return role !== null && can({ eventRole: null, orgRole: role }, "manage_org_members") +} + +export const ORG_INVITE_CAP_MESSAGE = + "This organization already has the maximum number of open invitations." + +/** + * A role change that takes away the power to invite withdraws the invites already sent with it, so + * they stop showing up as open in the inviter's org and the invitee's inbox. + */ +export function roleChangeWithdrawsInvites( + from: OrganizationMemberRole, + to: OrganizationMemberRole, +): boolean { + return canManageOrgMembers(from) && !canManageOrgMembers(to) +} + +/** The inviter as the organization knows them at accept time; `null` when the account is gone. */ +export interface InviterStanding { + role: OrganizationMemberRole | null + deleted: boolean +} + +/** + * An invite seats the role it names on the inviter's authority, so accepting re-checks that authority: + * an invite that outlived it (sent concurrently with the demotion, or before revocation on demotion + * existed) must not seat anyone. `null` means the inviter still holds it. + */ +export function inviterRevocationReason( + inviter: InviterStanding | null, +): InviterRevocationReason | null { + if (inviter === null) return "inviter_removed" + if (inviter.deleted) return "account_deleted" + if (inviter.role === null) return "inviter_removed" + return canManageOrgMembers(inviter.role) ? null : "inviter_demoted" +} diff --git a/services/api/src/services/social-repository.ts b/services/api/src/services/social-repository.ts index b715d721..bdf08485 100644 --- a/services/api/src/services/social-repository.ts +++ b/services/api/src/services/social-repository.ts @@ -1,5 +1,5 @@ import type { SocialLinks } from "@civfix/shared" -import type { CleanupRecord } from "./cleanup-service.js" +import type { CleanupRecord } from "./cleanup-repository.js" export interface PersonView { id: string diff --git a/services/api/test/helpers/host/host-team-repository.memory.ts b/services/api/test/helpers/host/host-team-repository.memory.ts index e60f89a4..b2f259e5 100644 --- a/services/api/test/helpers/host/host-team-repository.memory.ts +++ b/services/api/test/helpers/host/host-team-repository.memory.ts @@ -30,7 +30,7 @@ import type { PendingInviteForUserRecord, RevokeTeamInviteOutcome, } from "../../../src/services/host/host-team-repository.js" -import { TEAM_INVITE_CAP_MESSAGE } from "../../../src/services/host/host-team-repository.js" +import { TEAM_INVITE_CAP_MESSAGE } from "../../../src/services/host/host-team-rules.js" import { MS_PER_DAY } from "../../../src/lib/time.js" interface StoredInvite { diff --git a/services/api/test/helpers/host/organization-repository.memory.ts b/services/api/test/helpers/host/organization-repository.memory.ts index 2b302c34..1ed4e23b 100644 --- a/services/api/test/helpers/host/organization-repository.memory.ts +++ b/services/api/test/helpers/host/organization-repository.memory.ts @@ -45,15 +45,17 @@ import type { UpdateOrganizationAudit, UpdateOrganizationOutcome, UpdateOrganizationPatch, +} from "../../../src/services/host/organization-repository.js" +import type { InviterRevocationReason, InviterStanding, -} from "../../../src/services/host/organization-repository.js" +} from "../../../src/services/host/organization-rules.js" import { canManageOrgMembers, inviterRevocationReason, roleChangeWithdrawsInvites, -} from "../../../src/services/host/organization-repository.js" -import { ORG_INVITE_CAP_MESSAGE } from "../../../src/services/host/organization-repository.js" +} from "../../../src/services/host/organization-rules.js" +import { ORG_INVITE_CAP_MESSAGE } from "../../../src/services/host/organization-rules.js" import { encodeTimeCursor } from "../../../src/db/cursor-helpers.js" const SEED_DISPLAY_NAME = "Member"