From b83a27147249f2b6ff961fd92dba26f2c99b610e Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 07:21:15 +0000 Subject: [PATCH 01/10] remove shared value exports no consumer uses --- packages/shared/__tests__/address.test.ts | 4 -- .../shared/__tests__/admin-schemas.test.ts | 11 ++- .../__tests__/client-response-parsing.test.ts | 15 ++-- packages/shared/__tests__/geocode.test.ts | 23 +----- .../shared/__tests__/host-insights.test.ts | 2 - .../shared/__tests__/host-schemas.test.ts | 8 +-- .../shared/__tests__/schema-additions.test.ts | 11 +-- packages/shared/__tests__/schemas.test.ts | 8 +-- packages/shared/__tests__/versioning.test.ts | 11 +-- packages/shared/__tests__/volunteer.test.ts | 10 +-- packages/shared/src/address.ts | 4 -- packages/shared/src/client/client.ts | 5 -- packages/shared/src/client/versioning.ts | 3 - packages/shared/src/geocode.ts | 25 ------- .../shared/src/host/__tests__/derive.test.ts | 72 ++++++++----------- packages/shared/src/host/derive.ts | 56 --------------- packages/shared/src/host/index.ts | 1 - .../src/schemas/admin/forward-template.ts | 4 -- packages/shared/src/schemas/common.ts | 31 +------- .../shared/src/schemas/host/broadcasts.ts | 4 +- packages/shared/src/schemas/host/insights.ts | 3 - packages/shared/src/schemas/volunteer.ts | 12 +--- .../tokens/__tests__/chip-contrast.test.ts | 12 ---- packages/shared/src/tokens/chip-contrast.ts | 10 --- 24 files changed, 67 insertions(+), 278 deletions(-) diff --git a/packages/shared/__tests__/address.test.ts b/packages/shared/__tests__/address.test.ts index 202ab0a3..ca4a8b1a 100644 --- a/packages/shared/__tests__/address.test.ts +++ b/packages/shared/__tests__/address.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest" import { ADDRESS_PRECISION_LADDER, - comparePrecision, geocodePointKey, isLocatedPrecision, isVerifiedEventAddress, @@ -39,9 +38,6 @@ describe("address precision ladder", () => { "locality", ]) expect(AddressPrecisionSchema.options).toEqual([...ADDRESS_PRECISION_LADDER]) - expect(comparePrecision("street", "locality")).toBeLessThan(0) - expect(comparePrecision("landmark", "intersection")).toBeGreaterThan(0) - expect(comparePrecision("street", "street")).toBe(0) }) it("treats street/intersection/landmark as located and locality/null as not", () => { diff --git a/packages/shared/__tests__/admin-schemas.test.ts b/packages/shared/__tests__/admin-schemas.test.ts index 2e041d22..708b7532 100644 --- a/packages/shared/__tests__/admin-schemas.test.ts +++ b/packages/shared/__tests__/admin-schemas.test.ts @@ -39,7 +39,6 @@ import { DEFAULT_FORWARD_BODY_TEMPLATE, DEFAULT_FORWARD_SUBJECT_TEMPLATE, FORWARD_TEMPLATE_SAMPLE_VALUES, - FORWARD_TEMPLATE_VARIABLE_NAMES, FORWARD_TEMPLATE_VARIABLES, PreviewForwardTemplateRequestSchema, SetForwardTemplateDefaultRequestSchema, @@ -300,6 +299,8 @@ describe("discovery schemas", () => { }) describe("jurisdictions schemas", () => { + const paletteNames = FORWARD_TEMPLATE_VARIABLES.map((v) => v.token.slice(1, -1)) + it("SaveContactsRequest takes a per-category email map + form url and rejects a bad category", () => { expect( SaveContactsRequestSchema.safeParse({ @@ -393,14 +394,12 @@ describe("jurisdictions schemas", () => { }) it("the palette never exposes the reporter's identity or an unmodelled department", () => { - expect(FORWARD_TEMPLATE_VARIABLE_NAMES).not.toContain("reporterName") - expect(FORWARD_TEMPLATE_VARIABLE_NAMES).not.toContain("dept") + expect(paletteNames).not.toContain("reporterName") + expect(paletteNames).not.toContain("dept") }) it("sample values and the built-in defaults cover exactly the palette", () => { - expect(Object.keys(FORWARD_TEMPLATE_SAMPLE_VALUES).sort()).toEqual( - [...FORWARD_TEMPLATE_VARIABLE_NAMES].sort(), - ) + expect(Object.keys(FORWARD_TEMPLATE_SAMPLE_VALUES).sort()).toEqual([...paletteNames].sort()) expect(forwardTemplateIssues(DEFAULT_FORWARD_SUBJECT_TEMPLATE)).toEqual([]) expect(forwardTemplateIssues(DEFAULT_FORWARD_BODY_TEMPLATE)).toEqual([]) expect(interpolateForwardTemplate(DEFAULT_FORWARD_SUBJECT_TEMPLATE, FORWARD_TEMPLATE_SAMPLE_VALUES)).toBe( diff --git a/packages/shared/__tests__/client-response-parsing.test.ts b/packages/shared/__tests__/client-response-parsing.test.ts index b2abf43f..0d18bf1c 100644 --- a/packages/shared/__tests__/client-response-parsing.test.ts +++ b/packages/shared/__tests__/client-response-parsing.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { createApiClient, parseResponse, resetResponseWarnings } from "../src/client/client.js" -import { endpoints } from "../src/client/endpoints.js" +import type * as ClientModule from "../src/client/client.js" +import type * as EndpointsModule from "../src/client/endpoints.js" /** * The typed client parses 2xx bodies against the endpoint's response schema, so the registry's @@ -42,13 +42,20 @@ function legacyCleanup() { } } +let createApiClient: typeof ClientModule.createApiClient +let parseResponse: typeof ClientModule.parseResponse +let endpoints: typeof EndpointsModule.endpoints + function clientReturning(body: unknown) { const fetchImpl = vi.fn(async () => jsonResponse(body)) as unknown as typeof fetch return createApiClient({ baseURL: "https://api.civfix.test", fetchImpl }) } -beforeEach(() => { - resetResponseWarnings() +// A fresh module per test resets the client's once-per-endpoint warning memo. +beforeEach(async () => { + vi.resetModules() + ;({ createApiClient, parseResponse } = await import("../src/client/client.js")) + ;({ endpoints } = await import("../src/client/endpoints.js")) }) afterEach(() => { diff --git a/packages/shared/__tests__/geocode.test.ts b/packages/shared/__tests__/geocode.test.ts index 87815935..80738983 100644 --- a/packages/shared/__tests__/geocode.test.ts +++ b/packages/shared/__tests__/geocode.test.ts @@ -5,14 +5,12 @@ import { photonSuggest, mapboxSuggest, suggestAddresses, - ipLocate, } from "../src/geocode.js" /** - * Tests for the unified forward geocoder. parseLatLng is pure; photonSuggest, mapboxSuggest, - * suggestAddresses, and ipLocate are exercised against a stubbed global fetch so the coordinate - * short-circuit, the Mapbox->Photon fallback, abort propagation, and the IP lookup are covered without - * hitting Photon / Mapbox / GeoJS. + * Tests for the unified forward geocoder. parseLatLng is pure; photonSuggest, mapboxSuggest and + * suggestAddresses are exercised against a stubbed global fetch so the coordinate short-circuit, the + * Mapbox->Photon fallback and abort propagation are covered without hitting Photon / Mapbox. */ afterEach(() => { @@ -329,18 +327,3 @@ describe("suggestion language/country options", () => { expect(new URL(urls[1]!).searchParams.get("country")).toBeNull() }) }) - -describe("ipLocate", () => { - it("parses lat/lng from GeoJS", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(JSON.stringify({ latitude: "34.05", longitude: "-118.24" }), { status: 200 })), - ) - expect(await ipLocate()).toEqual({ lat: 34.05, lng: -118.24 }) - }) - - it("returns null on failure", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 }))) - expect(await ipLocate()).toBeNull() - }) -}) diff --git a/packages/shared/__tests__/host-insights.test.ts b/packages/shared/__tests__/host-insights.test.ts index 512f00b6..4fd0fe5a 100644 --- a/packages/shared/__tests__/host-insights.test.ts +++ b/packages/shared/__tests__/host-insights.test.ts @@ -5,7 +5,6 @@ import { } from "../src/schemas/common.js" import { ArrivalOffsetBucketSchema, - EventInsightsSchema, EventPhaseSchema, GetEventInsightsRequestSchema, GetEventInsightsResponseSchema, @@ -98,7 +97,6 @@ describe("getEventInsights contract", () => { expect(parsed.arrivals).toEqual([]) expect(parsed.phase).toBe("upcoming") expect(parsed.returning).toBeNull() - expect(EventInsightsSchema.parse(minimalInsights())).toEqual(parsed) }) it("keeps every count a seat count and every seat count non-negative", () => { diff --git a/packages/shared/__tests__/host-schemas.test.ts b/packages/shared/__tests__/host-schemas.test.ts index 2ffdf7f8..52cc67a4 100644 --- a/packages/shared/__tests__/host-schemas.test.ts +++ b/packages/shared/__tests__/host-schemas.test.ts @@ -35,7 +35,7 @@ import { MAX_PORTFOLIO_TOP_VOLUNTEERS, } from "../src/schemas/host/analytics.js" import { - EventInsightsSchema, + GetEventInsightsResponseSchema, MAX_INSIGHTS_TOP_VOLUNTEERS, } from "../src/schemas/host/insights.js" import { NotificationTypeSchema } from "../src/schemas/notifications.js" @@ -1174,14 +1174,14 @@ describe("hours on the host read models (DECISIONS §39)", () => { }) it("defaults EventInsights.topVolunteers to [] and caps it", () => { - expect(EventInsightsSchema.parse(insights()).topVolunteers).toEqual([]) - expect(EventInsightsSchema.parse(insights({ topVolunteers: [volunteer(1)] })).topVolunteers) + expect(GetEventInsightsResponseSchema.parse(insights()).topVolunteers).toEqual([]) + expect(GetEventInsightsResponseSchema.parse(insights({ topVolunteers: [volunteer(1)] })).topVolunteers) .toHaveLength(1) expect(MAX_INSIGHTS_TOP_VOLUNTEERS).toBe(5) const overflow = Array.from({ length: MAX_INSIGHTS_TOP_VOLUNTEERS + 1 }, (_row, i) => volunteer(i + 1), ) - expect(EventInsightsSchema.safeParse(insights({ topVolunteers: overflow })).success).toBe(false) + expect(GetEventInsightsResponseSchema.safeParse(insights({ topVolunteers: overflow })).success).toBe(false) }) it("keeps the org hours stats optional so a new org never reads zero", () => { diff --git a/packages/shared/__tests__/schema-additions.test.ts b/packages/shared/__tests__/schema-additions.test.ts index 1612a7af..89a456f2 100644 --- a/packages/shared/__tests__/schema-additions.test.ts +++ b/packages/shared/__tests__/schema-additions.test.ts @@ -7,7 +7,7 @@ import { REPORT_TYPE_VALUES, REPORT_TYPE_LABELS, REPORT_TYPE_TO_CATEGORY, - WEB_REPORT_TYPE_BY_ID, + WEB_REPORT_TYPES, LatLngFields, MediaPurposeSchema, } from "../src/schemas/common.js" @@ -403,10 +403,11 @@ describe("canonical report type taxonomy", () => { }) it("WEB_REPORT_TYPES categories agree with REPORT_TYPE_TO_CATEGORY for the overlapping ids", () => { - expect(WEB_REPORT_TYPE_BY_ID.infrastructure.category).toBe(REPORT_TYPE_TO_CATEGORY.infrastructure) - expect(WEB_REPORT_TYPE_BY_ID.vegetation.category).toBe(REPORT_TYPE_TO_CATEGORY.vegetation) - expect(WEB_REPORT_TYPE_BY_ID.infrastructure.category).toBe("water") - expect(WEB_REPORT_TYPE_BY_ID.vegetation.category).toBe("recycling") + const categoryOf = (id: string) => WEB_REPORT_TYPES.find((t) => t.id === id)?.category + expect(categoryOf("infrastructure")).toBe(REPORT_TYPE_TO_CATEGORY.infrastructure) + expect(categoryOf("vegetation")).toBe(REPORT_TYPE_TO_CATEGORY.vegetation) + expect(categoryOf("infrastructure")).toBe("water") + expect(categoryOf("vegetation")).toBe("recycling") }) it("CreateReportRequest requires a type", () => { diff --git a/packages/shared/__tests__/schemas.test.ts b/packages/shared/__tests__/schemas.test.ts index 1653e75d..71a8d01a 100644 --- a/packages/shared/__tests__/schemas.test.ts +++ b/packages/shared/__tests__/schemas.test.ts @@ -7,8 +7,6 @@ import { PaginationQuerySchema, pageResponse, WEB_REPORT_TYPES, - WEB_REPORT_TYPE_BY_ID, - webReportTypeToCategory, PushPlatformSchema, OAuthProviderSchema, CleanupMemberRoleSchema, @@ -96,11 +94,9 @@ describe("common enums + taxonomy", () => { it("WEB_REPORT_TYPES map to valid canonical categories", () => { for (const t of WEB_REPORT_TYPES) { expect(ReportCategorySchema.safeParse(t.category).success).toBe(true) - expect(t.gov.email).toMatch(/@/) } - expect(WEB_REPORT_TYPE_BY_ID.graffiti.category).toBe("graffiti") - expect(webReportTypeToCategory("dump")).toBe("trash") - expect(webReportTypeToCategory("unknown-id")).toBe("other") + expect(WEB_REPORT_TYPES.find((t) => t.id === "graffiti")?.category).toBe("graffiti") + expect(WEB_REPORT_TYPES.find((t) => t.id === "dump")?.category).toBe("trash") }) }) diff --git a/packages/shared/__tests__/versioning.test.ts b/packages/shared/__tests__/versioning.test.ts index cab81197..887d4166 100644 --- a/packages/shared/__tests__/versioning.test.ts +++ b/packages/shared/__tests__/versioning.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect } from "vitest" -import { - versionedPath, - API_VERSIONS, - LATEST_API_VERSION, -} from "../src/client/versioning.js" +import { versionedPath } from "../src/client/versioning.js" import { endpoints } from "../src/client/endpoints.js" describe("versionedPath", () => { @@ -20,11 +16,6 @@ describe("versionedPath", () => { "/auth/google/start", ) }) - - it("exposes the supported versions and the latest", () => { - expect(API_VERSIONS).toEqual(["v1"]) - expect(LATEST_API_VERSION).toBe("v1") - }) }) describe("endpoint version invariants", () => { diff --git a/packages/shared/__tests__/volunteer.test.ts b/packages/shared/__tests__/volunteer.test.ts index b8061336..6b8b06a8 100644 --- a/packages/shared/__tests__/volunteer.test.ts +++ b/packages/shared/__tests__/volunteer.test.ts @@ -14,7 +14,6 @@ import { PublicVolunteerHoursResponseSchema, EventHoursQuerySchema, EventHoursResponseSchema, - REPORT_VOLUNTEER_HOURS, VOLUNTEER_HOURS_SOURCES, VolunteerHoursSourceSchema, MAX_EVENT_HOURS, @@ -275,13 +274,8 @@ describe("volunteer DTOs", () => { expect(MAX_EVENT_HOURS).toBe(24) }) - /** - * REPORT_VOLUNTEER_HOURS only documents what the historical `source='report'` rows are worth, so its - * value is frozen. `"report"` stays in VOLUNTEER_HOURS_SOURCES because old ledger rows, issued - * certificate snapshots and older servers still carry it. - */ - it("the retired report auto-award constant is frozen, and its source stays parseable", () => { - expect(REPORT_VOLUNTEER_HOURS).toBe(0.1) + // Old ledger rows, issued certificate snapshots and older servers still carry "report". + it("the retired report source stays parseable", () => { expect(VOLUNTEER_HOURS_SOURCES).toContain("report") expect(VolunteerHoursSourceSchema.safeParse("report").success).toBe(true) }) diff --git a/packages/shared/src/address.ts b/packages/shared/src/address.ts index 757859c4..39111544 100644 --- a/packages/shared/src/address.ts +++ b/packages/shared/src/address.ts @@ -17,10 +17,6 @@ export function needsNearPrefix(precision: AddressPrecision | null | undefined): return precision === "landmark" } -export function comparePrecision(a: AddressPrecision, b: AddressPrecision): number { - return ADDRESS_PRECISION_LADDER.indexOf(a) - ADDRESS_PRECISION_LADDER.indexOf(b) -} - export function isVerifiedEventAddress( addressSource: EventAddressSource | null | undefined, address: string | null | undefined, diff --git a/packages/shared/src/client/client.ts b/packages/shared/src/client/client.ts index a7ca06e3..6fec9168 100644 --- a/packages/shared/src/client/client.ts +++ b/packages/shared/src/client/client.ts @@ -136,11 +136,6 @@ for (const name of Object.keys(endpoints) as EndpointName[]) { /** Endpoint names already warned about, so a mismatching server logs once instead of per call. */ const warnedEndpoints = new Set() -/** Reset the once-per-endpoint warning memo. Exported for unit testing only. */ -export function resetResponseWarnings(): void { - warnedEndpoints.clear() -} - /** * Run a 2xx body through the endpoint's response schema so the DTOs' `.default()`s and `.catch()`es * actually apply on the read path; without this the inferred types lie whenever the deployed server is diff --git a/packages/shared/src/client/versioning.ts b/packages/shared/src/client/versioning.ts index bf4c2ef9..afecfa3e 100644 --- a/packages/shared/src/client/versioning.ts +++ b/packages/shared/src/client/versioning.ts @@ -10,9 +10,6 @@ export type ApiVersion = "v1" export type EndpointVersion = ApiVersion | "unversioned" -export const API_VERSIONS = ["v1"] as const satisfies readonly ApiVersion[] -export const LATEST_API_VERSION: ApiVersion = "v1" - /** The ONE place version->path lives; the backend route() helper imports this too. */ export function versionedPath(ep: { version: EndpointVersion; path: string }): string { return ep.version === "unversioned" ? ep.path : `/${ep.version}${ep.path}` diff --git a/packages/shared/src/geocode.ts b/packages/shared/src/geocode.ts index ec3f811d..4b897663 100644 --- a/packages/shared/src/geocode.ts +++ b/packages/shared/src/geocode.ts @@ -307,28 +307,3 @@ export async function suggestAddresses(query: string, opts: SuggestOptions = {}) return [] } } - -const GEOJS_URL = "https://get.geojs.io/v1/ip/geo.json" - -/** - * Best-effort IP geolocation (no permission prompt) via GeoJS - a free, CORS-enabled, key-less HTTPS - * endpoint. Returns null on any failure so callers can fall back to a map center. Used as the proximity - * source when device location sharing is denied or unavailable. - * - * @deprecated since 0.47.0. A third-party data flow with no consumer-plane callers left. Use - * `GET /geo/approximate` (`getApproximateLocation`, DECISIONS #45). - */ -export async function ipLocate(signal?: AbortSignal): Promise { - try { - const res = await fetch(GEOJS_URL, { signal, headers: { Accept: "application/json" } }) - if (!res.ok) return null - const data = (await res.json()) as { latitude?: string | number; longitude?: string | number } - const lat = Number(data.latitude) - const lng = Number(data.longitude) - if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null - if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null - return { lat, lng } - } catch { - return null - } -} diff --git a/packages/shared/src/host/__tests__/derive.test.ts b/packages/shared/src/host/__tests__/derive.test.ts index db4c824e..aa483d16 100644 --- a/packages/shared/src/host/__tests__/derive.test.ts +++ b/packages/shared/src/host/__tests__/derive.test.ts @@ -5,15 +5,30 @@ import { arrivalsCurve, bestDayTime, breakdown, - cumulativeSeries, dailySeries, enumerateDays, funnel, - hourlySeries, - registrationSeries, - repeatAttendanceRate, + type DayCount, + type DayRange, + type DerivedSeriesPanel, + type SuppressOptions, } from "../derive.js" +// The cumulative panel as the backend publishes it: the closure's running totals, and the total only +// when the closure marks it publishable. +function cumulativeView( + points: readonly DayCount[], + range: DayRange, + options: SuppressOptions = {}, +): DerivedSeriesPanel { + const closure = seriesClosure(points, range, options) + return { + panelSuppressed: closure.panelSuppressed, + total: closure.totalPublishable ? closure.total : null, + points: closure.cumulative, + } +} + describe("enumerateDays", () => { it("gap-fills an inclusive range across a month boundary", () => { expect(enumerateDays({ from: "2026-01-30", to: "2026-02-02" })).toEqual([ @@ -92,30 +107,6 @@ describe("dailySeries", () => { expect(panel.total).toBe(7) expect(panel.points[0]?.value).toBe(7) }) - - it("is aliased as registrationSeries and is deterministic", () => { - const points = [{ day: "2026-05-04", count: 8 }] - expect(registrationSeries(points, range)).toEqual(dailySeries(points, range)) - }) -}) - -describe("hourlySeries", () => { - it("always returns 24 gap-filled buckets", () => { - const panel = hourlySeries([ - { hour: 9, count: 4 }, - { hour: 9, count: 3 }, - { hour: 30, count: 99 }, - ]) - expect(panel.points).toHaveLength(24) - expect(panel.points[9]?.value).toBe(7) - expect(panel.total).toBe(7) - }) - - it("suppresses the panel under k", () => { - const panel = hourlySeries([{ hour: 1, count: 2 }]) - expect(panel.panelSuppressed).toBe(true) - expect(panel.points.every((p) => p.value === null)).toBe(true) - }) }) describe("breakdown", () => { @@ -268,18 +259,11 @@ describe("bestDayTime", () => { }) }) -describe("repeatAttendanceRate", () => { - it("is a rate and never a list", () => { - expect(repeatAttendanceRate(3, 12)).toEqual({ suppressed: false, value: 0.25 }) - expect(repeatAttendanceRate(1, 4)).toEqual({ suppressed: true, value: null }) - }) -}) - -describe("cumulativeSeries", () => { +describe("seriesClosure cumulative view", () => { const range = { from: "2026-05-01", to: "2026-05-05" } it("publishes no step at all once the hidden tail could be pinned by the total", () => { - const panel = cumulativeSeries( + const panel = cumulativeView( [ { day: "2026-05-01", count: 6 }, { day: "2026-05-02", count: 2 }, @@ -298,7 +282,7 @@ describe("cumulativeSeries", () => { }) it("waits for the hidden group to reach a revealable band, then shows the total again", () => { - const panel = cumulativeSeries( + const panel = cumulativeView( [ { day: "2026-05-01", count: 10 }, { day: "2026-05-02", count: 3 }, @@ -312,19 +296,19 @@ describe("cumulativeSeries", () => { }) it("suppresses the whole panel below k", () => { - const panel = cumulativeSeries([{ day: "2026-05-02", count: 3 }], range) + const panel = cumulativeView([{ day: "2026-05-02", count: 3 }], range) expect(panel.panelSuppressed).toBe(true) expect(panel.total).toBeNull() expect(panel.points.every((p) => p.value === null && p.suppressed)).toBe(true) }) it("shows a leading run of zeroes without disclosing anything", () => { - const panel = cumulativeSeries([{ day: "2026-05-05", count: 9 }], range) + const panel = cumulativeView([{ day: "2026-05-05", count: 9 }], range) expect(panel.points.map((p) => p.value)).toEqual([0, 0, 0, 0, 9]) }) it("ignores days outside the range", () => { - const panel = cumulativeSeries( + const panel = cumulativeView( [ { day: "2026-04-30", count: 100 }, { day: "2026-05-02", count: 8 }, @@ -347,7 +331,7 @@ describe("complementary suppression across the daily, cumulative and total views it("leaks the sub-k day through NO combination of the three published views", () => { const daily = dailySeries(points, range, { suppressPoints: true }) - const cumulative = cumulativeSeries(points, range) + const cumulative = cumulativeView(points, range) expect(daily.points.map((p) => p.value)).toEqual([6, null, 7]) expect(daily.total).toBeNull() @@ -376,7 +360,7 @@ describe("complementary suppression across the daily, cumulative and total views { day: "2026-05-03", count: 7 }, ] const daily = dailySeries(open, range, { suppressPoints: true }) - const cumulative = cumulativeSeries(open, range) + const cumulative = cumulativeView(open, range) expect(daily.points.map((p) => p.value)).toEqual([6, 5, 7]) expect(daily.total).toBe(18) expect(cumulative.points.map((p) => p.value)).toEqual([6, 11, 18]) @@ -404,7 +388,7 @@ function publishedOf(values: readonly number[], k = K) { const range = rangeOf(values.length) const points = countsOf(values) const daily = dailySeries(points, range, { suppressPoints: true, k }) - const cumulative = cumulativeSeries(points, range, { k }) + const cumulative = cumulativeView(points, range, { k }) return { daily: daily.points.map((p) => p.value), dailyTotal: daily.total, diff --git a/packages/shared/src/host/derive.ts b/packages/shared/src/host/derive.ts index efb139e9..3be854b7 100644 --- a/packages/shared/src/host/derive.ts +++ b/packages/shared/src/host/derive.ts @@ -1,6 +1,5 @@ import { intOr } from "../internal/numbers.js" import { K_SUPPRESS, normalizeK, roundRate, safeCount } from "./counts.js" -import { suppressRate, type SuppressedRatio } from "./suppress.js" export const MAX_SERIES_DAYS = 400 export const MAX_ARRIVAL_BUCKETS = 200 @@ -143,10 +142,6 @@ function utcMsToDay(ms: number): string { return `${year}-${month}-${date}` } -export function isCalendarDay(day: string): boolean { - return Number.isFinite(dayToUtcMs(day)) -} - export function enumerateDays(range: DayRange): string[] { const from = dayToUtcMs(range.from) const to = dayToUtcMs(range.to) @@ -304,36 +299,6 @@ export function dailySeries( } } -export function registrationSeries( - points: readonly DayCount[], - range: DayRange, - options: SuppressOptions = {}, -): DerivedSeriesPanel { - return dailySeries(points, range, options) -} - -export function hourlySeries(points: readonly HourCount[], options: SuppressOptions = {}): DerivedHourPanel { - const k = normalizeK(options.k) - const byHour = new Array(24).fill(0) - for (const point of points) { - if (!Number.isInteger(point.hour) || point.hour < 0 || point.hour > 23) continue - byHour[point.hour] = (byHour[point.hour] ?? 0) + safeCount(point.count) - } - const total = byHour.reduce((sum, value) => sum + value, 0) - const panelSuppressed = total < k - const suppressPoints = options.suppressPoints === true - const hidden = suppressPoints ? hiddenGroupOf(byHour, k) : { count: 0, mass: 0 } - const totalPublishable = !panelSuppressed && groupRevealable(hidden.count, hidden.mass, k) - return { - panelSuppressed, - total: totalPublishable ? total : null, - points: byHour.map((value, hour) => { - const suppressed = panelSuppressed || (suppressPoints && value < k) - return { hour, value: suppressed ? null : value, suppressed } - }), - } -} - export function breakdownClosure( rows: readonly KeyCount[], options: BreakdownOptions = {}, @@ -455,24 +420,3 @@ export function bestDayTime(cells: readonly DayTimeCount[], k: number = K_SUPPRE if (best === null || best.value < threshold) return null return best } - -export function repeatAttendanceRate( - repeatAttendees: number, - totalAttendees: number, - k: number = K_SUPPRESS, -): SuppressedRatio { - return suppressRate(repeatAttendees, totalAttendees, k) -} - -export function cumulativeSeries( - points: readonly DayCount[], - range: DayRange, - options: SuppressOptions = {}, -): DerivedSeriesPanel { - const closure = seriesClosure(points, range, options) - return { - panelSuppressed: closure.panelSuppressed, - total: closure.totalPublishable ? closure.total : null, - points: closure.cumulative, - } -} diff --git a/packages/shared/src/host/index.ts b/packages/shared/src/host/index.ts index 14ed1be2..f9ff64fb 100644 --- a/packages/shared/src/host/index.ts +++ b/packages/shared/src/host/index.ts @@ -11,4 +11,3 @@ export * from "./checkin-result.js" export * from "./roster-seats.js" export * from "./registration.js" export * from "./day-label.js" -export * from "../markdown/safe-url.js" diff --git a/packages/shared/src/schemas/admin/forward-template.ts b/packages/shared/src/schemas/admin/forward-template.ts index 675e39fd..6be00b9f 100644 --- a/packages/shared/src/schemas/admin/forward-template.ts +++ b/packages/shared/src/schemas/admin/forward-template.ts @@ -87,10 +87,6 @@ export const FORWARD_TEMPLATE_VARIABLES: readonly ForwardTemplateVariable[] = [ }, ] -export const FORWARD_TEMPLATE_VARIABLE_NAMES: readonly string[] = FORWARD_TEMPLATE_VARIABLES.map((v) => - v.token.slice(1, -1), -) - export type ForwardTemplateValues = Record export const FORWARD_TEMPLATE_SAMPLE_VALUES: Readonly = { diff --git a/packages/shared/src/schemas/common.ts b/packages/shared/src/schemas/common.ts index 306a2594..11475832 100644 --- a/packages/shared/src/schemas/common.ts +++ b/packages/shared/src/schemas/common.ts @@ -66,8 +66,7 @@ export const ISODateSchema = z .transform((d) => d.toISOString()) export type ISODate = string -export const H3CellSchema = z.string().min(1) -export type H3Cell = z.infer +export type H3Cell = string export const AppErrorSchema = z.object({ code: z.string(), @@ -163,10 +162,6 @@ export const REPORT_TYPE_CODE: Record = { other: "OT", } -export const REPORT_CODE_TO_TYPE = Object.fromEntries( - Object.entries(REPORT_TYPE_CODE).map(([t, c]) => [c, t]), -) as Record - export const GeomSourceSchema = z.enum(["device", "exif", "manual"]) export type GeomSource = z.infer @@ -416,17 +411,10 @@ export type LegalDocumentType = z.infer export const ConsentSurfaceSchema = z.enum(["web_register", "mobile_register", "onboarding"]) export type ConsentSurface = z.infer - -export interface GovTarget { - name: string - email: string -} - export interface WebReportType { id: string label: string category: ReportCategory - gov: GovTarget } export const WEB_REPORT_TYPES = [ @@ -434,59 +422,42 @@ export const WEB_REPORT_TYPES = [ id: "dump", label: "Illegal dumping", category: "trash", - gov: { name: "LA Bureau of Sanitation", email: "sanitation@lacity.gov" }, }, { id: "encampment", label: "Encampment", category: "encampment", - gov: { name: "LA Bureau of Sanitation", email: "sanitation@lacity.gov" }, }, { id: "graffiti", label: "Graffiti", category: "graffiti", - gov: { name: "Office of Community Beautification", email: "ocb@lacity.gov" }, }, { id: "infrastructure", label: "Broken infrastructure", category: "water", - gov: { name: "LA Bureau of Street Services", email: "streetservices@lacity.gov" }, }, { id: "pavement", label: "Pavement distress", category: "hazard", - gov: { name: "LA Bureau of Street Services", email: "streetservices@lacity.gov" }, }, { id: "vegetation", label: "Overgrown vegetation", category: "recycling", - gov: { name: "LA Bureau of Street Services", email: "streetservices@lacity.gov" }, }, { id: "water", label: "Water/leak", category: "water", - gov: { name: "LADWP", email: "customerservice@ladwp.com" }, }, { id: "recycling", label: "Recycling", category: "recycling", - gov: { name: "LA Bureau of Sanitation", email: "sanitation@lacity.gov" }, }, ] as const satisfies readonly WebReportType[] export type WebReportTypeId = (typeof WEB_REPORT_TYPES)[number]["id"] - -export const WEB_REPORT_TYPE_BY_ID: Record = Object.fromEntries( - WEB_REPORT_TYPES.map((t) => [t.id, t]), -) as Record - -export function webReportTypeToCategory(id: string): ReportCategory { - const entry = WEB_REPORT_TYPE_BY_ID[id as WebReportTypeId] - return entry ? entry.category : "other" -} diff --git a/packages/shared/src/schemas/host/broadcasts.ts b/packages/shared/src/schemas/host/broadcasts.ts index 93d0a976..74e6b400 100644 --- a/packages/shared/src/schemas/host/broadcasts.ts +++ b/packages/shared/src/schemas/host/broadcasts.ts @@ -51,13 +51,11 @@ export const HostBroadcastChannelsSchema = z .max(3) .refine(hostBroadcastChannelsValid, { message: PUSH_REQUIRES_INAPP_MESSAGE }) -export const HttpsCtaUrlSchema = HttpsUrlSchema - const BroadcastDraftFields = { subject: z.string().trim().min(1).max(MAX_BROADCAST_SUBJECT), bodyMd: z.string().trim().min(1).max(MAX_BROADCAST_BODY), ctaLabel: z.string().trim().max(MAX_BROADCAST_CTA_LABEL).nullable().optional(), - ctaUrl: HttpsCtaUrlSchema.nullable().optional(), + ctaUrl: HttpsUrlSchema.nullable().optional(), segment: BroadcastSegmentSchema, channels: HostBroadcastChannelsSchema, } as const diff --git a/packages/shared/src/schemas/host/insights.ts b/packages/shared/src/schemas/host/insights.ts index feb531df..fc5e8f96 100644 --- a/packages/shared/src/schemas/host/insights.ts +++ b/packages/shared/src/schemas/host/insights.ts @@ -106,9 +106,6 @@ const EventInsightsObjectSchema = z.object({ }) export type EventInsights = z.infer -export const EventInsightsSchema: z.ZodType = - EventInsightsObjectSchema - export const GetEventInsightsRequestSchema = z.object({ id: IdSchema }).strict() export type GetEventInsightsRequest = z.infer diff --git a/packages/shared/src/schemas/volunteer.ts b/packages/shared/src/schemas/volunteer.ts index 971eb951..35bdb72b 100644 --- a/packages/shared/src/schemas/volunteer.ts +++ b/packages/shared/src/schemas/volunteer.ts @@ -9,20 +9,14 @@ import { /** * `"report"` is historical only and must stay in this list. Nothing writes a report credit any more - * (filing a report is not volunteer service), but old ledger rows, the frozen `snapshot` of every - * already-issued certificate, and older servers still carry the value, so dropping it would make those - * payloads fail to parse. Do not add a new source that credits reports. + * (filing a report is not volunteer service), but old ledger rows (0.1 hours each), the frozen + * `snapshot` of every already-issued certificate, and older servers still carry the value, so dropping + * it would make those payloads fail to parse. Do not add a new source that credits reports. */ export const VOLUNTEER_HOURS_SOURCES = ["report", "event", "manual"] as const export const VolunteerHoursSourceSchema = z.enum(VOLUNTEER_HOURS_SOURCES) export type VolunteerHoursSource = z.infer -/** - * @deprecated Filing a report is not volunteer service and nothing credits it. The constant only - * documents what the historical `source='report'` rows are worth; crediting anything with it would put - * report filings back on the public leaderboard and on signed PDF transcripts. - */ -export const REPORT_VOLUNTEER_HOURS = 0.1 /** Smallest creditable event-hours amount (2-dp minimum; rounding is enforced backend-side). */ export const MIN_EVENT_HOURS = 0.01 export const MAX_EVENT_HOURS = 24 diff --git a/packages/shared/src/tokens/__tests__/chip-contrast.test.ts b/packages/shared/src/tokens/__tests__/chip-contrast.test.ts index 5a332904..25b8cb7b 100644 --- a/packages/shared/src/tokens/__tests__/chip-contrast.test.ts +++ b/packages/shared/src/tokens/__tests__/chip-contrast.test.ts @@ -4,9 +4,7 @@ import { CHIP_HUE_NAMES, MIN_CHIP_RATIO, chipHuePairs, - chipPairPasses, contrastRatio, - mixWithWhite, relativeLuminance, } from "../chip-contrast.js" @@ -24,14 +22,6 @@ describe("contrast math", () => { expect(contrastRatio("#356291", "#E9F1FA")).toBeCloseTo(contrastRatio("#E9F1FA", "#356291"), 12) }) - it("mixes toward white and clamps the weight", () => { - expect(mixWithWhite("#000000", 1)).toBe("#000000") - expect(mixWithWhite("#000000", 0)).toBe("#FFFFFF") - expect(mixWithWhite("#000000", 0.5)).toBe("#808080") - expect(mixWithWhite("#000000", 5)).toBe("#000000") - expect(mixWithWhite("#000000", -5)).toBe("#FFFFFF") - }) - it("refuses a malformed hex instead of silently scoring garbage", () => { expect(() => relativeLuminance("#FFF")).toThrow(RangeError) expect(() => contrastRatio("nope", "#FFFFFF")).toThrow(RangeError) @@ -41,7 +31,6 @@ describe("contrast math", () => { expect(() => relativeLuminance("#1G2233")).toThrow(RangeError) expect(() => relativeLuminance("#11223Z")).toThrow(RangeError) expect(() => relativeLuminance("12#3456")).toThrow(RangeError) - expect(() => mixWithWhite("#-12233", 0.5)).toThrow(RangeError) expect(relativeLuminance("ffffff")).toBeCloseTo(1, 10) }) }) @@ -68,7 +57,6 @@ describe("chipHuePairs", () => { ratio, `${scheme} ${pair.name} chip ink ${pair.text} on ${pair.bg} is ${ratio.toFixed(2)}:1`, ).toBeGreaterThanOrEqual(MIN_CHIP_RATIO) - expect(chipPairPasses(pair)).toBe(true) } } }) diff --git a/packages/shared/src/tokens/chip-contrast.ts b/packages/shared/src/tokens/chip-contrast.ts index da0ae4ad..c8f1f2d8 100644 --- a/packages/shared/src/tokens/chip-contrast.ts +++ b/packages/shared/src/tokens/chip-contrast.ts @@ -36,12 +36,6 @@ export function contrastRatio(a: string, b: string): number { return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) } -export function mixWithWhite(hex: string, colorWeight: number): string { - const weight = Math.min(1, Math.max(0, colorWeight)) - const mixed = channels(hex).map((value) => Math.round(weight * value + (1 - weight) * 255)) - return `#${mixed.map((c) => c.toString(16).padStart(2, "0")).join("").toUpperCase()}` -} - export function chipHuePairs(scheme: ColorSchemeName): readonly ChipHuePair[] { const palette = colorSchemes[scheme] return CHIP_HUE_NAMES.map((name) => ({ @@ -51,7 +45,3 @@ export function chipHuePairs(scheme: ColorSchemeName): readonly ChipHuePair[] { minRatio: MIN_CHIP_RATIO, })) } - -export function chipPairPasses(pair: ChipHuePair): boolean { - return contrastRatio(pair.text, pair.bg) >= pair.minRatio -} From 377265e231434620d45e04eb9caed9841b724477 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 07:32:58 +0000 Subject: [PATCH 02/10] shared contract fixes: AppError subclasses keep their prototype, client rejects a non-JSON success body, safe ICS URIs, fewer false unsafe-scheme hits, lowercase score cursors, honest fakes, additive isErrorCode and geocode onError --- .../__tests__/client-response-parsing.test.ts | 51 +++++++++++++++++++ packages/shared/__tests__/errors.test.ts | 48 +++++++++++++++++ packages/shared/__tests__/fakes.test.ts | 35 +++++++++++++ packages/shared/__tests__/geocode.test.ts | 40 +++++++++++++++ .../__tests__/host-dashboard-fakes.test.ts | 10 ++++ packages/shared/src/client/client.ts | 12 +++-- .../shared/src/fakes/chat-service.fake.ts | 7 ++- .../shared/src/fakes/host-dashboard.fake.ts | 45 +++++----------- .../shared/src/fakes/inbound-mail.fake.ts | 21 ++++---- packages/shared/src/geocode.ts | 21 ++++++-- .../shared/src/host/__tests__/render.test.ts | 36 +++++++++++++ packages/shared/src/host/render.ts | 4 +- .../shared/src/ics/__tests__/build.test.ts | 26 ++++++++++ packages/shared/src/ics/build.ts | 13 +++-- .../shared/src/schemas/__tests__/feed.test.ts | 8 +++ .../src/schemas/__tests__/posts.test.ts | 9 ++++ packages/shared/src/schemas/posts.ts | 14 +++-- packages/shared/src/types/errors.ts | 33 +++++++----- 18 files changed, 357 insertions(+), 76 deletions(-) diff --git a/packages/shared/__tests__/client-response-parsing.test.ts b/packages/shared/__tests__/client-response-parsing.test.ts index 0d18bf1c..1c215404 100644 --- a/packages/shared/__tests__/client-response-parsing.test.ts +++ b/packages/shared/__tests__/client-response-parsing.test.ts @@ -97,3 +97,54 @@ describe("typed client response parsing", () => { expect(warn).not.toHaveBeenCalled() }) }) + +describe("typed client 2xx body decoding", () => { + function clientWith(res: () => Response) { + const fetchImpl = vi.fn(async () => res()) as unknown as typeof fetch + return createApiClient({ baseURL: "https://api.civfix.test", fetchImpl }) + } + + it("rejects a 2xx body that is not JSON with an INTERNAL AppError carrying the request id", async () => { + const client = clientWith( + () => + new Response("gateway", { + status: 200, + headers: { "content-type": "text/html", "x-request-id": "req-1" }, + }), + ) + await expect(client.listCleanups({})).rejects.toMatchObject({ + name: "AppError", + code: "INTERNAL", + httpStatus: 500, + requestId: "req-1", + }) + }) + + it("rejects an empty 200 body instead of resolving undefined", async () => { + const client = clientWith(() => new Response("", { status: 200 })) + await expect(client.listCleanups({})).rejects.toMatchObject({ code: "INTERNAL" }) + }) + + it("still resolves a 204 to undefined", async () => { + const client = clientWith(() => new Response(null, { status: 204 })) + await expect(client.listCleanups({})).resolves.toBeUndefined() + }) + + it("rethrows the abort, not an AppError, when the caller aborted during the body read", async () => { + const controller = new AbortController() + controller.abort() + const abort = new DOMException("The operation was aborted.", "AbortError") + const client = clientWith( + () => + ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => { + throw abort + }, + }) as unknown as Response, + ) + await expect(client.listCleanups({}, { signal: controller.signal })).rejects.toBe(abort) + }) +}) diff --git a/packages/shared/__tests__/errors.test.ts b/packages/shared/__tests__/errors.test.ts index f601fd11..b499759f 100644 --- a/packages/shared/__tests__/errors.test.ts +++ b/packages/shared/__tests__/errors.test.ts @@ -8,6 +8,8 @@ import { byErrorCode, errorCopyKey, isAppErrorLike, + isErrorCode, + MailSendError, toAppError, } from "../src/types/errors.js" import { AppErrorSchema } from "../src/schemas/common.js" @@ -81,6 +83,27 @@ describe("AppError", () => { expect(err.message).toBe("missing") }) + it("keeps a subclass's own prototype, so instanceof and subclass members survive", () => { + class DeadlineError extends AppError { + constructor() { + super(ErrorCode.INTERNAL, "deadline") + } + isDeadline(): boolean { + return true + } + } + const err = new DeadlineError() + expect(err).toBeInstanceOf(DeadlineError) + expect(err).toBeInstanceOf(AppError) + expect(err.isDeadline()).toBe(true) + + const mail = new MailSendError(ErrorCode.INTERNAL, "smtp down", { responseCode: 421 }) + expect(mail).toBeInstanceOf(MailSendError) + expect(mail).toBeInstanceOf(AppError) + expect(mail.smtp).toEqual({ responseCode: 421 }) + expect(mail.name).toBe("MailSendError") + }) + it("serializes to the wire envelope and validates against AppErrorSchema", () => { const err = new AppError(ErrorCode.VALIDATION, "bad", { fields: { x: "y" }, @@ -179,6 +202,31 @@ describe("toAppError", () => { }) }) +describe("isErrorCode", () => { + it("accepts every ErrorCode value and nothing else", () => { + for (const code of Object.values(ErrorCode)) expect(isErrorCode(code)).toBe(true) + for (const value of ["BOGUS", "not_found", "", undefined, null, 404, {}]) { + expect(isErrorCode(value)).toBe(false) + } + }) +}) + +describe("toAppError fallbackMessage", () => { + it("uses the caller's fallback for a non-Error throw and for an empty message", () => { + const opts = { fallbackMessage: "Network request failed" } + expect(toAppError("boom", opts).message).toBe("Network request failed") + expect(toAppError(new Error(""), opts).message).toBe("Network request failed") + expect(toAppError({ code: ErrorCode.CONFLICT, message: "" }, opts).message).toBe( + "Network request failed", + ) + }) + + it("keeps the value's own message and the default fallback when no option is given", () => { + expect(toAppError(new Error("offline"), { fallbackMessage: "x" }).message).toBe("offline") + expect(toAppError("boom").message).toBe("Unknown error") + }) +}) + describe("isAppErrorLike", () => { it("accepts any object carrying a known ErrorCode and a string message", () => { expect(isAppErrorLike({ code: ErrorCode.CONFLICT, message: "Already exists" })).toBe(true) diff --git a/packages/shared/__tests__/fakes.test.ts b/packages/shared/__tests__/fakes.test.ts index ea655ae6..923762bc 100644 --- a/packages/shared/__tests__/fakes.test.ts +++ b/packages/shared/__tests__/fakes.test.ts @@ -107,6 +107,32 @@ describe("FakeInboundMail", () => { }) }) +describe("FakeInboundMail thread token parity with the real adapter", () => { + it("ignores a spoofable X-Thread-Token header", async () => { + const m = new FakeInboundMail() + const mail = await m.parse( + enc.encode("To: clerk@lacity.gov\nX-Thread-Token: abcdefgh1234\n\nhi"), + ) + expect(m.extractThreadToken(mail)).toBeNull() + }) + + it("finds a reply address in Cc when To is the city clerk", async () => { + const m = new FakeInboundMail() + const mail = await m.parse( + enc.encode( + 'To: clerk@lacity.gov\nCc: "Ops" , report-k7m2x9q4ab3d@civfix.org\n\nhi', + ), + ) + expect(m.extractThreadToken(mail)).toBe("k7m2x9q4ab3d") + }) + + it("lowercases the address before matching, like the real adapter", async () => { + const m = new FakeInboundMail() + const mail = await m.parse(enc.encode("To: Report-K7M2X9Q4AB3D@CivFix.org\n\nhi")) + expect(m.extractThreadToken(mail)).toBe("k7m2x9q4ab3d") + }) +}) + describe("FakeChatService", () => { it("broadcast reaches a joined connection and history paginates", async () => { const chat = new FakeChatService() @@ -132,6 +158,15 @@ describe("FakeChatService", () => { }) }) +describe("FakeChatService clock", () => { + it("stamps createdAt from the injected clock", async () => { + const at = Date.parse("2026-05-16T16:00:00.000Z") + const chat = new FakeChatService(() => at) + const msg = await chat.persist({ cleanupId: "cleanup-1", userId: "user-1", body: "hello" }) + expect(msg.createdAt).toBe("2026-05-16T16:00:00.000Z") + }) +}) + describe("FakeUserChannel", () => { it("delivers a signal frame only to the targeted user's connections and captures the publish", async () => { const ch = new FakeUserChannel() diff --git a/packages/shared/__tests__/geocode.test.ts b/packages/shared/__tests__/geocode.test.ts index 80738983..a78de440 100644 --- a/packages/shared/__tests__/geocode.test.ts +++ b/packages/shared/__tests__/geocode.test.ts @@ -294,6 +294,46 @@ describe("suggestAddresses", () => { }) }) +describe("suggestAddresses onError", () => { + it("reports a Photon outage while still resolving []", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("down", { status: 503 }))) + const onError = vi.fn() + await expect(suggestAddresses("echo park", { onError })).resolves.toEqual([]) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError.mock.calls[0]?.[1]).toBe("photon") + expect(String(onError.mock.calls[0]?.[0])).toContain("503") + }) + + it("reports a Mapbox failure it falls back past, then the Photon result wins", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL) => + String(url).includes("mapbox.com") + ? new Response("err", { status: 500 }) + : photonResponse([{ lat: 34.0, lng: -118.2, props: { name: "Echo Park" } }]), + ), + ) + const onError = vi.fn() + const out = await suggestAddresses("echo park", { mapboxToken: "pk.test", onError }) + expect(out[0]?.source).toBe("photon") + expect(onError.mock.calls.map((call) => call[1])).toEqual(["mapbox"]) + }) + + it("is not called for the caller's own abort", async () => { + const controller = new AbortController() + vi.stubGlobal( + "fetch", + vi.fn(async () => { + controller.abort() + throw Object.assign(new Error("aborted"), { name: "AbortError" }) + }), + ) + const onError = vi.fn() + await expect(suggestAddresses("echo", { signal: controller.signal, onError })).rejects.toThrow() + expect(onError).not.toHaveBeenCalled() + }) +}) + describe("suggestion language/country options", () => { it("forwards a supported language to Photon and falls back to English for the rest", async () => { const urls: string[] = [] diff --git a/packages/shared/__tests__/host-dashboard-fakes.test.ts b/packages/shared/__tests__/host-dashboard-fakes.test.ts index f376d17a..970ff214 100644 --- a/packages/shared/__tests__/host-dashboard-fakes.test.ts +++ b/packages/shared/__tests__/host-dashboard-fakes.test.ts @@ -16,6 +16,7 @@ import { ListMyHostedEventsResponseSchema, } from "../src/schemas/host/portfolio.js" import { eventPhase } from "../src/host/phase.js" +import { hostCapabilities } from "../src/host/capabilities.js" const NOW = Date.parse("2026-09-11T17:00:00.000Z") @@ -190,6 +191,15 @@ describe("fakeHostedEvents", () => { } }) + it("grants each row exactly the capabilities the server derives from its event role", () => { + for (const when of ["upcoming", "past"] as const) { + for (const item of fakeHostedEvents(when, { now: NOW }).items) { + const expected = [...hostCapabilities({ eventRole: item.myRole ?? null, orgRole: null })] + expect(item.myCapabilities, `${when} ${item.myRole}`).toEqual(expected) + } + } + }) + it("stamps the org on every row when one is given", () => { const org = { orgId: "6f1a2c1e-4d9b-4c7a-8a2f-1b7c9d3e5a10", orgName: "Bayview Stewards" } for (const item of fakeHostedEvents("upcoming", { now: NOW, ...org }).items) { diff --git a/packages/shared/src/client/client.ts b/packages/shared/src/client/client.ts index 6fec9168..ebba91ae 100644 --- a/packages/shared/src/client/client.ts +++ b/packages/shared/src/client/client.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { AppErrorSchema } from "../schemas/common.js" -import { AppError, ErrorCode, isAppErrorLike } from "../types/errors.js" +import { AppError, ErrorCode, isErrorCode } from "../types/errors.js" import { endpoints, type EndpointDef, @@ -99,7 +99,7 @@ export async function parseError(res: Response, requestId?: string): Promise(endpoint, data) } diff --git a/packages/shared/src/fakes/chat-service.fake.ts b/packages/shared/src/fakes/chat-service.fake.ts index 01315087..77c676ce 100644 --- a/packages/shared/src/fakes/chat-service.fake.ts +++ b/packages/shared/src/fakes/chat-service.fake.ts @@ -30,6 +30,11 @@ export class FakeChatService implements ChatService { private readonly rooms = new Map>() private readonly messages = new Map() private readonly nextId = makeIdFactory(42) + private readonly now: () => number + + constructor(now: () => number = () => Date.now()) { + this.now = now + } joinRoom(cleanupId: string, conn: ChatConnection, _userId: string): Promise { let room = this.rooms.get(cleanupId) @@ -86,7 +91,7 @@ export class FakeChatService implements ChatService { // The fake has no media pipeline, so a media send over the all-fakes dev path echoes back with no // attachments. attachments: null, - createdAt: new Date().toISOString(), + createdAt: new Date(this.now()).toISOString(), editedAt: null, reactions: [], mentions: [], diff --git a/packages/shared/src/fakes/host-dashboard.fake.ts b/packages/shared/src/fakes/host-dashboard.fake.ts index 92ffe0a4..1cc664ec 100644 --- a/packages/shared/src/fakes/host-dashboard.fake.ts +++ b/packages/shared/src/fakes/host-dashboard.fake.ts @@ -1,4 +1,6 @@ import { avatarGradient } from "../avatar.js" +import { hostCapabilities } from "../host/capabilities.js" +import type { CleanupMemberRole, HostCapability } from "../schemas/common.js" import { ANALYTICS_SUPPRESSION_K } from "../schemas/host/suppression.js" import type { BreakdownRow, @@ -412,6 +414,10 @@ export function fakeHostPortfolioKpis(): HostPortfolioKpis { } } +function eventRoleCapabilities(eventRole: CleanupMemberRole): HostCapability[] { + return [...hostCapabilities({ eventRole, orgRole: null })] +} + export function fakeHostedEvents( when: "upcoming" | "past" = "upcoming", options: FakeHostedEventsOptions, @@ -436,17 +442,7 @@ export function fakeHostedEvents( checkedInCount: 0, waitlistCount: 7, myRole: "organizer", - myCapabilities: [ - "view_event_private", - "view_roster", - "view_analytics", - "check_in", - "manage_event", - "manage_tickets", - "manage_team", - "broadcast", - "export", - ], + myCapabilities: eventRoleCapabilities("organizer"), orgId, orgName, pageSlug: null, @@ -466,7 +462,7 @@ export function fakeHostedEvents( checkedInCount: 0, waitlistCount: 0, myRole: "cohost", - myCapabilities: ["view_event_private", "view_roster", "check_in", "broadcast"], + myCapabilities: eventRoleCapabilities("cohost"), orgId, orgName, pageSlug: null, @@ -486,7 +482,7 @@ export function fakeHostedEvents( checkedInCount: 0, waitlistCount: 0, myRole: "coordinator", - myCapabilities: ["view_event_private", "view_roster", "view_analytics"], + myCapabilities: eventRoleCapabilities("coordinator"), orgId, orgName, pageSlug: null, @@ -509,17 +505,7 @@ export function fakeHostedEvents( waitlistCount: 7, hoursCredited: 114.5, myRole: "organizer", - myCapabilities: [ - "view_event_private", - "view_roster", - "view_analytics", - "check_in", - "manage_event", - "manage_tickets", - "manage_team", - "broadcast", - "export", - ], + myCapabilities: eventRoleCapabilities("organizer"), orgId, orgName, pageSlug: null, @@ -540,14 +526,7 @@ export function fakeHostedEvents( waitlistCount: 3, hoursCredited: 0, myRole: "organizer", - myCapabilities: [ - "view_event_private", - "view_roster", - "view_analytics", - "check_in", - "manage_event", - "broadcast", - ], + myCapabilities: eventRoleCapabilities("organizer"), orgId, orgName, pageSlug: null, @@ -567,7 +546,7 @@ export function fakeHostedEvents( checkedInCount: 0, waitlistCount: 0, myRole: "organizer", - myCapabilities: ["view_event_private", "view_roster", "view_analytics", "manage_event"], + myCapabilities: eventRoleCapabilities("organizer"), orgId, orgName, pageSlug: null, diff --git a/packages/shared/src/fakes/inbound-mail.fake.ts b/packages/shared/src/fakes/inbound-mail.fake.ts index 19cc294c..b7635d79 100644 --- a/packages/shared/src/fakes/inbound-mail.fake.ts +++ b/packages/shared/src/fakes/inbound-mail.fake.ts @@ -19,6 +19,8 @@ const DEFAULT_REPLY_DOMAIN = "civfix.org" */ const REPLY_ADDRESS_RE = /^(?:reply|report|event)[-+]([^@\s]+)@([^@\s]+)$/ +const REPLY_ADDRESS_SCAN_RE = /(?,;"]+@[^@\s<>,;"]+/gi + /** * In-memory InboundMail. Parses a tiny subset of RFC822: leading "Header: value" lines until a * blank line, then the remainder is the text body. Deterministic and dependency-free. @@ -73,19 +75,16 @@ export class FakeInboundMail implements InboundMail { } /** - * Extract a thread token from an X-Thread-Token header or a typed reply address on OUR reply domain. - * The address is anchored + domain-checked and the token shape-validated exactly like the real - * CfInboundMail adapter, so a city's own `report-*@city.gov` alias, a foreign CC, or a junk value - * cannot create stray threads. + * Mirrors the real CfInboundMail adapter: a typed reply address on OUR reply domain, read from To and + * then scanned out of the raw Cc header, lowercased before matching. An X-Thread-Token header is ignored + * because any sender can set it, which would let a stranger pick the thread. Anchoring, the domain check + * and the token shape keep a city's own `report-*@city.gov` alias or a junk value from creating threads. */ extractThreadToken(mail: ParsedMail): string | null { - const headerToken = mail.headers["x-thread-token"] - if (headerToken && THREAD_TOKEN_RE.test(headerToken)) return headerToken - for (const addr of mail.to) { - const m = addr.address.match(REPLY_ADDRESS_RE) - if (m && m[1] && m[2] && m[2].toLowerCase() === this.replyDomain) { - if (THREAD_TOKEN_RE.test(m[1])) return m[1] - } + const ccAddresses = (mail.headers["cc"] ?? "").match(REPLY_ADDRESS_SCAN_RE) ?? [] + for (const address of [...mail.to.map((addr) => addr.address), ...ccAddresses]) { + const match = address.toLowerCase().match(REPLY_ADDRESS_RE) + if (match?.[1] && match[2] === this.replyDomain && THREAD_TOKEN_RE.test(match[1])) return match[1] } return null } diff --git a/packages/shared/src/geocode.ts b/packages/shared/src/geocode.ts index 4b897663..b81985a7 100644 --- a/packages/shared/src/geocode.ts +++ b/packages/shared/src/geocode.ts @@ -69,6 +69,12 @@ export interface SuggestOptions { * results. Ignored by Photon (which has no equivalent filter). */ country?: string | null + /** + * Told about a provider failure that suggestAddresses degrades past (Mapbox falling back to Photon, + * Photon resolving []), so a server caller can log an outage the result alone cannot show. Never + * called for the caller's own abort. + */ + onError?: (error: unknown, provider: "mapbox" | "photon") => void } /** Languages Photon actually serves; anything else is requested as English. */ @@ -104,11 +110,15 @@ export function parseLatLng(input: string): LatLng | null { return { lat, lng } } +// English fallbacks: the wire suggestion has no locale-free field for these, so the UI cannot translate them. +const EXACT_COORDINATES_LABEL = "Exact coordinates" +const UNKNOWN_PLACE_LABEL = "Unknown place" + function coordSuggestion(coord: LatLng): GeoSuggestion { return { id: `coordinate:${coord.lat},${coord.lng}`, label: coordsLabel(coord), - secondary: "Exact coordinates", + secondary: EXACT_COORDINATES_LABEL, lat: coord.lat, lng: coord.lng, source: "coordinate", @@ -136,7 +146,7 @@ interface PhotonFeature { function photonLabel(p: PhotonProperties): string { if (p.name) return p.name const street = [p.housenumber, p.street].filter(Boolean).join(" ") - return street || p.city || p.state || "Unknown place" + return street || p.city || p.state || UNKNOWN_PLACE_LABEL } function photonSecondary(p: PhotonProperties, label: string): string | undefined { const parts = [p.city, p.state, p.country].filter((v): v is string => !!v && v !== label) @@ -211,7 +221,7 @@ interface MapboxV6Feature { } function mapboxLabel(p: MapboxV6Properties): string { - return p.name || p.full_address || p.place_formatted || "Unknown place" + return p.name || p.full_address || p.place_formatted || UNKNOWN_PLACE_LABEL } function mapboxSecondary(p: MapboxV6Properties, label: string): string | undefined { if (p.place_formatted && p.place_formatted !== label) return p.place_formatted @@ -280,7 +290,7 @@ function isAbort(err: unknown, signal?: AbortSignal): boolean { /** * Address autocomplete: a pasted coordinate short-circuits; then Mapbox when `opts.mapboxToken` is set * (falling back to Photon when Mapbox throws OR returns nothing); else Photon. Provider errors degrade - * to [], but an abort rejects. + * to [] and are reported through `opts.onError`, but an abort rejects. */ export async function suggestAddresses(query: string, opts: SuggestOptions = {}): Promise { const q = query.trim() @@ -297,13 +307,14 @@ export async function suggestAddresses(query: string, opts: SuggestOptions = {}) // An abort is the caller cancelling a stale keystroke: reject so the old request cannot resolve [] // over the newer one's suggestions, and skip a Photon request nobody is waiting on. if (isAbort(err, opts.signal)) throw err - // Mapbox unavailable → fall through to Photon. + opts.onError?.(err, "mapbox") } } try { return await photonSuggest(q, opts) } catch (err) { if (isAbort(err, opts.signal)) throw err + opts.onError?.(err, "photon") return [] } } diff --git a/packages/shared/src/host/__tests__/render.test.ts b/packages/shared/src/host/__tests__/render.test.ts index df9d39ed..c78bf774 100644 --- a/packages/shared/src/host/__tests__/render.test.ts +++ b/packages/shared/src/host/__tests__/render.test.ts @@ -109,6 +109,42 @@ describe("assertSafeBroadcastLinks", () => { expect(inspectBroadcastLinks("data:text/html;base64,PHNjcmlwdD4=")[0]?.kind).toBe("insecure_scheme") }) + it("still flags every dangerous scheme wherever it starts a token and runs into a payload", () => { + const schemes = ["javascript", "data", "vbscript", "file", "blob", "jar", "about"] + const openers = ["", " ", "\t", "\n", "(", "[", "{", "<", '"', "'", "`", "=", ",", ";", "!", "*", "|", ">", "](", "](<"] + const payloads = ["alert(1)", "//evil.example/x", "text/html;base64,PHNjcmlwdD4=", "%0aalert(1)", "blank"] + for (const scheme of schemes) { + for (const cased of [scheme, scheme.toUpperCase(), scheme[0]!.toUpperCase() + scheme.slice(1)]) { + for (const opener of openers) { + for (const payload of payloads) { + const text = `Tap ${opener}${cased}:${payload}` + const flagged = inspectBroadcastLinks(text).some( + (i) => i.kind === "insecure_scheme" && i.scheme === scheme, + ) + expect(flagged, text).toBe(true) + } + } + } + } + }) + + it("does not flag a scheme word used as prose or inside an https path", () => { + const prose = [ + "Questions about: parking.", + "Bring data: forms and gloves", + "Waiver file: here", + "Topic: about:", + "https://example.org/data:foo", + "https://example.org/a.javascript:x", + "https://example.org/a-blob:x", + "https://example.org/a:jar:x", + ] + for (const text of prose) { + expect(inspectBroadcastLinks(text), text).toEqual([]) + } + expect(inspectBroadcastLinks("Questions about: parking. Bring data: forms. Waiver file: here")).toEqual([]) + }) + it("rejects scheme-relative links", () => { expect(inspectBroadcastLinks("go to //evil.example/x")[0]?.kind).toBe("scheme_relative") }) diff --git a/packages/shared/src/host/render.ts b/packages/shared/src/host/render.ts index 1766bf5a..162ad610 100644 --- a/packages/shared/src/host/render.ts +++ b/packages/shared/src/host/render.ts @@ -87,7 +87,9 @@ export interface BroadcastLinkOptions { const ABSOLUTE_URL = /\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/[^\s<>"'`)\]}]+/g const SCHEME_RELATIVE = /(^|[\s(<[{])(\/\/[^\s<>"'`)\]}]+)/g -const DANGEROUS_SCHEME = /\b(javascript|data|vbscript|file|blob|jar|about):/gi +// A scheme only becomes a link where it starts a token and runs straight into its payload. Requiring +// that keeps prose ("Questions about: parking") and https URL paths ("/data:foo") from being refused. +const DANGEROUS_SCHEME = /(? { diff --git a/packages/shared/src/ics/__tests__/build.test.ts b/packages/shared/src/ics/__tests__/build.test.ts index 9079e76a..0b896eef 100644 --- a/packages/shared/src/ics/__tests__/build.test.ts +++ b/packages/shared/src/ics/__tests__/build.test.ts @@ -121,6 +121,32 @@ describe("buildIcs", () => { ) }) + it("emits URL and mailto values as URIs, without TEXT backslash escaping", () => { + const rows = unfold( + buildIcs({ + ...BASE, + url: "https://civfix.org/e/creek?a=1,2;b=3", + organizer: { email: "ada,lovelace;x@civfix.org" }, + }), + ) + expect(rows).toContain("URL:https://civfix.org/e/creek?a=1,2;b=3") + expect(rows).toContain("ORGANIZER:mailto:ada,lovelace;x@civfix.org") + }) + + it("drops a URL the safe-https check refuses and an organizer email with control characters", () => { + for (const url of [ + "https://user@civfix.org/x", + "https://192.168.0.1/x", + "https://xn--80ak6aa92e.com/x", + "https://civfix.org/a\u0001b", + `https://civfix.org/${"a".repeat(2048)}`, + ]) { + expect(unfold(buildIcs({ ...BASE, url })).some((row) => row.startsWith("URL:")), url).toBe(false) + } + const rows = unfold(buildIcs({ ...BASE, organizer: { email: "ada\u0001@civfix.org" } })) + expect(rows.some((row) => row.startsWith("ORGANIZER"))).toBe(false) + }) + it("supports cancellations, sequences and open-ended events", () => { const rows = unfold( buildIcs({ uid: "x", title: "T", startsAt: BASE.startsAt, status: "CANCELLED", sequence: 3 }), diff --git a/packages/shared/src/ics/build.ts b/packages/shared/src/ics/build.ts index a6e6101e..33f1067e 100644 --- a/packages/shared/src/ics/build.ts +++ b/packages/shared/src/ics/build.ts @@ -1,9 +1,12 @@ import { isValidTimeZone } from "../datetime.js" import { intOr } from "../internal/numbers.js" +import { isSafeHttpsUrl } from "../markdown/safe-url.js" export const ICS_PRODID = "-//civfix//civfix events//EN" export const ICS_UID_DOMAIN = "civfix.org" const FOLD_OCTETS = 75 +// eslint-disable-next-line no-control-regex -- a mailto value is emitted raw, so control characters must be refused +const ORGANIZER_EMAIL = /^[^\s@\u0000-\u001f\u007f]+@[^\s@\u0000-\u001f\u007f]+$/u export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED" @@ -132,16 +135,18 @@ export function buildIcs(input: IcsEventInput): string { lines.push(`LOCATION:${escapeText(location)}`) } + // URL and ORGANIZER are URI / CAL-ADDRESS values (RFC 5545 3.3.3, 3.3.13), which take no TEXT + // backslash escaping; the validation is what keeps CR, LF and control characters out of the line. const url = input.url?.trim() - if (url !== undefined && /^https:\/\/\S+$/iu.test(url)) { - lines.push(`URL:${escapeText(url)}`) + if (url !== undefined && isSafeHttpsUrl(url)) { + lines.push(`URL:${url}`) } const organizerEmail = input.organizer?.email?.trim() - if (organizerEmail !== undefined && /^[^\s@]+@[^\s@]+$/u.test(organizerEmail)) { + if (organizerEmail !== undefined && ORGANIZER_EMAIL.test(organizerEmail)) { const name = input.organizer?.name?.trim() const cn = name !== undefined && name.length > 0 ? `;CN=${escapeParam(name)}` : "" - lines.push(`ORGANIZER${cn}:mailto:${escapeText(organizerEmail)}`) + lines.push(`ORGANIZER${cn}:mailto:${organizerEmail}`) } const sequence = intOr(input.sequence, 0, 0) diff --git a/packages/shared/src/schemas/__tests__/feed.test.ts b/packages/shared/src/schemas/__tests__/feed.test.ts index 33b37cdb..c03550db 100644 --- a/packages/shared/src/schemas/__tests__/feed.test.ts +++ b/packages/shared/src/schemas/__tests__/feed.test.ts @@ -112,6 +112,14 @@ describe("feed score cursor", () => { expect(parseFeedScoreCursor(encoded)).toEqual({ score: 0, postId: UUID_B }) }) + it("lowercases the post id so it orders like the stored lowercase ids", () => { + const upper = "ABCDEF12-3456-7890-ABCD-EF1234567890" + const parsed = parseFeedScoreCursor(`5.000000|${upper}`) + expect(parsed).toEqual({ score: 5, postId: upper.toLowerCase() }) + const nextRow = { score: 5, postId: "abcdef12-3456-7890-abcd-ef1234567889" } + expect(isAfterFeedScoreCursor(nextRow, parsed!)).toBe(true) + }) + it("does not claim a legacy ISO time cursor", () => { expect(parseFeedScoreCursor(`2026-09-14T10:00:00.000Z|${UUID_A}`)).toBeNull() }) diff --git a/packages/shared/src/schemas/__tests__/posts.test.ts b/packages/shared/src/schemas/__tests__/posts.test.ts index a36622e1..fc70621d 100644 --- a/packages/shared/src/schemas/__tests__/posts.test.ts +++ b/packages/shared/src/schemas/__tests__/posts.test.ts @@ -6,6 +6,7 @@ import { PostRefDTOSchema, } from "../entities.js" import { + POST_BODY_MAX, PostComposeInputSchema, FeedPageDTOSchema, HomeFeedQuerySchema, @@ -80,6 +81,14 @@ describe("PostDTOSchema round-trip", () => { }) describe("PostComposeInputSchema refinements", () => { + it("caps the body at POST_BODY_MAX characters", () => { + expect(POST_BODY_MAX).toBe(2000) + expect(PostComposeInputSchema.safeParse({ body: "a".repeat(POST_BODY_MAX) }).success).toBe(true) + expect(PostComposeInputSchema.safeParse({ body: "a".repeat(POST_BODY_MAX + 1) }).success).toBe( + false, + ) + }) + it("passes a text-only post (kind defaults to 'post')", () => { const res = PostComposeInputSchema.safeParse({ body: "hello neighbors" }) expect(res.success).toBe(true) diff --git a/packages/shared/src/schemas/posts.ts b/packages/shared/src/schemas/posts.ts index b046ea74..d38c7d96 100644 --- a/packages/shared/src/schemas/posts.ts +++ b/packages/shared/src/schemas/posts.ts @@ -8,15 +8,19 @@ import { MAX_MENTIONED_USERS, OkResponseSchema } from "./internal-fields.js" * contained there. */ +export const POST_BODY_MAX = 2000 + /** * Body of POST /posts, which also carries quotes and replies. A pure repost is not this route: it is - * the toggle POST /posts/:id/repost. A quote requires repostOfId, a reply requires replyToId, every - * post needs a body, an attachment or media, and organizationId is refused on a repost. + * the toggle POST /posts/:id/repost. `kind: "repost"` still parses here because narrowing PostKindSchema + * would change the published input type, but the server refuses it with a VALIDATION error; the + * organizationId refine below therefore only fires on a request the server would reject anyway. A quote + * requires repostOfId, a reply requires replyToId, and every post needs a body, an attachment or media. */ export const PostComposeInputSchema = z .object({ kind: PostKindSchema.default("post"), - body: z.string().trim().max(2000).optional(), + body: z.string().trim().max(POST_BODY_MAX).optional(), replyToId: IdSchema.optional(), repostOfId: IdSchema.optional(), eventId: IdSchema.optional(), // the server refuses an event the author neither hosts nor attends @@ -127,9 +131,11 @@ export function parseFeedScoreCursor(cursor: string | null | undefined): FeedSco if (typeof cursor !== "string") return null const match = FEED_SCORE_CURSOR_RE.exec(cursor.trim()) if (!match) return null + // Post ids are stored lowercase and isAfterFeedScoreCursor compares them as strings, so an uppercase + // id from a hand-edited or foreign cursor would order against the wrong rows. const parsed = FeedScoreCursorSchema.safeParse({ score: Number(match[1]), - postId: match[2], + postId: match[2]?.toLowerCase(), }) return parsed.success ? parsed.data : null } diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index 5582450e..cf4a5303 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -55,7 +55,7 @@ export class AppError extends Error { this.httpStatus = opts.httpStatus ?? ERROR_HTTP_STATUS[code] if (opts.fields !== undefined) this.fields = opts.fields if (opts.requestId !== undefined) this.requestId = opts.requestId - Object.setPrototypeOf(this, AppError.prototype) + Object.setPrototypeOf(this, new.target.prototype) } toJSON(): { @@ -152,7 +152,6 @@ export class MailSendError extends AppError { super(code, message, opts) this.name = "MailSendError" this.smtp = smtp - Object.setPrototypeOf(this, MailSendError.prototype) } } @@ -166,14 +165,14 @@ export interface AppErrorLike { const ERROR_CODE_VALUES: ReadonlySet = new Set(Object.values(ErrorCode)) +export function isErrorCode(value: unknown): value is ErrorCode { + return typeof value === "string" && ERROR_CODE_VALUES.has(value) +} + export function isAppErrorLike(value: unknown): value is AppErrorLike { if (typeof value !== "object" || value === null) return false const candidate = value as { code?: unknown; message?: unknown } - return ( - typeof candidate.code === "string" && - ERROR_CODE_VALUES.has(candidate.code) && - typeof candidate.message === "string" - ) + return isErrorCode(candidate.code) && typeof candidate.message === "string" } function stringFields(fields: unknown): Record | undefined { @@ -184,13 +183,21 @@ function stringFields(fields: unknown): Record | undefined { return named.length > 0 ? Object.fromEntries(named) : undefined } -export function toAppError(value: unknown): AppError { +export interface ToAppErrorOptions { + /** Message for a value that carries none of its own (an empty message, or a non-Error throw). */ + fallbackMessage?: string +} + +const UNKNOWN_ERROR_MESSAGE = "Unknown error" + +export function toAppError(value: unknown, opts: ToAppErrorOptions = {}): AppError { if (value instanceof AppError) return value + const fallbackMessage = opts.fallbackMessage ?? UNKNOWN_ERROR_MESSAGE if (isAppErrorLike(value)) { const { httpStatus, requestId } = value const fields = stringFields(value.fields) - return new AppError(value.code, value.message || "Unknown error", { + return new AppError(value.code, value.message || fallbackMessage, { ...(typeof httpStatus === "number" ? { httpStatus } : {}), ...(fields !== undefined ? { fields } : {}), ...(typeof requestId === "string" ? { requestId } : {}), @@ -199,10 +206,10 @@ export function toAppError(value: unknown): AppError { } if (value instanceof Error) { - return new AppError(ErrorCode.INTERNAL, value.message || "Unknown error", { cause: value }) + return new AppError(ErrorCode.INTERNAL, value.message || fallbackMessage, { cause: value }) } - return new AppError(ErrorCode.INTERNAL, "Unknown error") + return new AppError(ErrorCode.INTERNAL, fallbackMessage) } /** @@ -232,8 +239,8 @@ export function byErrorCode( table: ErrorCodeTable, fallback: Value, ): Value { - if (code === undefined || !ERROR_CODE_VALUES.has(code)) return fallback - const value = table[code as ErrorCode] + if (!isErrorCode(code)) return fallback + const value = table[code] return value === undefined ? fallback : value } From 1f203ab5c964f7c0ea6e95dc767f7cfab9903cd9 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 07:41:44 +0000 Subject: [PATCH 03/10] optional contract fields the backend and admin asked for, shared report status buckets, and shared uuid, time unit and url helpers used by the app --- apps/community-mobile/src/lib/authResume.ts | 4 +- .../src/components/dev/bodies-gallery.tsx | 23 +- .../src/components/dev/dashboard-fixtures.ts | 46 ++-- .../src/components/dev/fixtures.ts | 11 +- .../src/components/dev/landscape-fake-api.ts | 4 +- apps/community-web/src/lib/api.ts | 7 +- apps/community-web/src/lib/ws.ts | 5 +- .../__tests__/handoff-additions.test.ts | 237 ++++++++++++++++++ .../shared/__tests__/units-and-ids.test.ts | 59 +++++ packages/shared/src/datetime.ts | 17 +- .../shared/src/fakes/host-dashboard.fake.ts | 46 ++-- packages/shared/src/host/derive.ts | 6 +- packages/shared/src/host/phase.ts | 10 +- packages/shared/src/index.ts | 3 + packages/shared/src/interfaces/jobs.ts | 4 + .../shared/src/schemas/admin/analytics.ts | 17 ++ packages/shared/src/schemas/admin/common.ts | 18 ++ .../shared/src/schemas/admin/discovery.ts | 5 +- packages/shared/src/schemas/admin/events.ts | 3 + packages/shared/src/schemas/admin/home.ts | 3 + packages/shared/src/schemas/admin/mail.ts | 4 + .../shared/src/schemas/admin/moderation.ts | 3 + packages/shared/src/schemas/admin/reports.ts | 4 +- packages/shared/src/schemas/admin/users.ts | 13 +- .../shared/src/schemas/host/registrations.ts | 3 + packages/shared/src/time-units.ts | 9 + packages/shared/src/url.ts | 5 + packages/shared/src/uuid.ts | 7 + packages/ui/src/bodies/calendarModel.ts | 9 +- .../host/__tests__/analyticsModel.test.ts | 4 +- packages/ui/src/bodies/host/analyticsModel.ts | 9 +- .../bodies/host/dashboard/dashboardModel.ts | 9 +- .../certificate/CertificateIssuedPanel.tsx | 5 +- packages/ui/src/bodies/relativeTime.ts | 5 +- .../bodies/reportPicker/reportPickerModel.ts | 4 +- packages/ui/src/bodies/timeUnits.ts | 3 - packages/ui/src/primitives/externalUrls.ts | 3 +- 37 files changed, 502 insertions(+), 125 deletions(-) create mode 100644 packages/shared/__tests__/handoff-additions.test.ts create mode 100644 packages/shared/__tests__/units-and-ids.test.ts create mode 100644 packages/shared/src/time-units.ts create mode 100644 packages/shared/src/url.ts create mode 100644 packages/shared/src/uuid.ts delete mode 100644 packages/ui/src/bodies/timeUnits.ts diff --git a/apps/community-mobile/src/lib/authResume.ts b/apps/community-mobile/src/lib/authResume.ts index c50ab031..4c700f30 100644 --- a/apps/community-mobile/src/lib/authResume.ts +++ b/apps/community-mobile/src/lib/authResume.ts @@ -1,3 +1,5 @@ +import { stripTrailingSlashes } from "@civfix/shared" + const ROOT_ROUTE = "/" function routeQuery(params: unknown): string { @@ -27,7 +29,7 @@ export function resumePathname(href: string): string { const cut = href.search(/[?#]/) const path = cut === -1 ? href : href.slice(0, cut) if (path.length > 1 && path.endsWith("/")) { - const trimmed = path.replace(/\/+$/, "") + const trimmed = stripTrailingSlashes(path) return trimmed.length > 0 ? trimmed : "/" } return path diff --git a/apps/community-web/src/components/dev/bodies-gallery.tsx b/apps/community-web/src/components/dev/bodies-gallery.tsx index b8d91807..f645066a 100644 --- a/apps/community-web/src/components/dev/bodies-gallery.tsx +++ b/apps/community-web/src/components/dev/bodies-gallery.tsx @@ -21,6 +21,7 @@ import type { PostDTO, } from "@civfix/shared" import type { CleanupAttendeesResponse } from "@civfix/shared" +import { MS_PER_DAY, MS_PER_MINUTE } from "@civfix/shared" import type { EventHoursResponse, EventSlotDTO, @@ -79,14 +80,12 @@ import { portfolioOverrides, } from "./dashboard-fixtures" import { - DAY_MS, GALLERY_LEADERBOARD_PODIUM, GALLERY_MY_PROFILE, GALLERY_NOTIFICATIONS, GALLERY_NOTIFICATION_PREFS, GALLERY_SEARCH_RESULTS, GALLERY_VIEWER, - MINUTE_MS, daysAgo, galleryChatHistory, galleryThreads, @@ -108,7 +107,7 @@ const ORGANIZER = { } function event(id: string, title: string, daysAgo: number, organizerId: string): CleanupDTO { - const scheduledAt = isoFromNow(-daysAgo * DAY_MS) + const scheduledAt = isoFromNow(-daysAgo * MS_PER_DAY) return { id, title, @@ -166,7 +165,7 @@ const LINKED_EVENTS = [ id: "e1", title: "Creekside litter sweep", eventKind: "cleanup", - scheduledAt: isoFromNow(2 * DAY_MS), + scheduledAt: isoFromNow(2 * MS_PER_DAY), lat: 37.77, lng: -122.42, going: 8, @@ -264,7 +263,7 @@ const NEARBY_CLEANUPS: { items: CleanupDTO[]; nextCursor: string | null } = { items: [ { ...event("e1", "Creekside litter sweep", -2, "p-ann"), - scheduledAt: isoFromNow(2 * DAY_MS), + scheduledAt: isoFromNow(2 * MS_PER_DAY), status: "upcoming", joined: true, going: 8, @@ -273,7 +272,7 @@ const NEARBY_CLEANUPS: { items: CleanupDTO[]; nextCursor: string | null } = { { ...event("e2", "Mission mural touch-up", -2, "p-lee"), organizer: { ...ORGANIZER, id: "p-lee", name: "Lee Tran", handle: "leetran" }, - scheduledAt: isoFromNow(5 * DAY_MS), + scheduledAt: isoFromNow(5 * MS_PER_DAY), status: "upcoming", joined: false, going: 14, @@ -282,7 +281,7 @@ const NEARBY_CLEANUPS: { items: CleanupDTO[]; nextCursor: string | null } = { { ...event("e5", "Dolores Park planting", -2, "p-mei"), organizer: { ...ORGANIZER, id: "p-mei", name: "Mei Wong", handle: "meiwong" }, - scheduledAt: isoFromNow(6 * DAY_MS), + scheduledAt: isoFromNow(6 * MS_PER_DAY), status: "upcoming", joined: false, going: 5, @@ -290,7 +289,7 @@ const NEARBY_CLEANUPS: { items: CleanupDTO[]; nextCursor: string | null } = { }, { ...event("e6", "Bayview shoreline sweep", -2, "p-ann"), - scheduledAt: isoFromNow(8 * DAY_MS), + scheduledAt: isoFromNow(8 * MS_PER_DAY), status: "upcoming", joined: false, going: 21, @@ -322,7 +321,7 @@ const FEED_POSTS: PostDTO[] = [ author: ORGANIZER, kind: "post", body: "We are meeting by the east gate Saturday morning. Come help us reset the creek path before summer.", - createdAt: isoFromNow(-35 * MINUTE_MS), + createdAt: isoFromNow(-35 * MS_PER_MINUTE), event: { id: "e1", title: "Creekside litter sweep", @@ -333,7 +332,7 @@ const FEED_POSTS: PostDTO[] = [ lng: NEARBY_CLEANUPS.items[0]!.lng, going: 8, organizer: ORGANIZER, - linkedAt: isoFromNow(-35 * MINUTE_MS), + linkedAt: isoFromNow(-35 * MS_PER_MINUTE), }, report: null, repostOf: null, @@ -412,7 +411,7 @@ const CLEANUP_DETAIL: CleanupDTO = { eventKind: "cleanup", description: "Join us for a morning sweep along the creek path. We will tackle the litter that washed up after the rain, then grab coffee. Newcomers welcome - tools provided if you do not have your own.", - scheduledAt: isoFromNow(2 * DAY_MS), + scheduledAt: isoFromNow(2 * MS_PER_DAY), status: "upcoming", joined: false, going: 8, @@ -440,7 +439,7 @@ const EDIT_CLEANUP_DETAIL: CleanupDTO = { ...event("e-mine", "24th St planter day", -3, "me"), eventKind: "cleanup", description: "We are refreshing the sidewalk planters along 24th St. Tools and soil provided.", - scheduledAt: isoFromNow(6 * DAY_MS), + scheduledAt: isoFromNow(6 * MS_PER_DAY), status: "upcoming", joined: false, going: 5, diff --git a/apps/community-web/src/components/dev/dashboard-fixtures.ts b/apps/community-web/src/components/dev/dashboard-fixtures.ts index ed299d92..6d7f64ee 100644 --- a/apps/community-web/src/components/dev/dashboard-fixtures.ts +++ b/apps/community-web/src/components/dev/dashboard-fixtures.ts @@ -4,7 +4,7 @@ import { fakeHostedEvents, fakeHostedEventsAnalytics, } from "@civfix/shared/fakes" -import { MAX_PORTFOLIO_TOP_VOLUNTEERS } from "@civfix/shared" +import { MAX_PORTFOLIO_TOP_VOLUNTEERS, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE } from "@civfix/shared" import type { CleanupDTO, EventCheckinCountersDTO, @@ -26,7 +26,7 @@ import type { PortfolioAnalyticsRange, } from "@civfix/shared" -import { DAY_MS, HOUR_MS, MINUTE_MS, makeCannedApi, type FakeEndpoint } from "./fixtures" +import { makeCannedApi, type FakeEndpoint } from "./fixtures" const FIXTURE_NOW = Date.now() @@ -114,8 +114,8 @@ const DASHBOARD_ORGS: readonly OrganizationDTO[] = [ socialLinks: null, verifiedStatus: "verified", verifiedKind: "nonprofit", - verifiedAt: new Date(FIXTURE_NOW - 240 * DAY_MS).toISOString(), - createdAt: new Date(FIXTURE_NOW - 500 * DAY_MS).toISOString(), + verifiedAt: new Date(FIXTURE_NOW - 240 * MS_PER_DAY).toISOString(), + createdAt: new Date(FIXTURE_NOW - 500 * MS_PER_DAY).toISOString(), memberCount: 24, eventCount: 31, myRole: "owner", @@ -134,7 +134,7 @@ const DASHBOARD_ORGS: readonly OrganizationDTO[] = [ verifiedStatus: "unverified", verifiedKind: null, verifiedAt: null, - createdAt: new Date(FIXTURE_NOW - 180 * DAY_MS).toISOString(), + createdAt: new Date(FIXTURE_NOW - 180 * MS_PER_DAY).toISOString(), memberCount: 9, eventCount: 6, myRole: "admin", @@ -151,15 +151,15 @@ const EVENT_INVITES: ListMyEventInvitesResponse = { event: { id: "ev-invite-1", title: "Creekside trail restoration", - startsAt: new Date(FIXTURE_NOW + 6 * DAY_MS).toISOString(), - endsAt: new Date(FIXTURE_NOW + 6 * DAY_MS + 4 * HOUR_MS).toISOString(), + startsAt: new Date(FIXTURE_NOW + 6 * MS_PER_DAY).toISOString(), + endsAt: new Date(FIXTURE_NOW + 6 * MS_PER_DAY + 4 * MS_PER_HOUR).toISOString(), status: "upcoming", coverThumbUrl: null, address: "Glen Canyon Park, San Francisco", }, invitedBy: COHOST, - createdAt: new Date(FIXTURE_NOW - 2 * DAY_MS).toISOString(), - expiresAt: new Date(FIXTURE_NOW + 12 * DAY_MS).toISOString(), + createdAt: new Date(FIXTURE_NOW - 2 * MS_PER_DAY).toISOString(), + expiresAt: new Date(FIXTURE_NOW + 12 * MS_PER_DAY).toISOString(), }, ], nextCursor: null, @@ -179,20 +179,20 @@ const ORG_INVITES: ListMyOrgInvitesResponse = { }, role: "admin", invitedBy: COHOST, - createdAt: new Date(FIXTURE_NOW - 4 * DAY_MS).toISOString(), - expiresAt: new Date(FIXTURE_NOW + 10 * DAY_MS).toISOString(), + createdAt: new Date(FIXTURE_NOW - 4 * MS_PER_DAY).toISOString(), + expiresAt: new Date(FIXTURE_NOW + 10 * MS_PER_DAY).toISOString(), }, ], } const ORG_MEMBERS: ListOrganizationMembersResponse = { items: [ - { person: HOST, role: "owner", joinedAt: new Date(FIXTURE_NOW - 500 * DAY_MS).toISOString(), canRemove: false }, - { person: COHOST, role: "admin", joinedAt: new Date(FIXTURE_NOW - 300 * DAY_MS).toISOString(), canRemove: true }, + { person: HOST, role: "owner", joinedAt: new Date(FIXTURE_NOW - 500 * MS_PER_DAY).toISOString(), canRemove: false }, + { person: COHOST, role: "admin", joinedAt: new Date(FIXTURE_NOW - 300 * MS_PER_DAY).toISOString(), canRemove: true }, { person: person("p-lee", "Lee Tran", "leetran"), role: "member", - joinedAt: new Date(FIXTURE_NOW - 90 * DAY_MS).toISOString(), + joinedAt: new Date(FIXTURE_NOW - 90 * MS_PER_DAY).toISOString(), canRemove: true, }, ], @@ -209,8 +209,8 @@ const ORG_INVITE_LIST: ListOrganizationInvitesResponse = { role: "member", status: "pending", invitedBy: HOST, - createdAt: new Date(FIXTURE_NOW - 3 * DAY_MS).toISOString(), - expiresAt: new Date(FIXTURE_NOW + 11 * DAY_MS).toISOString(), + createdAt: new Date(FIXTURE_NOW - 3 * MS_PER_DAY).toISOString(), + expiresAt: new Date(FIXTURE_NOW + 11 * MS_PER_DAY).toISOString(), }, ], } @@ -239,8 +239,8 @@ const EXTRA_TOP_VOLUNTEERS: readonly LeaderboardEntryDTO[] = [ const NEEDS_HOURS_EVENT: HostedEventDTO = { id: "ev-needs-hours", title: "Islais Creek weed pull", - startsAt: new Date(FIXTURE_NOW - 2 * DAY_MS).toISOString(), - endsAt: new Date(FIXTURE_NOW - 2 * DAY_MS + 3 * HOUR_MS).toISOString(), + startsAt: new Date(FIXTURE_NOW - 2 * MS_PER_DAY).toISOString(), + endsAt: new Date(FIXTURE_NOW - 2 * MS_PER_DAY + 3 * MS_PER_HOUR).toISOString(), timezone: "America/Los_Angeles", status: "upcoming", visibility: "public", @@ -368,7 +368,7 @@ function phaseCounters(id: string, phase: EventPhase): EventCheckinCountersDTO { capacity: row.capacity, })), arrivals: insights.arrivals.map((bucket) => ({ - at: new Date(startsAt + bucket.offsetMin * MINUTE_MS).toISOString(), + at: new Date(startsAt + bucket.offsetMin * MS_PER_MINUTE).toISOString(), count: bucket.seats, })), asOf: new Date().toISOString(), @@ -407,18 +407,18 @@ function phaseRoster(id: string, phase: EventPhase): ListEventRegistrationsRespo attendeeName: seatIndex === 0 ? who.name : null, status: "active" as const, ticketToken: null, - checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MINUTE_MS).toISOString() : null, + checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MS_PER_MINUTE).toISOString() : null, checkinMethod: checkedIn ? ("scan" as const) : null, noShowAt: null, })), status: i === ROSTER_PEOPLE.length - 1 ? "cancelled" : "registered", source: i === 3 ? "walkup" : "self", - registeredAt: new Date(startsAt - (14 - i) * DAY_MS).toISOString(), + registeredAt: new Date(startsAt - (14 - i) * MS_PER_DAY).toISOString(), cancelledAt: i === ROSTER_PEOPLE.length - 1 - ? new Date(startsAt - 2 * DAY_MS).toISOString() + ? new Date(startsAt - 2 * MS_PER_DAY).toISOString() : null, - checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MINUTE_MS).toISOString() : null, + checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MS_PER_MINUTE).toISOString() : null, checkedInBy: checkedIn ? HOST : null, slot: null, waitlistPosition: null, diff --git a/apps/community-web/src/components/dev/fixtures.ts b/apps/community-web/src/components/dev/fixtures.ts index 7c00a582..d68c9d4b 100644 --- a/apps/community-web/src/components/dev/fixtures.ts +++ b/apps/community-web/src/components/dev/fixtures.ts @@ -11,14 +11,11 @@ import type { UserDTO, UserProfileDTO, } from "@civfix/shared" - -export const MINUTE_MS = 60_000 -export const HOUR_MS = 60 * MINUTE_MS -export const DAY_MS = 24 * HOUR_MS +import { MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE } from "@civfix/shared" export const isoFromNow = (ms: number): string => new Date(Date.now() + ms).toISOString() -export const hoursAgo = (h: number): string => isoFromNow(-h * HOUR_MS) -export const daysAgo = (d: number): string => isoFromNow(-d * DAY_MS) +export const hoursAgo = (h: number): string => isoFromNow(-h * MS_PER_HOUR) +export const daysAgo = (d: number): string => isoFromNow(-d * MS_PER_DAY) export const GALLERY_VIEWER = { id: "me", @@ -139,7 +136,7 @@ function chatMessage( }, body, kind: "text", - createdAt: isoFromNow(-minsAgo * MINUTE_MS), + createdAt: isoFromNow(-minsAgo * MS_PER_MINUTE), } as ChatMessageDTO } diff --git a/apps/community-web/src/components/dev/landscape-fake-api.ts b/apps/community-web/src/components/dev/landscape-fake-api.ts index bc2b82e3..f8240c67 100644 --- a/apps/community-web/src/components/dev/landscape-fake-api.ts +++ b/apps/community-web/src/components/dev/landscape-fake-api.ts @@ -15,10 +15,10 @@ import type { ReportDTO, UserProfileDTO, } from "@civfix/shared" +import { MS_PER_DAY } from "@civfix/shared" import { makeFakeApiClient } from "@civfix/ui/data" import { - DAY_MS, GALLERY_LEADERBOARD_PODIUM, GALLERY_MY_PROFILE, GALLERY_NOTIFICATIONS, @@ -77,7 +77,7 @@ function cleanup( description: "Gloves, bags and grabbers provided. Meet at the gate; we finish by noon.", lat: 37.77, lng: -122.42, - scheduledAt: isoFromNow(inDays * DAY_MS), + scheduledAt: isoFromNow(inDays * MS_PER_DAY), status: "upcoming", organizer, going: 12, diff --git a/apps/community-web/src/lib/api.ts b/apps/community-web/src/lib/api.ts index 8d4687bc..840ddeb6 100644 --- a/apps/community-web/src/lib/api.ts +++ b/apps/community-web/src/lib/api.ts @@ -1,12 +1,17 @@ "use client" +import { stripTrailingSlashes } from "@civfix/shared" import { createApiClient, type ApiClient } from "@civfix/shared/client" import { getCsrfToken, useAuthStore, waitForSessionSettled } from "@/store/auth-store" +const LOCAL_API_URL = "http://localhost:8080" + /** NEXT_PUBLIC_ vars are inlined into the static export, so changing this needs a rebuild. */ +const CONFIGURED_API_URL = process.env.NEXT_PUBLIC_API_URL + export const API_BASE_URL: string = - process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ?? "http://localhost:8080" + CONFIGURED_API_URL === undefined ? LOCAL_API_URL : stripTrailingSlashes(CONFIGURED_API_URL) /** * The Next prerender of the shell has no window, so this falls back to globalThis.fetch and module diff --git a/apps/community-web/src/lib/ws.ts b/apps/community-web/src/lib/ws.ts index 0544fc09..8e566f39 100644 --- a/apps/community-web/src/lib/ws.ts +++ b/apps/community-web/src/lib/ws.ts @@ -1,5 +1,6 @@ "use client" +import { stripTrailingSlashes } from "@civfix/shared" import { ChatSocketCore, type WsConnection } from "@civfix/ui/realtime" import { API_BASE_URL } from "@/lib/api" @@ -16,7 +17,7 @@ import { API_BASE_URL } from "@/lib/api" export function wsUrlFromApiBase(apiBase: string = API_BASE_URL): string { if (typeof window === "undefined") return "" - const trimmed = apiBase.replace(/\/+$/, "") + const trimmed = stripTrailingSlashes(apiBase) let url: URL try { url = new URL(trimmed, window.location.origin) @@ -24,7 +25,7 @@ export function wsUrlFromApiBase(apiBase: string = API_BASE_URL): string { return "" } url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - url.pathname = `${url.pathname.replace(/\/+$/, "")}/ws` + url.pathname = `${stripTrailingSlashes(url.pathname)}/ws` url.search = "" url.hash = "" return url.toString() diff --git a/packages/shared/__tests__/handoff-additions.test.ts b/packages/shared/__tests__/handoff-additions.test.ts new file mode 100644 index 00000000..2d70575a --- /dev/null +++ b/packages/shared/__tests__/handoff-additions.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest" +import { + ADMIN_REPORT_STATUS_BUCKETS, + AdminOkResponseSchema, + AdminReportStatusBucketSchema, + AdminReportStatusSchema, + ModerationItemStatusSchema, +} from "../src/schemas/admin/common.js" +import { MailAttachmentSchema } from "../src/schemas/admin/mail.js" +import { + AdminUserListQuerySchema, + FlagUserRequestSchema, + UserMessageItemDTOSchema, + UserSubListQuerySchema, +} from "../src/schemas/admin/users.js" +import { FlagEventRequestSchema } from "../src/schemas/admin/events.js" +import { FlagReportRequestSchema, SetReportVerdictResponseSchema } from "../src/schemas/admin/reports.js" +import { ModerationItemDTOSchema } from "../src/schemas/admin/moderation.js" +import { DiscoveryContactSchema } from "../src/schemas/admin/discovery.js" +import { AnalyticsKpiKey, AnalyticsKpiSchema } from "../src/schemas/admin/analytics.js" +import { HomeSummaryResponseSchema } from "../src/schemas/admin/home.js" +import { CreateWalkupRegistrationRequestSchema } from "../src/schemas/host/registrations.js" + +const UUID = "11111111-2222-4333-8444-555555555555" + +describe("ADMIN_REPORT_STATUS_BUCKETS", () => { + it("holds the backend's status buckets exactly", () => { + expect(ADMIN_REPORT_STATUS_BUCKETS).toEqual({ + submitted: ["submitted", "held", "published"], + in_progress: ["acknowledged", "in_progress"], + completed: ["resolved"], + }) + }) + + it("keys every bucket the schema names and puts every live status in exactly one bucket", () => { + expect(Object.keys(ADMIN_REPORT_STATUS_BUCKETS).sort()).toEqual( + [...AdminReportStatusBucketSchema.options].sort(), + ) + const bucketed = Object.values(ADMIN_REPORT_STATUS_BUCKETS).flat() + expect(new Set(bucketed).size).toBe(bucketed.length) + const unbucketed = AdminReportStatusSchema.options.filter((s) => !bucketed.includes(s)) + expect(unbucketed).toEqual(["rejected"]) + }) +}) + +describe("MailAttachmentSchema url", () => { + const attachment = { key: "https://r2.example/a.pdf?sig=1", filename: "a.pdf", size: 10 } + + it("still parses a payload without url", () => { + expect(MailAttachmentSchema.safeParse(attachment).success).toBe(true) + }) + + it("parses a url and rejects a non-url", () => { + expect(MailAttachmentSchema.safeParse({ ...attachment, url: attachment.key }).success).toBe(true) + expect(MailAttachmentSchema.safeParse({ ...attachment, url: "inbound/a.pdf" }).success).toBe(false) + }) +}) + +describe("AdminUserListQuerySchema excludeOrgId", () => { + it("stays optional and non-strict", () => { + expect(AdminUserListQuerySchema.safeParse({}).success).toBe(true) + expect(AdminUserListQuerySchema.safeParse({ echoed: "x" }).success).toBe(true) + }) + + it("accepts an org id and rejects a non-uuid", () => { + expect(AdminUserListQuerySchema.parse({ excludeOrgId: UUID }).excludeOrgId).toBe(UUID) + expect(AdminUserListQuerySchema.safeParse({ excludeOrgId: "org-1" }).success).toBe(false) + }) +}) + +describe("UserSubListQuerySchema cursor", () => { + it("parses the same inputs as before", () => { + expect(UserSubListQuerySchema.safeParse({ id: "u" }).success).toBe(true) + expect(UserSubListQuerySchema.parse({ id: "u", cursor: "abc" }).cursor).toBe("abc") + expect(UserSubListQuerySchema.safeParse({ id: "u", cursor: 5 }).success).toBe(false) + expect(UserSubListQuerySchema.safeParse({ id: "u", echoed: 1 }).success).toBe(true) + }) +}) + +describe("UserMessageItemDTOSchema removedBy", () => { + const message = { id: "m", text: "hi", thread: "t", when: "now", sourceId: null } + + it("still parses a payload without removedBy", () => { + expect(UserMessageItemDTOSchema.safeParse(message).success).toBe(true) + }) + + it("parses author, operator and null, and rejects anything else", () => { + for (const removedBy of ["author", "operator", null]) { + expect(UserMessageItemDTOSchema.safeParse({ ...message, removedBy }).success).toBe(true) + } + expect(UserMessageItemDTOSchema.safeParse({ ...message, removedBy: "system" }).success).toBe(false) + }) +}) + +describe("flag requests flagged", () => { + const schemas = [FlagEventRequestSchema, FlagReportRequestSchema, FlagUserRequestSchema] + + it("still parses a toggle request", () => { + for (const schema of schemas) expect(schema.safeParse({ id: "x" }).success).toBe(true) + }) + + it("parses an explicit value and rejects a non-boolean", () => { + for (const schema of schemas) { + expect(schema.parse({ id: "x", flagged: false }).flagged).toBe(false) + expect(schema.safeParse({ id: "x", flagged: "yes" }).success).toBe(false) + } + }) +}) + +describe("SetReportVerdictResponseSchema", () => { + it("is the admin ok schema, still strict", () => { + expect(SetReportVerdictResponseSchema).toBe(AdminOkResponseSchema) + expect(SetReportVerdictResponseSchema.safeParse({ ok: true }).success).toBe(true) + expect(SetReportVerdictResponseSchema.safeParse({ ok: true, extra: 1 }).success).toBe(false) + expect(SetReportVerdictResponseSchema.safeParse({ ok: false }).success).toBe(false) + }) +}) + +describe("ModerationItemDTOSchema status", () => { + const item = { + id: "MOD-1", + flag: "NSFW image", + reporter: "Anonymous session", + category: "graffiti", + reason: "Auto-held", + age: "12m", + priority: "high", + kind: "image", + subjectId: "REP-42", + reporterId: null, + desc: "Held photo", + autoAction: null, + place: null, + signals: [], + user: { + id: null, + handle: "anon", + name: "Anon", + joined: "today", + priorReports: 0, + priorRemovals: 0, + strikes: 0, + device: "iOS", + }, + similar: [], + media: [], + } + + it("still parses a payload without status", () => { + expect(ModerationItemDTOSchema.safeParse(item).success).toBe(true) + }) + + it("parses every stored status and rejects an unknown one", () => { + for (const status of ModerationItemStatusSchema.options) { + expect(ModerationItemDTOSchema.safeParse({ ...item, status }).success).toBe(true) + } + expect(ModerationItemDTOSchema.safeParse({ ...item, status: "resolved" }).success).toBe(false) + }) +}) + +describe("DiscoveryContactSchema bouncedAt", () => { + const contact = { category: "graffiti", email: "city@example.gov" } + + it("still parses a payload without bouncedAt", () => { + expect(DiscoveryContactSchema.safeParse(contact).success).toBe(true) + }) + + it("parses a timestamp or null", () => { + const at = "2026-09-01T10:00:00.000Z" + expect(DiscoveryContactSchema.parse({ ...contact, bouncedAt: at }).bouncedAt).toBe(at) + expect(DiscoveryContactSchema.parse({ ...contact, bouncedAt: null }).bouncedAt).toBeNull() + }) +}) + +describe("AnalyticsKpiSchema key and unit", () => { + const kpi = { label: "Resolved", num: 88.5, delta: "+1pt", dir: "up" } + + it("still parses a payload without key or unit", () => { + expect(AnalyticsKpiSchema.safeParse(kpi).success).toBe(true) + }) + + it("parses a known key, an unknown future key and each unit", () => { + expect(AnalyticsKpiSchema.safeParse({ ...kpi, key: AnalyticsKpiKey.resolved, unit: "percent" }).success).toBe(true) + expect(AnalyticsKpiSchema.safeParse({ ...kpi, key: "a_later_kpi" }).success).toBe(true) + for (const unit of ["count", "percent", "hours"]) { + expect(AnalyticsKpiSchema.safeParse({ ...kpi, unit }).success).toBe(true) + } + expect(AnalyticsKpiSchema.safeParse({ ...kpi, unit: "days" }).success).toBe(false) + expect(AnalyticsKpiSchema.safeParse({ ...kpi, key: "" }).success).toBe(false) + }) +}) + +describe("HomeSummaryResponseSchema degraded", () => { + const summary = { + discovery: { queue: 1, reportsWaiting: 2, overSla: 0 }, + reports: { flagged: 0, inProgress: 1, completed: 2 }, + events: { upcoming: 1, live: 0, attending: 3 }, + mail: { unread: 0, needsAction: 0 }, + users: { flagged: 0, highRisk: 0, suspended: 0 }, + analytics: { + pinsThisMonth: 1, + resolvedPct: 50, + coveragePct: 10, + cleanups: 0, + eventsThisMonth: 0, + newUsers: 1, + pinsByWeek: [1], + }, + livePins24h: 0, + } + + it("still parses a payload without degraded", () => { + expect(HomeSummaryResponseSchema.safeParse(summary).success).toBe(true) + }) + + it("parses a list of degraded sections", () => { + expect(HomeSummaryResponseSchema.parse({ ...summary, degraded: ["mail"] }).degraded).toEqual(["mail"]) + expect(HomeSummaryResponseSchema.safeParse({ ...summary, degraded: [1] }).success).toBe(false) + }) +}) + +describe("CreateWalkupRegistrationRequestSchema idempotencyKey", () => { + const walkup = { id: UUID, name: "Rosa" } + + it("still parses a request without idempotencyKey", () => { + expect(CreateWalkupRegistrationRequestSchema.safeParse(walkup).success).toBe(true) + }) + + it("applies the shared idempotency key bounds", () => { + const parse = (idempotencyKey: string) => + CreateWalkupRegistrationRequestSchema.safeParse({ ...walkup, idempotencyKey }).success + expect(parse("a".repeat(8))).toBe(true) + expect(parse("a".repeat(128))).toBe(true) + expect(parse("a".repeat(7))).toBe(false) + expect(parse("a".repeat(129))).toBe(false) + }) +}) diff --git a/packages/shared/__tests__/units-and-ids.test.ts b/packages/shared/__tests__/units-and-ids.test.ts new file mode 100644 index 00000000..d96b5a20 --- /dev/null +++ b/packages/shared/__tests__/units-and-ids.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest" +import { IdSchema } from "../src/schemas/common.js" +import * as units from "../src/time-units.js" +import { stripTrailingSlashes } from "../src/url.js" +import { isUuid } from "../src/uuid.js" + +describe("isUuid", () => { + const cases = [ + "11111111-2222-4333-8444-555555555555", + "ABCDEF01-2345-6789-ABCD-EF0123456789", + "abcdef01-2345-6789-abcd-ef0123456789", + "00000000-0000-0000-0000-000000000000", + "", + "not-a-uuid", + "11111111-2222-4333-8444-55555555555", + "11111111-2222-4333-8444-5555555555555", + "11111111222243338444555555555555", + " 11111111-2222-4333-8444-555555555555", + "g1111111-2222-4333-8444-555555555555", + ] + + it("accepts upper and lower case and rejects malformed values", () => { + expect(isUuid("11111111-2222-4333-8444-555555555555")).toBe(true) + expect(isUuid("ABCDEF01-2345-6789-ABCD-EF0123456789")).toBe(true) + expect(isUuid("11111111222243338444555555555555")).toBe(false) + expect(isUuid(" 11111111-2222-4333-8444-555555555555")).toBe(false) + }) + + it("agrees with IdSchema on every case", () => { + for (const value of cases) expect(isUuid(value)).toBe(IdSchema.safeParse(value).success) + }) +}) + +describe("time units", () => { + it("holds the backend's values", () => { + expect({ ...units }).toEqual({ + MS_PER_SECOND: 1000, + SECONDS_PER_MINUTE: 60, + MINUTES_PER_HOUR: 60, + SECONDS_PER_HOUR: 3600, + SECONDS_PER_DAY: 86_400, + MS_PER_MINUTE: 60_000, + MS_PER_HOUR: 3_600_000, + MS_PER_DAY: 86_400_000, + MS_PER_WEEK: 604_800_000, + }) + }) +}) + +describe("stripTrailingSlashes", () => { + it("removes every trailing slash and nothing else", () => { + expect(stripTrailingSlashes("https://api.example.org")).toBe("https://api.example.org") + expect(stripTrailingSlashes("https://api.example.org/")).toBe("https://api.example.org") + expect(stripTrailingSlashes("https://api.example.org/v1///")).toBe("https://api.example.org/v1") + expect(stripTrailingSlashes("/a//b/")).toBe("/a//b") + expect(stripTrailingSlashes("///")).toBe("") + expect(stripTrailingSlashes("")).toBe("") + }) +}) diff --git a/packages/shared/src/datetime.ts b/packages/shared/src/datetime.ts index 49e6ca6e..61bf3a8a 100644 --- a/packages/shared/src/datetime.ts +++ b/packages/shared/src/datetime.ts @@ -1,4 +1,5 @@ import { FORMAT_FALLBACK_LOCALE } from "./internal/format-locale.js" +import { MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_WEEK } from "./time-units.js" /** * The compact "ago" label, shared so the server and both clients produce identical text. @@ -49,12 +50,6 @@ export interface RelativeAgoOptions { absoluteFallback?: (d: Date) => string } -const SECOND = 1000 -const MINUTE = 60 * SECOND -const HOUR = 60 * MINUTE -const DAY = 24 * HOUR -const WEEK = 7 * DAY - function toEpochMs(value: Date | string | number): number | null { if (value instanceof Date) { const t = value.getTime() @@ -89,13 +84,13 @@ export function relativeAgo( const diff = nowMs - fromMs // Future timestamps and anything under a minute collapse to the just-now label. - if (diff < MINUTE) return justNow - if (diff < HOUR) return `${Math.floor(diff / MINUTE)}${minute}` - if (diff < DAY) return `${Math.floor(diff / HOUR)}${hour}` - if (diff < WEEK) return `${Math.floor(diff / DAY)}${day}` + if (diff < MS_PER_MINUTE) return justNow + if (diff < MS_PER_HOUR) return `${Math.floor(diff / MS_PER_MINUTE)}${minute}` + if (diff < MS_PER_DAY) return `${Math.floor(diff / MS_PER_HOUR)}${hour}` + if (diff < MS_PER_WEEK) return `${Math.floor(diff / MS_PER_DAY)}${day}` if (opts?.absoluteFallback) return opts.absoluteFallback(new Date(fromMs)) - return `${Math.floor(diff / WEEK)}${week}` + return `${Math.floor(diff / MS_PER_WEEK)}${week}` } export interface WallClock { diff --git a/packages/shared/src/fakes/host-dashboard.fake.ts b/packages/shared/src/fakes/host-dashboard.fake.ts index 1cc664ec..904de315 100644 --- a/packages/shared/src/fakes/host-dashboard.fake.ts +++ b/packages/shared/src/fakes/host-dashboard.fake.ts @@ -25,18 +25,16 @@ import type { } from "../schemas/host/portfolio.js" import type { LeaderboardEntryDTO } from "../schemas/entities.js" import { makeIdFactory } from "./ids.js" +import { MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE } from "../time-units.js" -const MINUTE_MS = 60_000 -const HOUR_MS = 3_600_000 -const DAY_MS = 86_400_000 const FAKE_CAPACITY = 60 const FAKE_REGISTERED = 42 const FAKE_WAITLISTED = 7 const FAKE_CANCELLED_SEATS = 5 const FAKE_TREND_DAYS = 14 -const FAKE_EVENT_DURATION_MS = 4 * HOUR_MS +const FAKE_EVENT_DURATION_MS = 4 * MS_PER_HOUR const FAKE_TIMEZONE = "America/Los_Angeles" export interface FakeEventInsightsOptions { @@ -144,7 +142,7 @@ function phaseProfile(phase: EventPhase, now: number): PhaseProfile { if (phase === "live") { return { status: "active", - startsAt: now - 45 * MINUTE_MS, + startsAt: now - 45 * MS_PER_MINUTE, completedAt: null, checkedIn: 27, noShow: 0, @@ -153,7 +151,7 @@ function phaseProfile(phase: EventPhase, now: number): PhaseProfile { } } if (phase === "ended") { - const startsAt = now - 9 * DAY_MS + const startsAt = now - 9 * MS_PER_DAY return { status: "done", startsAt, @@ -167,7 +165,7 @@ function phaseProfile(phase: EventPhase, now: number): PhaseProfile { if (phase === "cancelled") { return { status: "cancelled", - startsAt: now + 5 * DAY_MS, + startsAt: now + 5 * MS_PER_DAY, completedAt: null, checkedIn: 0, noShow: 0, @@ -177,7 +175,7 @@ function phaseProfile(phase: EventPhase, now: number): PhaseProfile { } return { status: "upcoming", - startsAt: now + 3 * DAY_MS, + startsAt: now + 3 * MS_PER_DAY, completedAt: null, checkedIn: 0, noShow: 0, @@ -227,9 +225,9 @@ function fakeBroadcasts( phase: EventPhase, startsAt: number, ): InsightsBroadcast[] { - const confirmationAt = startsAt - 12 * DAY_MS - const reminderAt = startsAt - 2 * DAY_MS - const recapAt = startsAt + FAKE_EVENT_DURATION_MS + HOUR_MS + const confirmationAt = startsAt - 12 * MS_PER_DAY + const reminderAt = startsAt - 2 * MS_PER_DAY + const recapAt = startsAt + FAKE_EVENT_DURATION_MS + MS_PER_HOUR const pending = phase === "upcoming" || phase === "cancelled" return [ { @@ -278,7 +276,7 @@ function fakeTrend( ): SeatPoint[] { const points = cumulative(registered, FAKE_TREND_DAYS, rng) return points.map((seats, i) => ({ - day: dayKey(lastDayMs - (FAKE_TREND_DAYS - 1 - i) * DAY_MS), + day: dayKey(lastDayMs - (FAKE_TREND_DAYS - 1 - i) * MS_PER_DAY), seats, })) } @@ -304,7 +302,7 @@ export function fakeEventInsights( startsAt: new Date(profile.startsAt).toISOString(), endsAt: new Date(endsAt).toISOString(), completedAt: profile.completedAt === null ? null : new Date(profile.completedAt).toISOString(), - registrationClosesAt: new Date(profile.startsAt - 12 * HOUR_MS).toISOString(), + registrationClosesAt: new Date(profile.startsAt - 12 * MS_PER_HOUR).toISOString(), timezone: options.timezone ?? FAKE_TIMEZONE, }, seats: { @@ -359,7 +357,7 @@ export function fakeHostedEventsAnalytics( const series: SeriesPoint[] = [] for (let i = points - 1; i >= 0; i -= 1) { series.push({ - day: dayKey(now - i * DAY_MS), + day: dayKey(now - i * MS_PER_DAY), value: Math.round(4 + rng() * 22), suppressed: false, }) @@ -431,8 +429,8 @@ export function fakeHostedEvents( { id: nextId(), title: PORTFOLIO_EVENT_TITLES[0], - startsAt: new Date(now + 3 * DAY_MS).toISOString(), - endsAt: new Date(now + 3 * DAY_MS + FAKE_EVENT_DURATION_MS).toISOString(), + startsAt: new Date(now + 3 * MS_PER_DAY).toISOString(), + endsAt: new Date(now + 3 * MS_PER_DAY + FAKE_EVENT_DURATION_MS).toISOString(), timezone: FAKE_TIMEZONE, status: "upcoming", visibility: "public", @@ -451,8 +449,8 @@ export function fakeHostedEvents( { id: nextId(), title: PORTFOLIO_EVENT_TITLES[3], - startsAt: new Date(now + 11 * DAY_MS).toISOString(), - endsAt: new Date(now + 11 * DAY_MS + FAKE_EVENT_DURATION_MS).toISOString(), + startsAt: new Date(now + 11 * MS_PER_DAY).toISOString(), + endsAt: new Date(now + 11 * MS_PER_DAY + FAKE_EVENT_DURATION_MS).toISOString(), timezone: FAKE_TIMEZONE, status: "upcoming", visibility: "unlisted", @@ -471,7 +469,7 @@ export function fakeHostedEvents( { id: nextId(), title: PORTFOLIO_EVENT_TITLES[5], - startsAt: new Date(now + 24 * DAY_MS).toISOString(), + startsAt: new Date(now + 24 * MS_PER_DAY).toISOString(), endsAt: null, timezone: FAKE_TIMEZONE, status: "upcoming", @@ -493,8 +491,8 @@ export function fakeHostedEvents( { id: nextId(), title: PORTFOLIO_EVENT_TITLES[1], - startsAt: new Date(now - 9 * DAY_MS).toISOString(), - endsAt: new Date(now - 9 * DAY_MS + FAKE_EVENT_DURATION_MS).toISOString(), + startsAt: new Date(now - 9 * MS_PER_DAY).toISOString(), + endsAt: new Date(now - 9 * MS_PER_DAY + FAKE_EVENT_DURATION_MS).toISOString(), timezone: FAKE_TIMEZONE, status: "done", visibility: "public", @@ -514,8 +512,8 @@ export function fakeHostedEvents( { id: nextId(), title: PORTFOLIO_EVENT_TITLES[2], - startsAt: new Date(now - 26 * DAY_MS).toISOString(), - endsAt: new Date(now - 26 * DAY_MS + FAKE_EVENT_DURATION_MS).toISOString(), + startsAt: new Date(now - 26 * MS_PER_DAY).toISOString(), + endsAt: new Date(now - 26 * MS_PER_DAY + FAKE_EVENT_DURATION_MS).toISOString(), timezone: FAKE_TIMEZONE, status: "done", visibility: "public", @@ -535,7 +533,7 @@ export function fakeHostedEvents( { id: nextId(), title: PORTFOLIO_EVENT_TITLES[4], - startsAt: new Date(now - 41 * DAY_MS).toISOString(), + startsAt: new Date(now - 41 * MS_PER_DAY).toISOString(), endsAt: null, timezone: FAKE_TIMEZONE, status: "cancelled", diff --git a/packages/shared/src/host/derive.ts b/packages/shared/src/host/derive.ts index 3be854b7..bb5c3759 100644 --- a/packages/shared/src/host/derive.ts +++ b/packages/shared/src/host/derive.ts @@ -1,4 +1,5 @@ import { intOr } from "../internal/numbers.js" +import { MS_PER_DAY } from "../time-units.js" import { K_SUPPRESS, normalizeK, roundRate, safeCount } from "./counts.js" export const MAX_SERIES_DAYS = 400 @@ -8,7 +9,6 @@ const DEFAULT_ARRIVAL_BUCKET_MINUTES = 15 const DEFAULT_ARRIVAL_FROM_MINUTES = -120 const DEFAULT_ARRIVAL_TO_MINUTES = 240 -const DAY_MS = 86400000 const DAY_RE = /^\d{4}-\d{2}-\d{2}$/ export interface DayRange { @@ -149,12 +149,12 @@ export function enumerateDays(range: DayRange): string[] { throw new RangeError("enumerateDays expects YYYY-MM-DD calendar days") } if (to < from) throw new RangeError("enumerateDays expects range.from <= range.to") - const span = Math.round((to - from) / DAY_MS) + 1 + const span = Math.round((to - from) / MS_PER_DAY) + 1 if (span > MAX_SERIES_DAYS) { throw new RangeError(`enumerateDays refuses an unbounded range (${span} days > ${MAX_SERIES_DAYS})`) } const days: string[] = [] - for (let i = 0; i < span; i++) days.push(utcMsToDay(from + i * DAY_MS)) + for (let i = 0; i < span; i++) days.push(utcMsToDay(from + i * MS_PER_DAY)) return days } diff --git a/packages/shared/src/host/phase.ts b/packages/shared/src/host/phase.ts index b51992e5..ac8daa55 100644 --- a/packages/shared/src/host/phase.ts +++ b/packages/shared/src/host/phase.ts @@ -1,13 +1,11 @@ import type { CleanupStatus } from "../schemas/entities.js" import type { EventPhase } from "../schemas/host/insights.js" import { DEFAULT_EVENT_DURATION_MINUTES } from "../schemas/event-duration.js" +import { MS_PER_HOUR, MS_PER_MINUTE } from "../time-units.js" -const MINUTE_MS = 60_000 -const HOUR_MS = 60 * MINUTE_MS - -export const LIVE_LEAD_MS = 2 * HOUR_MS -export const LIVE_TAIL_MS = 2 * HOUR_MS -export const DEFAULT_EVENT_DURATION_MS = DEFAULT_EVENT_DURATION_MINUTES * MINUTE_MS +export const LIVE_LEAD_MS = 2 * MS_PER_HOUR +export const LIVE_TAIL_MS = 2 * MS_PER_HOUR +export const DEFAULT_EVENT_DURATION_MS = DEFAULT_EVENT_DURATION_MINUTES * MS_PER_MINUTE export const DEFAULT_DURATION_MS = DEFAULT_EVENT_DURATION_MS export interface EventWindowLike { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4123084d..b902d2ca 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -35,6 +35,9 @@ export * from "./address.js" export * from "./avatar.js" export * from "./datetime.js" export * from "./number-format.js" +export * from "./time-units.js" +export * from "./url.js" +export * from "./uuid.js" export * from "./chat/index.js" export * from "./ws/index.js" diff --git a/packages/shared/src/interfaces/jobs.ts b/packages/shared/src/interfaces/jobs.ts index 187cea8e..ef329cc1 100644 --- a/packages/shared/src/interfaces/jobs.ts +++ b/packages/shared/src/interfaces/jobs.ts @@ -12,6 +12,10 @@ export interface EnqueueOptions { export interface JobHandlerArg { id: string data: any + /** Retries already spent. Absent when the implementation does not track attempts. */ + retryCount?: number + /** Set with `retryCount`; `retryCount >= retryLimit` marks the final attempt. */ + retryLimit?: number } export type JobHandler = (job: JobHandlerArg) => Promise diff --git a/packages/shared/src/schemas/admin/analytics.ts b/packages/shared/src/schemas/admin/analytics.ts index ae0f0ff6..d24464e9 100644 --- a/packages/shared/src/schemas/admin/analytics.ts +++ b/packages/shared/src/schemas/admin/analytics.ts @@ -7,6 +7,20 @@ import { ReportCategorySchema } from "../common.js" * category. */ +export const AnalyticsKpiUnitSchema = z.enum(["count", "percent", "hours"]) +export type AnalyticsKpiUnit = z.infer + +/** The `key` values the backend emits today, so a client matches a KPI without reading its label. */ +export const AnalyticsKpiKey = { + pinsThisMonth: "pins_this_month", + resolved: "resolved", + cleanupsPlanned: "cleanups_planned", + avgRouteTime: "avg_route_time", + eventsThisMonth: "events_this_month", + newUsers: "new_users", +} as const +export type AnalyticsKpiKey = (typeof AnalyticsKpiKey)[keyof typeof AnalyticsKpiKey] + /** One KPI cell: a label, a value, a delta, and the delta direction (for the up/down arrow). */ export const AnalyticsKpiSchema = z .object({ @@ -14,6 +28,9 @@ export const AnalyticsKpiSchema = z num: z.number(), delta: z.string(), dir: z.enum(["up", "down", "flat"]), + // An open string, not the AnalyticsKpiKey union, so a KPI added later still parses on older clients. + key: z.string().min(1).optional(), + unit: AnalyticsKpiUnitSchema.optional(), }) .strict() export type AnalyticsKpi = z.infer diff --git a/packages/shared/src/schemas/admin/common.ts b/packages/shared/src/schemas/admin/common.ts index 82349dd0..13447933 100644 --- a/packages/shared/src/schemas/admin/common.ts +++ b/packages/shared/src/schemas/admin/common.ts @@ -74,6 +74,20 @@ export function canTransitionReportStatus(from: AdminReportStatus, to: AdminRepo return ADMIN_REPORT_STATUS_TRANSITIONS[from].includes(to) } +export const AdminReportStatusBucketSchema = z.enum(["submitted", "in_progress", "completed"]) +export type AdminReportStatusBucket = z.infer + +// `published` and `held` sit in `submitted`: an authed pin is created published and is still awaiting +// city action. `rejected` has no bucket because removal soft-deletes the report out of every list, so +// the backend's list filters and counts and the admin pills must all read this one map. +export const ADMIN_REPORT_STATUS_BUCKETS: Readonly< + Record +> = { + submitted: ["submitted", "held", "published"], + in_progress: ["acknowledged", "in_progress"], + completed: ["resolved"], +} + /** Cleanup (event) lifecycle. */ export const EventStatusSchema = z.enum(["upcoming", "in_progress", "completed", "cancelled"]) export type EventStatus = z.infer @@ -214,6 +228,10 @@ export type ModerationDestinationKind = z.infer +/** The backend mirrors this exact value list (the `moderation_items.status` CHECK constraint). */ +export const ModerationItemStatusSchema = z.enum(["open", "approved", "removed", "held"]) +export type ModerationItemStatus = z.infer + /** Queue priority bands shared by moderation + discovery rows. */ export const PrioritySchema = z.enum(["low", "med", "high"]) export type Priority = z.infer diff --git a/packages/shared/src/schemas/admin/discovery.ts b/packages/shared/src/schemas/admin/discovery.ts index 4cd1e1e9..3a0dd61c 100644 --- a/packages/shared/src/schemas/admin/discovery.ts +++ b/packages/shared/src/schemas/admin/discovery.ts @@ -1,5 +1,5 @@ import { z } from "zod" -import { ReportCategorySchema } from "../common.js" +import { ISODateSchema, ReportCategorySchema } from "../common.js" import { pageResponse } from "../common.js" import { JurisdictionLayerSchema } from "../map.js" import { AdminListQuerySchema, PrioritySchema } from "./common.js" @@ -93,6 +93,9 @@ export const DiscoveryContactSchema = z .object({ category: ReportCategorySchema, email: z.string().email().nullable(), + // Set when mail to this address hard-bounced; routing skips it until then. Saving an email for the + // category (even the same address) clears it. + bouncedAt: ISODateSchema.nullable().optional(), }) .strict() export type DiscoveryContact = z.infer diff --git a/packages/shared/src/schemas/admin/events.ts b/packages/shared/src/schemas/admin/events.ts index fb5f4254..2e25e281 100644 --- a/packages/shared/src/schemas/admin/events.ts +++ b/packages/shared/src/schemas/admin/events.ts @@ -117,10 +117,13 @@ export const SetEventStatusRequestSchema = z .strict() export type SetEventStatusRequest = z.infer +// `flagged` sets the marker to that value so a retried request cannot undo itself; without it the +// request toggles, as older admin builds expect. export const FlagEventRequestSchema = z .object({ id: z.string(), reason: z.string().max(500).optional(), + flagged: z.boolean().optional(), }) .strict() export type FlagEventRequest = z.infer diff --git a/packages/shared/src/schemas/admin/home.ts b/packages/shared/src/schemas/admin/home.ts index c07162b7..ca309fae 100644 --- a/packages/shared/src/schemas/admin/home.ts +++ b/packages/shared/src/schemas/admin/home.ts @@ -67,6 +67,9 @@ export const HomeSummaryResponseSchema = z livePins24h: z.number().int().nonnegative(), moderationQueue: z.number().int().nonnegative().optional(), inboxUnread: z.number().int().nonnegative().optional(), + // The top-level keys whose query failed and were filled with zeros, so a tile can render as + // unavailable instead of showing a real-looking 0. + degraded: z.array(z.string()).optional(), }) .strict() export type HomeSummaryResponse = z.infer diff --git a/packages/shared/src/schemas/admin/mail.ts b/packages/shared/src/schemas/admin/mail.ts index e94ea400..1c3ebe82 100644 --- a/packages/shared/src/schemas/admin/mail.ts +++ b/packages/shared/src/schemas/admin/mail.ts @@ -12,7 +12,11 @@ import { export const MailAttachmentSchema = z .object({ + // Read endpoints overwrite `key` with the presigned link for older admin builds; `url` carries the + // same link under an honest name. It expires MEDIA_GET_URL_TTL_SEC (15 minutes) after the response + // is built, so refetch the message instead of caching the link. key: z.string(), + url: z.string().url().optional(), filename: z.string(), size: z.number().int().nonnegative(), }) diff --git a/packages/shared/src/schemas/admin/moderation.ts b/packages/shared/src/schemas/admin/moderation.ts index fb27c731..fb16efc7 100644 --- a/packages/shared/src/schemas/admin/moderation.ts +++ b/packages/shared/src/schemas/admin/moderation.ts @@ -7,6 +7,7 @@ import { ModerationKindSchema, ModerationSubjectTypeSchema, ModerationToneSchema, + ModerationItemStatusSchema, PrioritySchema, } from "./common.js" import { AdminMediaRefSchema } from "./internal-fields.js" @@ -113,6 +114,8 @@ export const ModerationItemDTOSchema = ModerationListItemDTOSchema.extend({ user: ModerationUserSchema, similar: z.array(ModerationSimilarSchema), media: z.array(ModerationMediaSchema), + // The detail endpoint also returns resolved items, which must not offer the decision actions again. + status: ModerationItemStatusSchema.optional(), }).strict() export type ModerationItemDTO = z.infer diff --git a/packages/shared/src/schemas/admin/reports.ts b/packages/shared/src/schemas/admin/reports.ts index 59c7c81e..68b41d3f 100644 --- a/packages/shared/src/schemas/admin/reports.ts +++ b/packages/shared/src/schemas/admin/reports.ts @@ -8,6 +8,7 @@ import { AdminCoordsSchema, AdminReportStatusSchema, AdminListQuerySchema, + AdminOkResponseSchema, RelAbsTimeSchema, } from "./common.js" import { AdminMediaRefSchema } from "./internal-fields.js" @@ -200,6 +201,7 @@ export const FlagReportRequestSchema = z .object({ id: z.string(), reason: z.string().max(500).optional(), + flagged: z.boolean().optional(), }) .strict() export type FlagReportRequest = z.infer @@ -263,7 +265,7 @@ export const SetReportVerdictRequestSchema = z .strict() export type SetReportVerdictRequest = z.infer -export const SetReportVerdictResponseSchema = z.object({ ok: z.literal(true) }).strict() +export const SetReportVerdictResponseSchema = AdminOkResponseSchema export type SetReportVerdictResponse = z.infer export const AdminReportMessagesRequestSchema = ReportChatHistoryRequestSchema diff --git a/packages/shared/src/schemas/admin/users.ts b/packages/shared/src/schemas/admin/users.ts index 106b5415..2498c58d 100644 --- a/packages/shared/src/schemas/admin/users.ts +++ b/packages/shared/src/schemas/admin/users.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { CleanupMemberRoleSchema, + CursorSchema, ReportCategorySchema, ISODateSchema, IdSchema, @@ -16,6 +17,9 @@ import { UserStatusSchema, } from "./common.js" +export const MessageRemovedBySchema = z.enum(["author", "operator"]) +export type MessageRemovedBy = z.infer + /** * A user list row. `reports`/`cleanups` are derived counts; `removals`/`strikes` come from the * moderation side table; `risk` is the moderation risk band; `flagged`/`flagReason` the abuse marker. @@ -56,6 +60,9 @@ export type AdminUserListItemDTO = z.infer */ export const AdminUserListQuerySchema = AdminListQuerySchema.extend({ filter: z.enum(["all", "active", "suspended", "flagged", "deleted", "banned"]).optional(), + // Leaves out the members of this organization server-side, so an add-member picker's page is not + // spent on people who are already members. + excludeOrgId: IdSchema.optional(), }) export type AdminUserListQuery = z.infer @@ -117,7 +124,7 @@ export type GetAdminUserResponse = z.infer */ export const UserSubListQuerySchema = z.object({ id: z.string(), - cursor: z.string().optional(), + cursor: CursorSchema.optional(), limit: z.coerce.number().int().positive().max(100).optional(), }) export type UserSubListQuery = z.infer @@ -164,6 +171,9 @@ export const UserMessageItemDTOSchema = z // When the user themselves deleted (tombstoned) this message; null/omitted => not user-deleted. // Operators keep full visibility of the original text. Optional so an older server still parses. deletedAt: ISODateSchema.nullable().optional(), + // Author self-deletion and operator removal both set `deletedAt`; this tells them apart. Null when + // the message is live. + removedBy: MessageRemovedBySchema.nullable().optional(), // `chat` is cleanup/event chat; `group` is a standalone group or channel. They intentionally remain // distinct because only cleanup chat has an admin event destination. source: z.enum(["chat", "group", "dm", "report"]).optional(), @@ -181,6 +191,7 @@ export const FlagUserRequestSchema = z .object({ id: z.string(), reason: z.string().max(500).optional(), + flagged: z.boolean().optional(), }) .strict() export type FlagUserRequest = z.infer diff --git a/packages/shared/src/schemas/host/registrations.ts b/packages/shared/src/schemas/host/registrations.ts index 4176f50a..f602fcb1 100644 --- a/packages/shared/src/schemas/host/registrations.ts +++ b/packages/shared/src/schemas/host/registrations.ts @@ -194,6 +194,9 @@ export const CreateWalkupRegistrationRequestSchema = z name: z.string().trim().min(1).max(MAX_ATTENDEE_NAME), partySize: z.number().int().min(1).max(MAX_PARTY_SIZE).default(1), checkInNow: z.boolean().default(true), + // Without it the backend derives a key from the name and the minute, which merges two same-name + // walk-ups and splits one double-tap across a minute boundary. + idempotencyKey: IdempotencyKeySchema.optional(), }) .strict() export type CreateWalkupRegistrationRequest = z.infer diff --git a/packages/shared/src/time-units.ts b/packages/shared/src/time-units.ts new file mode 100644 index 00000000..398db902 --- /dev/null +++ b/packages/shared/src/time-units.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 = SECONDS_PER_MINUTE * MINUTES_PER_HOUR +export const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +export const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND +export const MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE +export const MS_PER_DAY = 24 * MS_PER_HOUR +export const MS_PER_WEEK = 7 * MS_PER_DAY diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts new file mode 100644 index 00000000..05515dca --- /dev/null +++ b/packages/shared/src/url.ts @@ -0,0 +1,5 @@ +const TRAILING_SLASHES = /\/+$/ + +export function stripTrailingSlashes(value: string): string { + return value.replace(TRAILING_SLASHES, "") +} diff --git a/packages/shared/src/uuid.ts b/packages/shared/src/uuid.ts new file mode 100644 index 00000000..47b48cf5 --- /dev/null +++ b/packages/shared/src/uuid.ts @@ -0,0 +1,7 @@ +// Same shape and case-insensitivity as zod's `.uuid()` behind IdSchema and the backend's cursor check, +// so a value this accepts is one IdSchema accepts. +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export function isUuid(value: string): boolean { + return UUID_PATTERN.test(value) +} diff --git a/packages/ui/src/bodies/calendarModel.ts b/packages/ui/src/bodies/calendarModel.ts index 8941c349..9d3460e4 100644 --- a/packages/ui/src/bodies/calendarModel.ts +++ b/packages/ui/src/bodies/calendarModel.ts @@ -1,4 +1,4 @@ -import { MIN_EVENT_DURATION_MINUTES } from "@civfix/shared" +import { MIN_EVENT_DURATION_MINUTES, MS_PER_DAY } from "@civfix/shared" import { wallClockExistsInZone, wallClockInZone, @@ -6,7 +6,6 @@ import { zoneShortName, type WallClock, } from "@civfix/shared/datetime" -import { DAY_MS } from "./timeUnits" export function startOfDay(d: Date): Date { const x = new Date(d) @@ -70,7 +69,7 @@ function clockMs(time: Date): number { } function offsetFromClocks(startClock: number, endClock: number): number { - return (((endClock - startClock) % DAY_MS) + DAY_MS) % DAY_MS + return (((endClock - startClock) % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY } export function endOffsetMs(start: Date, end: Date): number { @@ -175,7 +174,7 @@ export function wallClockToFormDate(wallClock: WallClock): Date { export function addWallClockDays(wallClock: WallClock, days: number): WallClock { const anchor = new Date(Date.UTC(wallClock.year, wallClock.month - 1, wallClock.day, 12)) - const moved = new Date(anchor.getTime() + days * DAY_MS) + const moved = new Date(anchor.getTime() + days * MS_PER_DAY) return { year: moved.getUTCFullYear(), month: moved.getUTCMonth() + 1, @@ -276,7 +275,7 @@ export function makeZoneDisplayNameCache(): ZoneDisplayNameCache { const names = new Map() return { get(timeZone, locale, now = Date.now()) { - const today = Math.floor(now / DAY_MS) + const today = Math.floor(now / MS_PER_DAY) if (today !== day) { names.clear() day = today diff --git a/packages/ui/src/bodies/host/__tests__/analyticsModel.test.ts b/packages/ui/src/bodies/host/__tests__/analyticsModel.test.ts index 1c763a67..f4a0eb1c 100644 --- a/packages/ui/src/bodies/host/__tests__/analyticsModel.test.ts +++ b/packages/ui/src/bodies/host/__tests__/analyticsModel.test.ts @@ -27,7 +27,7 @@ import { wholeEventCheckedIn, wholeEventSignups, } from "../analyticsModel" -import { DAY_MS } from "../../timeUnits" +import { MS_PER_DAY } from "@civfix/shared" import { breakdownBars, seriesBars } from "../analytics/chartBars" import { EMPTY_VALUE } from "../../../i18n/emptyValue" @@ -167,7 +167,7 @@ describe("slicing a series only ever drops points", () => { }) it("returns an EMPTY series rather than silently falling back to the whole one", () => { - expect(rangeSlice(series, 1, now + 30 * DAY_MS)).toEqual([]) + expect(rangeSlice(series, 1, now + 30 * MS_PER_DAY)).toEqual([]) }) it("passes the whole series through when there is no window to apply", () => { diff --git a/packages/ui/src/bodies/host/analyticsModel.ts b/packages/ui/src/bodies/host/analyticsModel.ts index 487ac005..f6b4a2a3 100644 --- a/packages/ui/src/bodies/host/analyticsModel.ts +++ b/packages/ui/src/bodies/host/analyticsModel.ts @@ -7,10 +7,9 @@ import type { SeriesPoint, SuppressedRate, } from "@civfix/shared" -import { EVENT_ANALYTICS_COMPARISON_MIN_EVENTS } from "@civfix/shared" +import { EVENT_ANALYTICS_COMPARISON_MIN_EVENTS, MS_PER_DAY, isUuid } from "@civfix/shared" import { visibleValue } from "@civfix/shared/host" import { hasHostCapability } from "../../data/hooks/host" -import { DAY_MS } from "../timeUnits" import { dedupeById } from "../../primitives/listKeys" export const SUMMARY_PANELS = ["signups", "checkins", "hours", "impact"] as const @@ -53,7 +52,7 @@ export function rangeSlice( now: number, ): readonly SeriesPoint[] { if (days === null || days <= 0) return points - const from = Math.floor(now / DAY_MS) * DAY_MS - (days - 1) * DAY_MS + const from = Math.floor(now / MS_PER_DAY) * MS_PER_DAY - (days - 1) * MS_PER_DAY return points.filter((point) => { const at = parse(point.day) return at === null ? true : at >= from @@ -135,10 +134,8 @@ export function pickerOptions( .map((event) => ({ id: event.id, title: event.title })) } -const EVENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - export function isEventId(key: string): boolean { - return EVENT_ID.test(key) + return isUuid(key) } export function eventRowTarget( diff --git a/packages/ui/src/bodies/host/dashboard/dashboardModel.ts b/packages/ui/src/bodies/host/dashboard/dashboardModel.ts index f991ed51..53ee4141 100644 --- a/packages/ui/src/bodies/host/dashboard/dashboardModel.ts +++ b/packages/ui/src/bodies/host/dashboard/dashboardModel.ts @@ -12,13 +12,12 @@ import type { OrganizationMemberRole, OrgInviteIdentifierKind, } from "@civfix/shared" -import { ErrorCode, MAX_ORG_INVITES_PER_ORG, byErrorCode, type ErrorCodeTable } from "@civfix/shared" +import { ErrorCode, MAX_ORG_INVITES_PER_ORG, MS_PER_DAY, byErrorCode, type ErrorCodeTable } from "@civfix/shared" import type { EventWhenInput } from "@civfix/shared/datetime" import { wallClockInZone, wallClockToInstantMs, type WallClock } from "@civfix/shared/datetime" import { can, deriveCleanupStatus, eventPhase, type EventWindowLike } from "@civfix/shared/host" import { addWallClockDays, formInstantMs } from "../../calendarModel" import { viewerTimeZone } from "../../../i18n" -import { DAY_MS } from "../../timeUnits" import { hasHostCapability } from "../../../data/hooks/host" import { hasActions, @@ -267,9 +266,9 @@ export function nextDuplicateStart( ): DuplicateStartSeed { const zone = timezone ?? viewerTimeZone() const parsed = Date.parse(startsAt) - const seed = Number.isNaN(parsed) ? now.getTime() + 7 * DAY_MS : parsed + const seed = Number.isNaN(parsed) ? now.getTime() + 7 * MS_PER_DAY : parsed const behindMs = now.getTime() - seed - const weeks = behindMs > 0 ? Math.ceil(behindMs / (7 * DAY_MS)) : 0 + const weeks = behindMs > 0 ? Math.ceil(behindMs / (7 * MS_PER_DAY)) : 0 let wallClock = addWallClockDays(wallClockInZone(seed, zone), weeks * 7) for (let roll = 0; roll < MAX_DUPLICATE_ROLLS; roll++) { @@ -281,7 +280,7 @@ export function nextDuplicateStart( : addWallClockDays(wallClock, 7) } - const fallback = now.getTime() + 7 * DAY_MS + const fallback = now.getTime() + 7 * MS_PER_DAY return { instantMs: fallback, wallClock: wallClockInZone(fallback, zone) } } diff --git a/packages/ui/src/bodies/profile/certificate/CertificateIssuedPanel.tsx b/packages/ui/src/bodies/profile/certificate/CertificateIssuedPanel.tsx index 6ed0def5..5fa5c2cd 100644 --- a/packages/ui/src/bodies/profile/certificate/CertificateIssuedPanel.tsx +++ b/packages/ui/src/bodies/profile/certificate/CertificateIssuedPanel.tsx @@ -1,6 +1,6 @@ import React from "react" import { View, Pressable } from "react-native" -import type { ServiceHoursCertificateDTO } from "@civfix/shared" +import { MS_PER_MINUTE, type ServiceHoursCertificateDTO } from "@civfix/shared" import { useTheme, webSelectableText, focusRingProps } from "../../../theme" import { Text, Icon, iconMap } from "../../../typography" import { MetaDot } from "../../../primitives" @@ -8,13 +8,12 @@ import { useT } from "../../../i18n" import { certificateExpiryLabel } from "../../serviceCertificate" import { CertificateCode, CertificateCopyButton } from "./CertificateCode" import { useCertificateCardStyles } from "./certificateCardStyles" -import { MINUTE_MS } from "../../timeUnits" /** Whole minutes of link life left, floored at 0 (the card has already flipped to `expired` by then). */ function minutesLeft(expiresAt: string, now: number): number { const at = new Date(expiresAt).getTime() if (Number.isNaN(at)) return 0 - return Math.max(0, Math.ceil((at - now) / MINUTE_MS)) + return Math.max(0, Math.ceil((at - now) / MS_PER_MINUTE)) } export function CertificateIssuedPanel({ diff --git a/packages/ui/src/bodies/relativeTime.ts b/packages/ui/src/bodies/relativeTime.ts index 166cc793..00fedb0b 100644 --- a/packages/ui/src/bodies/relativeTime.ts +++ b/packages/ui/src/bodies/relativeTime.ts @@ -1,5 +1,4 @@ -import { relativeAgo, EDIT_WINDOW_HOURS, type RelativeUnitLabels } from "@civfix/shared" -import { HOUR_MS } from "./timeUnits" +import { relativeAgo, EDIT_WINDOW_HOURS, MS_PER_HOUR, type RelativeUnitLabels } from "@civfix/shared" export interface ListTimeAgoOptions { justNow?: string @@ -87,5 +86,5 @@ export function dayLabel(iso: string, opts: DayLabelOptions = {}): string { export function withinEditWindow(createdAt: string, now: Date = new Date()): boolean { const t = new Date(createdAt).getTime() if (Number.isNaN(t)) return false - return now.getTime() - t < EDIT_WINDOW_HOURS * HOUR_MS + return now.getTime() - t < EDIT_WINDOW_HOURS * MS_PER_HOUR } diff --git a/packages/ui/src/bodies/reportPicker/reportPickerModel.ts b/packages/ui/src/bodies/reportPicker/reportPickerModel.ts index ca6b5c39..f53a3ab8 100644 --- a/packages/ui/src/bodies/reportPicker/reportPickerModel.ts +++ b/packages/ui/src/bodies/reportPicker/reportPickerModel.ts @@ -1,5 +1,6 @@ import { haversineMeters, + isUuid, type BBox, type LinkedReportRef, type ReportCategory, @@ -177,13 +178,12 @@ export function mergePins(...groups: ReadonlyArray): Re return [...byId.values()] } -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const REFERENCE_CODE_PATTERN = /^[a-z]{2,4}-\d{1,6}-\d{6}$/i const SHORT_ID_LENGTH = 8 export function reportLookupKey(query: string): string | null { const q = query.trim() - if (UUID_PATTERN.test(q)) return q.toLowerCase() + if (isUuid(q)) return q.toLowerCase() if (REFERENCE_CODE_PATTERN.test(q)) return q.toUpperCase() return null } diff --git a/packages/ui/src/bodies/timeUnits.ts b/packages/ui/src/bodies/timeUnits.ts deleted file mode 100644 index b76219a9..00000000 --- a/packages/ui/src/bodies/timeUnits.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const MINUTE_MS = 60_000 -export const HOUR_MS = 60 * MINUTE_MS -export const DAY_MS = 24 * HOUR_MS diff --git a/packages/ui/src/primitives/externalUrls.ts b/packages/ui/src/primitives/externalUrls.ts index f21e6a34..6d0053b8 100644 --- a/packages/ui/src/primitives/externalUrls.ts +++ b/packages/ui/src/primitives/externalUrls.ts @@ -1,3 +1,4 @@ +import { stripTrailingSlashes } from "@civfix/shared" import { SOURCE_REPO_URL, sourceLink } from "@civfix/shared/legal" export const WEB_ORIGIN = "https://civfix.org" @@ -5,7 +6,7 @@ export const WEB_ORIGIN = "https://civfix.org" let configuredWebOrigin = WEB_ORIGIN export function setWebOrigin(origin: string): void { - configuredWebOrigin = origin.replace(/\/+$/, "") + configuredWebOrigin = stripTrailingSlashes(origin) } export function webOrigin(): string { From f3e75b53e45ad4bd870566c611dc56ab7153cab5 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 07:41:44 +0000 Subject: [PATCH 04/10] export the contract's input limits and constants and use them in ui, web and mobile instead of copies --- apps/community-mobile/app/auth/otp.tsx | 6 +- .../src/components/FirstRunGate.tsx | 4 +- .../src/components/auth/auth-modal.tsx | 7 +- .../src/features/auth/first-run-gate.tsx | 2 +- .../host/broadcasts/broadcast-composer.tsx | 2 +- .../src/features/host/org/org-invites.ts | 7 +- .../features/host/org/org-profile-form.tsx | 22 +- .../features/host/org/verification-screen.tsx | 7 +- .../host/page-builder/block-editor.tsx | 69 ++-- .../host/settings/settings-screen.tsx | 3 +- .../host/tickets/questions-editor.tsx | 10 +- apps/community-web/src/lib/input-limits.ts | 5 - apps/community-web/src/lib/turnstile.ts | 7 +- .../shared/__tests__/contract-limits.test.ts | 353 ++++++++++++++++++ packages/shared/src/host/turnstile.ts | 3 + packages/shared/src/schemas/auth.ts | 11 +- packages/shared/src/schemas/certificates.ts | 4 +- packages/shared/src/schemas/chat.ts | 11 +- packages/shared/src/schemas/cleanups.ts | 35 +- packages/shared/src/schemas/common.ts | 3 + packages/shared/src/schemas/entities.ts | 105 ++++-- packages/shared/src/schemas/groups.ts | 11 +- .../shared/src/schemas/host/organizations.ts | 6 +- packages/shared/src/schemas/host/questions.ts | 5 +- packages/shared/src/schemas/host/team.ts | 3 +- .../shared/src/schemas/internal-fields.ts | 11 +- packages/shared/src/schemas/reports.ts | 10 +- packages/shared/src/schemas/social.ts | 13 +- packages/shared/src/schemas/volunteer.ts | 4 +- packages/shared/src/types/ws.ts | 23 +- packages/ui/src/bodies/CleanupForm.tsx | 10 +- packages/ui/src/bodies/DeleteAccountModal.tsx | 12 +- .../ui/src/bodies/GroupIdentityFields.tsx | 6 +- .../src/bodies/__tests__/groupWizard.test.ts | 11 +- packages/ui/src/bodies/channelWizard.ts | 5 +- .../bodies/conversation/conversationModel.ts | 16 +- .../ui/src/bodies/feed/InlineComposer.tsx | 5 +- packages/ui/src/bodies/groupWizard.ts | 13 +- packages/ui/src/bodies/host/OrgManageBody.tsx | 4 +- .../host/__tests__/orgManageModel.test.ts | 5 +- packages/ui/src/bodies/host/hostTeamModel.ts | 5 +- packages/ui/src/bodies/host/orgManageModel.ts | 18 +- .../host/registration/GuestRsvpSheet.tsx | 6 +- .../host/registration/guestRsvpModel.ts | 8 +- packages/ui/src/bodies/index.ts | 1 - .../postComposer/ComposerMessageField.tsx | 4 +- packages/ui/src/bodies/postComposerModel.ts | 3 - .../ui/src/bodies/reportFlow/CaptureStep.tsx | 9 +- .../ui/src/bodies/reportFlow/DetailsStep.tsx | 6 +- .../bodies/settings/ChangeUsernameEditor.tsx | 4 +- .../src/bodies/settings/DisplayNameEditor.tsx | 7 +- .../bodies/settings/DonationLinkEditor.tsx | 4 +- .../__tests__/donationLinkField.test.ts | 5 +- .../src/bodies/settings/donationLinkField.ts | 2 - .../ui/src/bodies/thread/ReplyComposer.tsx | 8 +- .../src/data/__tests__/firstRunModel.test.ts | 11 +- packages/ui/src/data/firstRunModel.ts | 11 +- packages/ui/src/data/hooks/volunteer.ts | 12 +- packages/ui/src/data/inbound.ts | 11 +- packages/ui/src/data/index.ts | 2 +- .../i18n/__tests__/resourcesParity.test.ts | 10 +- packages/ui/src/i18n/config.ts | 4 +- packages/ui/src/i18n/resolveLocale.ts | 4 +- .../ui/src/primitives/CancelEventSheet.tsx | 7 +- .../ui/src/primitives/PollCreateSheet.tsx | 4 +- .../src/primitives/RequestResourcesSheet.tsx | 7 +- .../primitives/__tests__/pollDraft.test.ts | 5 +- packages/ui/src/primitives/pollDraft.ts | 7 +- .../src/report/__tests__/draftStore.test.ts | 11 +- .../report/__tests__/submitRecovery.test.ts | 11 +- packages/ui/src/report/draftStore.ts | 7 +- packages/ui/src/report/submit.ts | 11 +- .../ui/src/share/__tests__/shareToDm.test.ts | 5 +- packages/ui/src/share/shareToDm.ts | 6 +- 74 files changed, 734 insertions(+), 351 deletions(-) delete mode 100644 apps/community-web/src/lib/input-limits.ts create mode 100644 packages/shared/__tests__/contract-limits.test.ts diff --git a/apps/community-mobile/app/auth/otp.tsx b/apps/community-mobile/app/auth/otp.tsx index 93d5634e..6af0c9ab 100644 --- a/apps/community-mobile/app/auth/otp.tsx +++ b/apps/community-mobile/app/auth/otp.tsx @@ -4,6 +4,7 @@ import { useRouter, useLocalSearchParams } from "expo-router" import { useSafeAreaInsets } from "react-native-safe-area-context" import Ionicons from "@expo/vector-icons/Ionicons" import { + EMAIL_OTP_CODE_LENGTH, REVIEWER_OTP_CODE_MAX_LENGTH, REVIEWER_OTP_CODE_MIN_LENGTH, REVIEWER_OTP_EMAIL, @@ -32,10 +33,9 @@ import { useResendCooldown } from "@/hooks/useResendCooldown" const { ScrollView: OtpScrollView } = makeKeyboardAwareScrollHost(PLAIN_SCROLL_HOST) const MAX_ATTEMPTS = 3 -const CODE_LENGTH = 6 function isCodeIncomplete(code: string, reviewer: boolean): boolean { - return reviewer ? code.trim().length < REVIEWER_OTP_CODE_MIN_LENGTH : code.length !== CODE_LENGTH + return reviewer ? code.trim().length < REVIEWER_OTP_CODE_MIN_LENGTH : code.length !== EMAIL_OTP_CODE_LENGTH } export default function OtpScreen() { @@ -194,7 +194,7 @@ export default function OtpScreen() { setError(null) setCode(next) }} - length={CODE_LENGTH} + length={EMAIL_OTP_CODE_LENGTH} onComplete={onVerify} editable={!locked && !verifying} /> diff --git a/apps/community-mobile/src/components/FirstRunGate.tsx b/apps/community-mobile/src/components/FirstRunGate.tsx index 27a3dd4e..11063960 100644 --- a/apps/community-mobile/src/components/FirstRunGate.tsx +++ b/apps/community-mobile/src/components/FirstRunGate.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" import { View, StyleSheet, ActivityIndicator, Pressable } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import Ionicons from "@expo/vector-icons/Ionicons" +import { HANDLE_MAX_LENGTH } from "@civfix/shared" import { MIN_TOUCH_TARGET, makeThemedStyles, space, useTheme } from "@/theme" import { AgeConfirmation, @@ -32,9 +33,6 @@ import { friendlyError } from "@/lib/errors" const { ScrollView: FirstRunScrollView } = makeKeyboardAwareScrollHost(PLAIN_SCROLL_HOST) -// The upper bound of the shared HANDLE_REGEX, so the field stops where validation would reject. -const HANDLE_MAX_LENGTH = 20 - export function FirstRunGate() { const status = useAuthStore((s) => s.status) const incomplete = useAuthStore((s) => s.user?.profileComplete === false) diff --git a/apps/community-web/src/components/auth/auth-modal.tsx b/apps/community-web/src/components/auth/auth-modal.tsx index 60da180e..5eb67347 100644 --- a/apps/community-web/src/components/auth/auth-modal.tsx +++ b/apps/community-web/src/components/auth/auth-modal.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { createPortal } from "react-dom" import { X } from "lucide-react" +import { EMAIL_OTP_CODE_LENGTH } from "@civfix/shared" import { useT } from "@civfix/ui/i18n" import { useFocusTrap } from "@/components/console/overlay/use-focus-trap" @@ -18,8 +19,6 @@ import { useUiStore } from "@/store/ui-store" import { AuthStepHeading, ChoicesStep, CodeStep, EmailStep, type AuthStep } from "./auth-modal-steps" import { useOtpEntry } from "./use-otp-entry" -const OTP_LENGTH = 6 - /** * The return target is the current origin, or a same-origin console path allowlisted by oauthReturnPath * (such as an org invite waiting to be accepted). Apple is offered only when the server has a Sign in @@ -51,7 +50,7 @@ export function AuthModal({ oauthReturnPath = null }: AuthModalProps = {}) { const [step, setStep] = React.useState("choices") const [email, setEmail] = React.useState("") - const otp = useOtpEntry(OTP_LENGTH) + const otp = useOtpEntry(EMAIL_OTP_CODE_LENGTH) const [submitting, setSubmitting] = React.useState(false) const [error, setError] = React.useState(null) const [resendAfter, setResendAfter] = React.useState(0) @@ -222,7 +221,7 @@ export function AuthModal({ oauthReturnPath = null }: AuthModalProps = {}) { = { - facebook: "facebook.com/", - instagram: "instagram.com/", - tiktok: "tiktok.com/@", - x: "x.com/", - whatsapp: "+", -} export interface OrgProfileDraft { name: string @@ -730,7 +720,7 @@ function SocialLinkField({ optional error={error} > - + onChange(event.target.value)} diff --git a/apps/community-web/src/features/host/org/verification-screen.tsx b/apps/community-web/src/features/host/org/verification-screen.tsx index 8c0ce61d..ea5bc77b 100644 --- a/apps/community-web/src/features/host/org/verification-screen.tsx +++ b/apps/community-web/src/features/host/org/verification-screen.tsx @@ -12,6 +12,7 @@ import type { import { ApplyOrganizationVerificationRequestSchema, MAX_ORG_VERIFICATION_DOCUMENTS, + MAX_ORG_VERIFICATION_NOTE, } from "@civfix/shared" import { useApi } from "@civfix/ui/data" import { useT } from "@civfix/ui/i18n" @@ -40,8 +41,6 @@ import { suspendedForbiddenCopy } from "./org-copy" import { useOrgVerification } from "./use-org-verification" const KINDS: readonly OrgVerificationKind[] = ["nonprofit", "government", "community"] -/** Mirrors the contract's unexported `note` max on the apply request. */ -const MAX_NOTE = 1000 /** "XX-XXXXXXX", the shape `normalizeEin` types into the field. */ const EIN_INPUT_MAX = 10 @@ -350,13 +349,13 @@ function ApplyForm({ orgId, onSubmitted }: { orgId: string; onSubmitted: () => v label={t("verification.field_note", { defaultValue: "Note for the reviewer" })} htmlFor="verification-note" optional - counter={`${note.length}/${MAX_NOTE}`} + counter={`${note.length}/${MAX_ORG_VERIFICATION_NOTE}`} error={showError("note")} >