From de2613637c960ba55e67f7bcee71223e98c027db Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 13:24:59 +0000 Subject: [PATCH 1/3] in-file cleanup: posts, social, notifications, legal --- services/api/src/routes/legal.routes.ts | 4 +- .../api/src/routes/notifications.routes.ts | 4 +- services/api/src/routes/social.routes.ts | 44 ++- services/api/src/routes/users.routes.ts | 156 +++++---- .../api/src/services/discussion-mentions.ts | 13 +- services/api/src/services/feed-presence.ts | 10 +- services/api/src/services/feed-ranking.ts | 9 +- services/api/src/services/legal-service.ts | 43 +-- .../api/src/services/notification-helpers.ts | 14 +- .../notification-repository.drizzle.ts | 90 +++-- .../api/src/services/notification-service.ts | 309 +++++++++-------- .../src/services/post-repository.drizzle.ts | 323 ++++++++++-------- services/api/src/services/post-service.ts | 176 ++++++---- services/api/src/services/public-author.ts | 8 + .../api/src/services/push-token-policy.ts | 94 ++--- .../src/services/social-repository.drizzle.ts | 33 +- services/api/src/services/social-service.ts | 105 +++--- .../api/src/services/user-search.drizzle.ts | 41 +-- 18 files changed, 791 insertions(+), 685 deletions(-) diff --git a/services/api/src/routes/legal.routes.ts b/services/api/src/routes/legal.routes.ts index 91d93708..1da25a45 100644 --- a/services/api/src/routes/legal.routes.ts +++ b/services/api/src/routes/legal.routes.ts @@ -9,10 +9,8 @@ export const LEGAL_VERSIONS_RATE_LIMIT = perHost({ max: 120, timeWindow: "1 minu export async function registerLegalRoutes( app: FastifyInstance, - container: Container, + _container: Container, ): Promise { - void container - route( app, "getLegalVersions", diff --git a/services/api/src/routes/notifications.routes.ts b/services/api/src/routes/notifications.routes.ts index 25ea2672..947f5e4f 100644 --- a/services/api/src/routes/notifications.routes.ts +++ b/services/api/src/routes/notifications.routes.ts @@ -29,6 +29,8 @@ export const MARK_NOTIFICATIONS_READ_MAX_IDS = 200 export const PUSH_TOKEN_RATE_LIMIT = perIdentity({ max: 10, timeWindow: "1 minute" }) +const DeviceIdSchema = z.string().uuid() + export const MarkNotificationsReadBodySchema = MarkReadRequestSchema.extend({ ids: MarkReadRequestSchema.shape.ids.max(MARK_NOTIFICATIONS_READ_MAX_IDS), }) @@ -163,5 +165,3 @@ export function normalizeDeviceId(raw: string | undefined): string | undefined { const parsed = DeviceIdSchema.safeParse(raw.trim().toLowerCase()) return parsed.success ? parsed.data : undefined } - -const DeviceIdSchema = z.string().uuid() diff --git a/services/api/src/routes/social.routes.ts b/services/api/src/routes/social.routes.ts index 752902e2..0a8e0f17 100644 --- a/services/api/src/routes/social.routes.ts +++ b/services/api/src/routes/social.routes.ts @@ -18,9 +18,11 @@ import { requireAuth } from "../auth/context.js" import { makeContainerSuggestionsCache } from "../services/social-suggestions-wiring.js" import { makeSocialService, + PERSON_NOT_FOUND_MESSAGE, type SocialNotifier, type SocialRepository, type SocialService, + type SocialServiceDeps, type SocialViewer, } from "../services/social-service.js" import { makeDrizzleSocialRepository } from "../services/social-repository.drizzle.js" @@ -75,34 +77,28 @@ export async function registerSocialRoutes( const suggestionsCache = makeContainerSuggestionsCache(container) + function containerDeps(): Omit { + return { + affiliations: container.getAffiliationLoader(), + ...(suggestionsCache !== undefined ? { suggestionsCache } : {}), + presignAvatar: (k: string) => container.storage.presignGet(k, MEDIA_GET_URL_TTL_SEC), + volunteerHoursTotalFor: (userId: string) => + container.getVolunteerHoursRepo().totalHoursFor(userId), + isBlockedEitherWay: (viewerId: string, targetId: string) => + container.getBlocksRepo().isBlockedEitherWay(viewerId, targetId), + blockState: (viewerId: string, targetId: string) => + container.getBlocksRepo().blockState(viewerId, targetId), + } + } + function service(withNotifier = false): SocialService { const n = withNotifier ? notifier() : undefined - const cache = app.socialOverrides ? undefined : suggestionsCache - const presignAvatar = app.socialOverrides - ? undefined - : (k: string) => container.storage.presignGet(k, MEDIA_GET_URL_TTL_SEC) - const volunteerHoursTotalFor = app.socialOverrides - ? undefined - : (userId: string) => container.getVolunteerHoursRepo().totalHoursFor(userId) - const isBlockedEitherWay = app.socialOverrides - ? undefined - : (viewerId: string, targetId: string) => - container.getBlocksRepo().isBlockedEitherWay(viewerId, targetId) - const blockState = app.socialOverrides - ? undefined - : (viewerId: string, targetId: string) => - container.getBlocksRepo().blockState(viewerId, targetId) - const affiliations = app.socialOverrides ? undefined : container.getAffiliationLoader() + const wired = app.socialOverrides ? {} : containerDeps() return makeSocialService({ repo: repo(), logger: app.log, - ...(affiliations !== undefined ? { affiliations } : {}), - ...(cache !== undefined ? { suggestionsCache: cache } : {}), + ...wired, ...(n !== undefined ? { notifier: n } : {}), - ...(presignAvatar !== undefined ? { presignAvatar } : {}), - ...(volunteerHoursTotalFor !== undefined ? { volunteerHoursTotalFor } : {}), - ...(isBlockedEitherWay !== undefined ? { isBlockedEitherWay } : {}), - ...(blockState !== undefined ? { blockState } : {}), }) } @@ -116,10 +112,10 @@ export async function registerSocialRoutes( async function resolvePersonId(ref: string): Promise { if (UUID_RE.test(ref)) { const person = await repo().findPersonById(ref) - if (person === null) throw AppError.notFound("Person not found") + if (person === null) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return ref } - if (ref.length > PERSON_REF_MAX) throw AppError.notFound("Person not found") + if (ref.length > PERSON_REF_MAX) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return service().resolveHandleToId(ref) } diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index 7aab8de0..34507ee6 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -15,14 +15,14 @@ import { type RequestDataExportResponse, } from "@civfix/shared" import { z } from "zod" -import type { FastifyInstance } from "fastify" +import type { FastifyBaseLogger, FastifyInstance } from "fastify" import type { Container } from "../di.js" import { requireAuth } from "../auth/context.js" import { isOfficialAccount } from "../auth/official-account.js" import { clearCsrfCookie } from "../auth/csrf.js" import { clearSessionCookie } from "../auth/transport.js" import { searchByHandlePrefix, searchMentionable } from "../services/social-repository.drizzle.js" -import { toUserDTO } from "../auth/auth-services.js" +import { toUserDTO, type AuthServices } from "../auth/auth-services.js" import { exposeMessage } from "../errors/exposed-message.js" import { writeAudit } from "../services/admin/audit.js" import { DATA_EXPORT_JOB, dataExportSupportEmail } from "../services/data-export-jobs.js" @@ -41,6 +41,8 @@ const UserIdParamsSchema = z.object({ id: IdSchema }).strict() export const MentionSearchQuerySchema = trimTextFields(MentionSearchRequestSchema, "q") +// The contract's locale enum can gain a value before the server ships a catalog for it, so the +// stored locale is narrowed to what this server can render. const SettingsLocaleSchema = z .object({ locale: z.enum([...SUPPORTED_LOCALES] as [string, ...string[]]).optional(), @@ -52,11 +54,17 @@ const USER_SEARCH_DEFAULT_LIMIT = 10 const USER_SEARCH_RATE_LIMIT = { max: 30, timeWindow: "1 minute" } as const export const BLOCK_RATE_LIMIT = { max: 60, timeWindow: "1 minute" } as const -export const UNBLOCKABLE_MESSAGE = "User not found" +const UNBLOCKABLE_MESSAGE = "User not found" -export const ACCOUNT_DELETION_UNAVAILABLE_MESSAGE = +const AUTH_REQUIRED_MESSAGE = "Authentication required." + +const ACCOUNT_DELETION_UNAVAILABLE_MESSAGE = "We couldn't delete your account right now. Nothing was changed. Please try again in a few minutes." +const ACCOUNT_DELETED_AUDIT_ACTION = "account.deleted" + +type PostDeletionCleanup = readonly [step: string, run: () => Promise] + export async function registerUsersRoutes( app: FastifyInstance, container: Container, @@ -74,7 +82,7 @@ export async function registerUsersRoutes( async (request, reply) => { const userId = requireAuth(request) const q = parse(SearchUsersRequestSchema, request.query) - const term = q.q.startsWith("@") ? q.q.slice(1) : q.q + const term = withoutMentionPrefix(q.q) const limit = q.limit ?? USER_SEARCH_DEFAULT_LIMIT const results = term.length === 0 @@ -92,7 +100,7 @@ export async function registerUsersRoutes( async (request, reply) => { const userId = requireAuth(request) const q = parse(MentionSearchQuerySchema, request.query) - const term = q.q.startsWith("@") ? q.q.slice(1) : q.q + const term = withoutMentionPrefix(q.q) const results = term.length === 0 ? [] @@ -159,8 +167,7 @@ export async function registerUsersRoutes( const userId = requireAuth(request) const body = parse(UpdateSettingsRequestSchema, request.body) const locale = parse(SettingsLocaleSchema, request.body).locale - const store = app.authServices?.users - if (!store) throw AppError.unauthorized("Authentication required.") + const store = usersStoreOf(app) const patch = { ...(body.allowDirectMessages !== undefined ? { allowDirectMessages: body.allowDirectMessages } @@ -177,7 +184,7 @@ export async function registerUsersRoutes( Object.keys(patch).length === 0 ? await store.findById(userId) : await store.updateSettings(userId, patch) - if (!updated) throw AppError.unauthorized("Authentication required.") + if (!updated) throw AppError.unauthorized(AUTH_REQUIRED_MESSAGE) const payload: UpdateSettingsResponse = { user: toUserDTO(updated) } reply.status(200).send(payload) }) @@ -193,7 +200,7 @@ export async function registerUsersRoutes( const otp = app.authServices?.otp const oauth = app.authServices?.oauth if (!store || !sessions || !otp || !oauth) { - throw AppError.unauthorized("Authentication required.") + throw AppError.unauthorized(AUTH_REQUIRED_MESSAGE) } const { emailOtp } = parse(DeleteAccountRequestSchema, request.body) @@ -210,57 +217,27 @@ export async function registerUsersRoutes( } } - // A cached session projection is checked against the ban marker and the epoch, never the sessions - // table, so deleting the rows alone would leave every cached bearer working, and sliding forward, - // up to the absolute session cap. The marker goes up before anything is erased: if it cannot be - // written the deletion is refused, and if the erasure then fails the marker comes down again so the - // account keeps working. - try { - await sessions.markBanned(userId) - } catch (err) { - throw exposeMessage( - new AppError(ErrorCode.INTERNAL, ACCOUNT_DELETION_UNAVAILABLE_MESSAGE, { - httpStatus: 503, - cause: err, - }), - ) - } - try { - await store.softDeleteAndAnonymize(userId) - } catch (err) { - await sessions.clearBan(userId).catch((clearErr: unknown) => { - request.log.error( - { err: clearErr, userId }, - "account deletion: erasure failed and the pre-set ban marker could not be cleared; the live account stays locked out until the marker expires or an operator restores its status", - ) - }) - throw err - } + await eraseAccount({ users: store, sessions }, userId, request.log) clearSessionCookie(reply) clearCsrfCookie(reply) - const cleanups: ReadonlyArray Promise]> = [ - ["sessions.ban", () => sessions.banUser(userId)], - ["oauth.unlink", () => oauth.unlinkAllForUser(userId)], + await runPostDeletionCleanups( [ - "audit.account-deleted", - () => - writeAudit(container.getDb().sql, { - actorId: userId, - action: "account.deleted", - target: `user:${userId}`, - }), + ["sessions.ban", () => sessions.banUser(userId)], + ["oauth.unlink", () => oauth.unlinkAllForUser(userId)], + [ + "audit.account-deleted", + () => + writeAudit(container.getDb().sql, { + actorId: userId, + action: ACCOUNT_DELETED_AUDIT_ACTION, + target: `user:${userId}`, + }), + ], ], - ] - const outcomes = await Promise.allSettled(cleanups.map(async ([, run]) => run())) - outcomes.forEach((outcome, i) => { - if (outcome.status === "rejected") { - request.log.error( - { err: outcome.reason, userId, step: cleanups[i]![0] }, - "account deletion: post-commit cleanup step failed (the account IS deleted, its session rows went with the erasure and the ban marker set before it still rejects cached sessions)", - ) - } - }) + userId, + request.log, + ) const payload: DeleteAccountResponse = { ok: true } reply.status(200).send(payload) }, @@ -272,8 +249,7 @@ export async function registerUsersRoutes( { preHandler: csrfProtect, config: { rateLimit: DATA_EXPORT_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - const store = app.authServices?.users - if (!store) throw AppError.unauthorized("Authentication required.") + const store = usersStoreOf(app) const me = await store.findById(userId) const email = me?.email ?? null if (email === null) { @@ -299,10 +275,68 @@ async function assertUserBlockable( viewerId: string, userId: string, ): Promise { - const store = app.authServices?.users - if (!store) throw AppError.unauthorized("Authentication required.") - const u = await store.findById(userId) + const u = await usersStoreOf(app).findById(userId) if (!u || u.deletedAt !== null) throw AppError.notFound(UNBLOCKABLE_MESSAGE) const { blockedByViewer, blockedByTarget } = await blocks.blockState(viewerId, userId) if (blockedByTarget && !blockedByViewer) throw AppError.notFound(UNBLOCKABLE_MESSAGE) } + +function usersStoreOf(app: FastifyInstance): AuthServices["users"] { + const store = app.authServices?.users + if (!store) throw AppError.unauthorized(AUTH_REQUIRED_MESSAGE) + return store +} + +function withoutMentionPrefix(q: string): string { + return q.startsWith("@") ? q.slice(1) : q +} + +// A cached session projection is checked against the ban marker and the epoch, never the sessions +// table, so deleting the rows alone would leave every cached bearer working, and sliding forward, up +// to the absolute session cap. The marker goes up before anything is erased: if it cannot be written +// the deletion is refused, and if the erasure then fails the marker comes down again so the account +// keeps working. +async function eraseAccount( + services: Pick, + userId: string, + log: FastifyBaseLogger, +): Promise { + const { users, sessions } = services + try { + await sessions.markBanned(userId) + } catch (err) { + throw exposeMessage( + new AppError(ErrorCode.INTERNAL, ACCOUNT_DELETION_UNAVAILABLE_MESSAGE, { + httpStatus: 503, + cause: err, + }), + ) + } + try { + await users.softDeleteAndAnonymize(userId) + } catch (err) { + await sessions.clearBan(userId).catch((clearErr: unknown) => { + log.error( + { err: clearErr, userId }, + "account deletion: erasure failed and the pre-set ban marker could not be cleared; the live account stays locked out until the marker expires or an operator restores its status", + ) + }) + throw err + } +} + +async function runPostDeletionCleanups( + cleanups: readonly PostDeletionCleanup[], + userId: string, + log: FastifyBaseLogger, +): Promise { + const outcomes = await Promise.allSettled(cleanups.map(async ([, run]) => run())) + outcomes.forEach((outcome, i) => { + if (outcome.status === "rejected") { + log.error( + { err: outcome.reason, userId, step: cleanups[i]![0] }, + "account deletion: post-commit cleanup step failed (the account IS deleted, its session rows went with the erasure and the ban marker set before it still rejects cached sessions)", + ) + } + }) +} diff --git a/services/api/src/services/discussion-mentions.ts b/services/api/src/services/discussion-mentions.ts index 525fc0e6..46fedec9 100644 --- a/services/api/src/services/discussion-mentions.ts +++ b/services/api/src/services/discussion-mentions.ts @@ -1,11 +1,16 @@ +const MUNICIPALITY_PREFIX_RE = /^(city|town|village|county|borough|township|municipality)\s+of\s+/i +const MUNICIPALITY_SUFFIX_RE = /\s+(city|town|village|county|borough|township)$/i +const NON_HANDLE_RUN_RE = /[^a-z0-9]+/g +const EDGE_UNDERSCORES_RE = /^_+|_+$/g + export function jurisdictionHandle(name: string | null | undefined): string | null { if (name === null || name === undefined) return null let s = name.toLowerCase().trim() if (s === "") return null - s = s.replace(/^(city|town|village|county|borough|township|municipality)\s+of\s+/i, "") - s = s.replace(/\s+(city|town|village|county|borough|township)$/i, "") - s = s.replace(/[^a-z0-9]+/g, "_") - s = s.replace(/^_+|_+$/g, "") + s = s.replace(MUNICIPALITY_PREFIX_RE, "") + s = s.replace(MUNICIPALITY_SUFFIX_RE, "") + s = s.replace(NON_HANDLE_RUN_RE, "_") + s = s.replace(EDGE_UNDERSCORES_RE, "") return s === "" ? null : s } diff --git a/services/api/src/services/feed-presence.ts b/services/api/src/services/feed-presence.ts index fde27ccf..aba6c4c0 100644 --- a/services/api/src/services/feed-presence.ts +++ b/services/api/src/services/feed-presence.ts @@ -37,16 +37,20 @@ export interface FeedPresence { viewersOf(postId: string): Promise } +const SNAPSHOT_KEY_PREFIX = "feed:rank:v1:" +const SERVED_KEY_PREFIX = "feed:served:v1:" +const VIEWERS_KEY_PREFIX = "feed:viewers:v1:" + export function snapshotKey(userId: string, filter: string): string { - return `feed:rank:v1:${userId}:${filter}` + return `${SNAPSHOT_KEY_PREFIX}${userId}:${filter}` } export function servedKey(userId: string): string { - return `feed:served:v1:${userId}` + return `${SERVED_KEY_PREFIX}${userId}` } export function viewersKey(postId: string): string { - return `feed:viewers:v1:${postId}` + return `${VIEWERS_KEY_PREFIX}${postId}` } function isSnapshotEntry(value: unknown): value is [string, number] { diff --git a/services/api/src/services/feed-ranking.ts b/services/api/src/services/feed-ranking.ts index dd3875db..59903407 100644 --- a/services/api/src/services/feed-ranking.ts +++ b/services/api/src/services/feed-ranking.ts @@ -25,9 +25,10 @@ export interface RankedCandidate { } const MS_PER_HOUR = 3_600_000 +const MS_PER_SECOND = 1000 export function quantizeClock(nowMs: number, clockBucketSeconds: number): number { - const bucketMs = Math.max(1, Math.trunc(clockBucketSeconds)) * 1000 + const bucketMs = Math.max(1, Math.trunc(clockBucketSeconds)) * MS_PER_SECOND return Math.floor(nowMs / bucketMs) * bucketMs } @@ -91,6 +92,8 @@ export function rawScore(candidate: FeedCandidate, cfg: FeedRankingConfig): numb const FNV_OFFSET_BASIS = 2_166_136_261 const FNV_PRIME = 16_777_619 const UINT32_SPAN = 4_294_967_296 +const MURMUR3_FMIX_C1 = 2_246_822_507 +const MURMUR3_FMIX_C2 = 3_266_489_909 function fnv1a32(text: string): number { let hash = FNV_OFFSET_BASIS @@ -104,9 +107,9 @@ function fnv1a32(text: string): number { function avalanche(value: number): number { let hash = value >>> 0 hash ^= hash >>> 16 - hash = Math.imul(hash, 2_246_822_507) + hash = Math.imul(hash, MURMUR3_FMIX_C1) hash ^= hash >>> 13 - hash = Math.imul(hash, 3_266_489_909) + hash = Math.imul(hash, MURMUR3_FMIX_C2) hash ^= hash >>> 16 return hash >>> 0 } diff --git a/services/api/src/services/legal-service.ts b/services/api/src/services/legal-service.ts index 78ab5c86..e4048e75 100644 --- a/services/api/src/services/legal-service.ts +++ b/services/api/src/services/legal-service.ts @@ -4,22 +4,14 @@ import { type LegalDocumentType, type LegalDocumentVersionDTO, } from "@civfix/shared" -import { LEGAL_DOCUMENTS, legalDocument } from "@civfix/shared/legal" +import { LEGAL_DOCUMENTS, legalDocument, type LegalDocumentVersion } from "@civfix/shared/legal" export const LEGAL_VERSIONS_CACHE_SECONDS = 300 -export function legalDocumentVersions(): LegalDocumentVersionDTO[] { - return LEGAL_DOCUMENTS.map((document) => ({ - type: document.type, - version: document.version, - sha256: document.sha256, - effectiveAt: document.effectiveAt, - url: document.url, - })) -} +const STALE_CONSENT_MESSAGE = + "The terms shown to you have been updated. Reload the page and review them before continuing." -export function currentLegalDocument(type: LegalDocumentType): LegalDocumentVersionDTO { - const document = legalDocument(type) +function toVersionDTO(document: LegalDocumentVersion): LegalDocumentVersionDTO { return { type: document.type, version: document.version, @@ -29,6 +21,14 @@ export function currentLegalDocument(type: LegalDocumentType): LegalDocumentVers } } +export function legalDocumentVersions(): LegalDocumentVersionDTO[] { + return LEGAL_DOCUMENTS.map(toVersionDTO) +} + +export function currentLegalDocument(type: LegalDocumentType): LegalDocumentVersionDTO { + return toVersionDTO(legalDocument(type)) +} + export interface ConsentVersionClaim { type: LegalDocumentType version: string @@ -43,23 +43,6 @@ export function assertConsentVersionsCurrent(claims: readonly ConsentVersionClai } } if (Object.keys(stale).length > 0) { - throw new AppError( - ErrorCode.CONFLICT, - "The terms shown to you have been updated. Reload the page and review them before continuing.", - { fields: stale }, - ) + throw new AppError(ErrorCode.CONFLICT, STALE_CONSENT_MESSAGE, { fields: stale }) } } - -export function consentDocumentsFor( - claims: readonly ConsentVersionClaim[], -): { documentType: LegalDocumentType; documentVersion: string; documentSha256: string }[] { - return claims.map((claim) => { - const document = legalDocument(claim.type) - return { - documentType: document.type, - documentVersion: document.version, - documentSha256: document.sha256, - } - }) -} diff --git a/services/api/src/services/notification-helpers.ts b/services/api/src/services/notification-helpers.ts index 25226a16..822739d2 100644 --- a/services/api/src/services/notification-helpers.ts +++ b/services/api/src/services/notification-helpers.ts @@ -25,13 +25,18 @@ export function isFeedVisibleType(type: NotificationType): boolean { return !FEED_HIDDEN_NOTIFICATION_TYPES.includes(type) } +const TIME_OF_DAY_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/ +const MINUTES_PER_HOUR = 60 +const MAX_HOUR = 23 +const MAX_MINUTE = 59 + export function parseTimeOfDayMinutes(value: string): number | null { - const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(value.trim()) + const m = TIME_OF_DAY_RE.exec(value.trim()) if (!m) return null const hours = Number(m[1]) const mins = Number(m[2]) - if (hours > 23 || mins > 59) return null - return hours * 60 + mins + if (hours > MAX_HOUR || mins > MAX_MINUTE) return null + return hours * MINUTES_PER_HOUR + mins } function minutesInZone(now: Date, tz: string): number | null { @@ -46,10 +51,11 @@ function minutesInZone(now: Date, tz: string): number | null { const mm = parts.find((p) => p.type === "minute")?.value if (hh === undefined || mm === undefined) return null let h = Number(hh) + // Some Intl implementations render midnight as hour 24 under hour12: false. if (h === 24) h = 0 const m = Number(mm) if (!Number.isInteger(h) || !Number.isInteger(m)) return null - return h * 60 + m + return h * MINUTES_PER_HOUR + m } catch { // An unknown zone cannot place "now" in the user's day; failing open delivers the push rather // than silencing the user around the clock. diff --git a/services/api/src/services/notification-repository.drizzle.ts b/services/api/src/services/notification-repository.drizzle.ts index ed7e0205..753495b7 100644 --- a/services/api/src/services/notification-repository.drizzle.ts +++ b/services/api/src/services/notification-repository.drizzle.ts @@ -6,6 +6,7 @@ import { parseKeysetCursor, } from "../db/cursor-helpers.js" import type { + CoalescedNotificationArgs, NewNotificationArgs, NotificationPrefsPatch, NotificationPrefsRecord, @@ -19,6 +20,8 @@ import type { NotificationType, PushPlatform } from "@civfix/shared" const QUIET_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/ +const QUIET_HOURS_FORMAT_MESSAGE = "quietHours must be HH:MM (24-hour)" + export const MAX_ACTIVE_PUSH_TOKENS_PER_USER = 10 interface NotificationRowSelect { @@ -73,16 +76,10 @@ function toPrefsRecord(r: PrefsRowSelect): NotificationPrefsRecord { } } -export interface RefreshUnreadArgs { - userId: string - type: NotificationType - link: string - title: string - body: string | null - since: Date -} - -function refreshUnreadWith(exec: Sql, args: RefreshUnreadArgs): Promise { +function refreshUnreadWith( + exec: Sql, + args: CoalescedNotificationArgs, +): Promise { return exec` UPDATE notifications SET title = ${args.title}, body = ${args.body} @@ -228,41 +225,11 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit return existing[0] ? toPrefsRecord(existing[0]) : DEFAULT_PREFS } - const setFragments: Array> = [] - if (patch.push !== undefined) setFragments.push(sql`push = ${patch.push}`) - if (patch.cleanupChat !== undefined) - setFragments.push(sql`cleanup_chat = ${patch.cleanupChat}`) - if (patch.reportUpdates !== undefined) - setFragments.push(sql`report_updates = ${patch.reportUpdates}`) - if (patch.follows !== undefined) setFragments.push(sql`follows = ${patch.follows}`) - if (patch.mentions !== undefined) setFragments.push(sql`mentions = ${patch.mentions}`) - if (patch.postInteractions !== undefined) - setFragments.push(sql`post_interactions = ${patch.postInteractions}`) - if (patch.hostBroadcasts !== undefined) - setFragments.push(sql`host_broadcasts = ${patch.hostBroadcasts}`) - if (patch.quietHours !== undefined) { - const start = patch.quietHours === null ? null : patch.quietHours.start - const end = patch.quietHours === null ? null : patch.quietHours.end - if ( - (start !== null && !QUIET_TIME_RE.test(start)) || - (end !== null && !QUIET_TIME_RE.test(end)) - ) { - throw AppError.validation({ quietHours: "invalid" }, "quietHours must be HH:MM (24-hour)") - } - const tz = patch.quietHours === null ? null : (patch.quietHours.tz ?? null) - setFragments.push(sql`quiet_start = ${start}::time`) - setFragments.push(sql`quiet_end = ${end}::time`) - setFragments.push(sql`tz = ${tz}`) - } - - const insStart = - patch.quietHours !== undefined && patch.quietHours !== null ? patch.quietHours.start : null - const insEnd = - patch.quietHours !== undefined && patch.quietHours !== null ? patch.quietHours.end : null - const insTz = - patch.quietHours !== undefined && patch.quietHours !== null - ? (patch.quietHours.tz ?? null) - : null + const setFragments = prefsSetFragments(sql, patch) + const quiet = patch.quietHours ?? null + const insStart = quiet === null ? null : quiet.start + const insEnd = quiet === null ? null : quiet.end + const insTz = quiet === null ? null : (quiet.tz ?? null) const rows = await sql` INSERT INTO notification_prefs ( @@ -366,12 +333,12 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit }) as Promise<{ record: NotificationRecord; deduped: boolean }> }, - refreshUnreadNotification(args: RefreshUnreadArgs): Promise { + refreshUnreadNotification(args: CoalescedNotificationArgs): Promise { return refreshUnreadWith(sql, args) }, async upsertCoalescedNotification( - args: RefreshUnreadArgs, + args: CoalescedNotificationArgs, ): Promise<{ record: NotificationRecord; coalesced: boolean }> { return sql.begin(async (tx) => { // FOR UPDATE in the refresh locks nothing when no unread row exists yet, so two concurrent @@ -417,6 +384,35 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit } } +function prefsSetFragments(sql: Sql, patch: NotificationPrefsPatch): Array> { + const setFragments: Array> = [] + if (patch.push !== undefined) setFragments.push(sql`push = ${patch.push}`) + if (patch.cleanupChat !== undefined) setFragments.push(sql`cleanup_chat = ${patch.cleanupChat}`) + if (patch.reportUpdates !== undefined) + setFragments.push(sql`report_updates = ${patch.reportUpdates}`) + if (patch.follows !== undefined) setFragments.push(sql`follows = ${patch.follows}`) + if (patch.mentions !== undefined) setFragments.push(sql`mentions = ${patch.mentions}`) + if (patch.postInteractions !== undefined) + setFragments.push(sql`post_interactions = ${patch.postInteractions}`) + if (patch.hostBroadcasts !== undefined) + setFragments.push(sql`host_broadcasts = ${patch.hostBroadcasts}`) + if (patch.quietHours !== undefined) { + const start = patch.quietHours === null ? null : patch.quietHours.start + const end = patch.quietHours === null ? null : patch.quietHours.end + if ( + (start !== null && !QUIET_TIME_RE.test(start)) || + (end !== null && !QUIET_TIME_RE.test(end)) + ) { + throw AppError.validation({ quietHours: "invalid" }, QUIET_HOURS_FORMAT_MESSAGE) + } + const tz = patch.quietHours === null ? null : (patch.quietHours.tz ?? null) + setFragments.push(sql`quiet_start = ${start}::time`) + setFragments.push(sql`quiet_end = ${end}::time`) + setFragments.push(sql`tz = ${tz}`) + } + return setFragments +} + function isEmptyPatch(patch: NotificationPrefsPatch): boolean { return ( patch.push === undefined && diff --git a/services/api/src/services/notification-service.ts b/services/api/src/services/notification-service.ts index ff6a65ea..e82847f3 100644 --- a/services/api/src/services/notification-service.ts +++ b/services/api/src/services/notification-service.ts @@ -26,6 +26,7 @@ import { mapWithLimit } from "./media-presign.js" import { classifyPushToken, isRegistrablePushEndpoint } from "./push-token-policy.js" import { renderMessage, type MessageKey, type MessageVars } from "../i18n/renderMessage.js" import { DEFAULT_LOCALE } from "../i18n/locales.js" +import { actorDisplayName } from "./public-author.js" export { DEFAULT_PREFS, @@ -40,14 +41,26 @@ export { } from "./notification-helpers.js" export type { PushGateMode } from "./notification-helpers.js" -export const NOTIFICATIONS_DEFAULT_LIMIT = 20 +const NOTIFICATIONS_DEFAULT_LIMIT = 20 export const NOTIFICATION_DEDUPE_WINDOW_MS = 5 * 60 * 1000 -export const BULK_NOTIFY_CONCURRENCY = 8 +const BULK_NOTIFY_CONCURRENCY = 8 export const PUSH_FANOUT_BATCH_SIZE = 100 +const NOTIFICATIONS_TOPIC = "notifications" + +const INVALID_PUSH_TOKEN_MESSAGE = "Invalid push token" + +function postLink(postId: string): string { + return `/post/${postId}` +} + +function personLink(userId: string): string { + return `/people/${userId}` +} + export interface NotificationRecord { id: string userId: string @@ -91,6 +104,15 @@ export interface NotificationPrefsPatch { quietHours?: QuietHours | null } +export interface CoalescedNotificationArgs { + userId: string + type: NotificationType + link: string + title: string + body: string | null + since: Date +} + export interface NotificationRepository { insertNotification(args: NewNotificationArgs): Promise @@ -108,23 +130,11 @@ export interface NotificationRepository { args: NewNotificationArgs & { since: Date }, ): Promise<{ record: NotificationRecord; deduped: boolean }> - refreshUnreadNotification(args: { - userId: string - type: NotificationType - link: string - title: string - body: string | null - since: Date - }): Promise - - upsertCoalescedNotification(args: { - userId: string - type: NotificationType - link: string - title: string - body: string | null - since: Date - }): Promise<{ record: NotificationRecord; coalesced: boolean }> + refreshUnreadNotification(args: CoalescedNotificationArgs): Promise + + upsertCoalescedNotification( + args: CoalescedNotificationArgs, + ): Promise<{ record: NotificationRecord; coalesced: boolean }> deleteAllNotificationsForUser(userId: string): Promise @@ -177,12 +187,18 @@ export interface NotificationServiceDeps { isSafePushEndpoint?: (endpoint: string) => Promise } +export interface PostActivity { + recipientId: string + actorName: string + postId: string +} + export interface PostNotifier { - onPostLike(a: { recipientId: string; actorName: string; postId: string }): Promise - onPostRepost(a: { recipientId: string; actorName: string; postId: string }): Promise - onPostReply(a: { recipientId: string; actorName: string; postId: string }): Promise - onPostQuote(a: { recipientId: string; actorName: string; postId: string }): Promise - onPostMention(a: { recipientId: string; actorName: string; postId: string }): Promise + onPostLike(a: PostActivity): Promise + onPostRepost(a: PostActivity): Promise + onPostReply(a: PostActivity): Promise + onPostQuote(a: PostActivity): Promise + onPostMention(a: PostActivity): Promise } export interface FanOutNotificationResult { @@ -219,6 +235,86 @@ interface ResolvedPrefs { unreadable: Set } +type PostActivityType = "post_like" | "post_repost" | "post_reply" | "post_quote" | "post_mention" + +interface PostActivitySpec { + titleKey: MessageKey + bodyKey: MessageKey + deduped: boolean +} + +// Likes and reposts can be toggled repeatedly, so they are deduped; a reply, quote or mention is new +// content every time. +const POST_ACTIVITY_SPECS: Record = { + post_like: { + titleKey: "notification.post.like.title", + bodyKey: "notification.post.like.body", + deduped: true, + }, + post_repost: { + titleKey: "notification.post.repost.title", + bodyKey: "notification.post.repost.body", + deduped: true, + }, + post_reply: { + titleKey: "notification.post.reply.title", + bodyKey: "notification.post.reply.body", + deduped: false, + }, + post_quote: { + titleKey: "notification.post.quote.title", + bodyKey: "notification.post.quote.body", + deduped: false, + }, + post_mention: { + titleKey: "notification.post.mention.title", + bodyKey: "notification.post.mention.body", + deduped: false, + }, +} + +interface CreatedNotification { + userId: string + record: NotificationRecord +} + +interface PushGroup { + payload: PushPayload + userIds: string[] +} + +function groupPushesByPayload( + created: readonly CreatedNotification[], + { byUser, unreadable }: ResolvedPrefs, + mode: PushGateMode, + at: Date, +): Map { + const groups = new Map() + for (const { userId, record } of created) { + if (unreadable.has(userId)) continue + const prefs = byUser.get(userId) ?? DEFAULT_PREFS + if (!pushGateAllows(record.type, prefs, mode)) continue + if (isWithinQuietHours(at, prefs.quietStart, prefs.quietEnd, prefs.tz)) continue + const key = JSON.stringify([record.title, record.body, record.link]) + const group = groups.get(key) + if (group) { + group.userIds.push(userId) + continue + } + groups.set(key, { payload: pushPayloadOf(record, { type: record.type }), userIds: [userId] }) + } + return groups +} + +function pushPayloadOf(record: NotificationRecord, data: Record): PushPayload { + return { + title: record.title, + ...(record.body !== null ? { body: record.body } : {}), + ...(record.link !== null ? { link: record.link } : {}), + data, + } +} + export function makeNotificationService(deps: NotificationServiceDeps): NotificationService { const now = deps.now ?? (() => new Date()) const isSafeEndpoint = deps.isSafePushEndpoint ?? isRegistrablePushEndpoint @@ -240,12 +336,7 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica if (!pushGateAllows(record.type, prefs, mode)) return if (isWithinQuietHours(now(), prefs.quietStart, prefs.quietEnd, prefs.tz)) return - const payload: PushPayload = { - title: record.title, - ...(record.body !== null ? { body: record.body } : {}), - ...(record.link !== null ? { link: record.link } : {}), - data: { type: record.type, notificationId: record.id }, - } + const payload = pushPayloadOf(record, { type: record.type, notificationId: record.id }) await deps.pushSender.send(userId, payload) } catch (err) { deps.logger?.warn( @@ -258,7 +349,7 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica async function maybeSignalNotification(userId: string): Promise { if (!deps.userChannel) return try { - await deps.userChannel.publishToUser(userId, { topic: "notifications" }) + await deps.userChannel.publishToUser(userId, { topic: NOTIFICATIONS_TOPIC }) } catch (err) { deps.logger?.warn({ err, userId }, "notification signal publish failed (suppressed)") } @@ -395,6 +486,18 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica return toNotificationDTO(record) } + async function notifyPostActivity(type: PostActivityType, a: PostActivity): Promise { + const spec = POST_ACTIVITY_SPECS[type] + await doCreateNotification(a.recipientId, { + type, + titleKey: spec.titleKey, + bodyKey: spec.bodyKey, + vars: { name: a.actorName }, + link: postLink(a.postId), + ...(spec.deduped ? { dedupeWindowMs: NOTIFICATION_DEDUPE_WINDOW_MS } : {}), + }) + } + async function prefsForMany(userIds: string[]): Promise { if (deps.repo.findPrefsMany) { try { @@ -432,34 +535,12 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica } async function sendBatchedPush( - created: Array<{ userId: string; record: NotificationRecord }>, + created: CreatedNotification[], mode: PushGateMode, ): Promise { if (mode === "never") return - const { byUser, unreadable } = await prefsForMany(created.map((c) => c.userId)) - const at = now() - const groups = new Map() - for (const { userId, record } of created) { - if (unreadable.has(userId)) continue - const prefs = byUser.get(userId) ?? DEFAULT_PREFS - if (!pushGateAllows(record.type, prefs, mode)) continue - if (isWithinQuietHours(at, prefs.quietStart, prefs.quietEnd, prefs.tz)) continue - const key = JSON.stringify([record.title, record.body, record.link]) - const group = groups.get(key) - if (group) { - group.userIds.push(userId) - continue - } - groups.set(key, { - payload: { - title: record.title, - ...(record.body !== null ? { body: record.body } : {}), - ...(record.link !== null ? { link: record.link } : {}), - data: { type: record.type }, - }, - userIds: [userId], - }) - } + const prefs = await prefsForMany(created.map((c) => c.userId)) + const groups = groupPushesByPayload(created, prefs, mode, now()) for (const group of groups.values()) { for (let i = 0; i < group.userIds.length; i += PUSH_FANOUT_BATCH_SIZE) { const batch = group.userIds.slice(i, i + PUSH_FANOUT_BATCH_SIZE) @@ -475,7 +556,7 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica async function signalMany(userIds: string[]): Promise { if (!deps.userChannel) return try { - await deps.userChannel.publishToUsers(userIds, { topic: "notifications" }) + await deps.userChannel.publishToUsers(userIds, { topic: NOTIFICATIONS_TOPIC }) } catch (err) { deps.logger?.warn( { err, count: userIds.length }, @@ -484,6 +565,21 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica } } + function logFanOutFallbacks( + fallbacks: ReadonlyMap, + input: CreateNotificationInput, + recipients: number, + ): void { + for (const [lane, { count, err }] of fallbacks) { + deps.logger?.warn( + { err, type: input.type, lane, fallbacks: count, recipients }, + lane === "coalesce" + ? "fan-out notification coalesce upsert failed; creating anyway" + : "fan-out notification dedupe insert failed; creating anyway", + ) + } + } + async function doCreateNotifications( userIds: string[], input: CreateNotificationInput, @@ -511,23 +607,14 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica return null } }) - for (const [lane, { count, err }] of fallbacks) { - deps.logger?.warn( - { err, type: input.type, lane, fallbacks: count, recipients: unique.length }, - lane === "coalesce" - ? "fan-out notification coalesce upsert failed; creating anyway" - : "fan-out notification dedupe insert failed; creating anyway", - ) - } + logFanOutFallbacks(fallbacks, input, unique.length) if (failed.length > 0) { deps.logger?.warn( { err: firstFailure, type: input.type, failed: failed.length, recipients: unique.length }, "fan-out notification insert failed; reported to the caller", ) } - const created = persisted.filter( - (p): p is { userId: string; record: NotificationRecord } => p !== null, - ) + const created = persisted.filter((p): p is CreatedNotification => p !== null) if (created.length === 0) return { failed } void sendBatchedPush(created, input.push ?? "auto").catch((err: unknown) => { deps.logger?.error( @@ -584,12 +671,12 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica async registerPushToken(userId: string, req: RegisterPushTokenRequest): Promise<{ ok: true }> { const shape = classifyPushToken(req.platform, req.token) if (!shape.ok) { - throw AppError.validation({ [shape.field]: shape.reason }, "Invalid push token") + throw AppError.validation({ [shape.field]: shape.reason }, INVALID_PUSH_TOKEN_MESSAGE) } if (shape.kind === "web" && (await isSafeEndpoint(shape.endpoint)) === false) { throw AppError.validation( { token: "subscription endpoint must be a public https push service" }, - "Invalid push token", + INVALID_PUSH_TOKEN_MESSAGE, ) } const outcome = await deps.repo.upsertPushToken({ @@ -647,88 +734,20 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica }, async onNewFollower(args: { followeeId: string; follower: PersonView }): Promise { - const name = - args.follower.displayName.trim() !== "" - ? args.follower.displayName - : args.follower.handle - ? `@${args.follower.handle}` - : "Someone" await doCreateNotification(args.followeeId, { type: "new_follower", titleKey: "notification.follower.title", bodyKey: "notification.follower.body", - vars: { name }, - link: `/people/${args.follower.id}`, - dedupeWindowMs: NOTIFICATION_DEDUPE_WINDOW_MS, - }) - }, - - async onPostLike(a: { recipientId: string; actorName: string; postId: string }): Promise { - await doCreateNotification(a.recipientId, { - type: "post_like", - titleKey: "notification.post.like.title", - bodyKey: "notification.post.like.body", - vars: { name: a.actorName }, - link: `/post/${a.postId}`, + vars: { name: actorDisplayName(args.follower.displayName, args.follower.handle) }, + link: personLink(args.follower.id), dedupeWindowMs: NOTIFICATION_DEDUPE_WINDOW_MS, }) }, - async onPostRepost(a: { - recipientId: string - actorName: string - postId: string - }): Promise { - await doCreateNotification(a.recipientId, { - type: "post_repost", - titleKey: "notification.post.repost.title", - bodyKey: "notification.post.repost.body", - vars: { name: a.actorName }, - link: `/post/${a.postId}`, - dedupeWindowMs: NOTIFICATION_DEDUPE_WINDOW_MS, - }) - }, - - async onPostReply(a: { - recipientId: string - actorName: string - postId: string - }): Promise { - await doCreateNotification(a.recipientId, { - type: "post_reply", - titleKey: "notification.post.reply.title", - bodyKey: "notification.post.reply.body", - vars: { name: a.actorName }, - link: `/post/${a.postId}`, - }) - }, - - async onPostQuote(a: { - recipientId: string - actorName: string - postId: string - }): Promise { - await doCreateNotification(a.recipientId, { - type: "post_quote", - titleKey: "notification.post.quote.title", - bodyKey: "notification.post.quote.body", - vars: { name: a.actorName }, - link: `/post/${a.postId}`, - }) - }, - - async onPostMention(a: { - recipientId: string - actorName: string - postId: string - }): Promise { - await doCreateNotification(a.recipientId, { - type: "post_mention", - titleKey: "notification.post.mention.title", - bodyKey: "notification.post.mention.body", - vars: { name: a.actorName }, - link: `/post/${a.postId}`, - }) - }, + onPostLike: (a: PostActivity) => notifyPostActivity("post_like", a), + onPostRepost: (a: PostActivity) => notifyPostActivity("post_repost", a), + onPostReply: (a: PostActivity) => notifyPostActivity("post_reply", a), + onPostQuote: (a: PostActivity) => notifyPostActivity("post_quote", a), + onPostMention: (a: PostActivity) => notifyPostActivity("post_mention", a), } } diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index 86771642..02e7d58f 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -24,7 +24,7 @@ import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" import { uploadersOf } from "./media-uploader.js" import { publicServedKeyExpr } from "./media-served-key.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" -import { publicAuthorIdentity } from "./public-author.js" +import { actorDisplayName, FALLBACK_ACTOR_NAME, publicAuthorIdentity } from "./public-author.js" import { presentIds } from "./present-ids.js" import { NO_AFFILIATIONS, @@ -43,6 +43,13 @@ export const NIL_VIEWER_ID = "00000000-0000-0000-0000-000000000000" export const FEED_NEARBY_INDEX = "posts_geom_gist" +const POST_NOT_FOUND_MESSAGE = "Post not found" + +const POST_EXCERPT_MAX_CHARS = 140 +const SHARED_EVENT_EXCERPT = "Shared an event" +const SHARED_REPORT_EXCERPT = "Shared a report" +const UNTITLED_REPORT_TITLE = "Report" + function postColumns(sql: Queryable): postgres.Fragment { return sql` p.id, p.author_id, p.kind, p.body, p.reply_to_id, p.thread_root_id, p.repost_of_id, @@ -116,8 +123,10 @@ const KM_PER_DEGREE_LAT = 111.32 const MIN_LATITUDE_COSINE = 0.25 +const MAX_RADIUS_DEGREES = 90 + export function nearbyRadiusDegrees(radiusKm: number): number { - return Math.min(90, radiusKm / KM_PER_DEGREE_LAT / MIN_LATITUDE_COSINE) + return Math.min(MAX_RADIUS_DEGREES, radiusKm / KM_PER_DEGREE_LAT / MIN_LATITUDE_COSINE) } export interface FeedCandidateArgs { @@ -284,6 +293,24 @@ interface PostCounts { saves: number } +interface PostCountColumns { + like_count: number + repost_count: number + reply_count: number + save_count: number +} + +function countsOf(row: PostCountColumns): PostCounts { + return { + likes: Number(row.like_count), + reposts: Number(row.repost_count), + replies: Number(row.reply_count), + saves: Number(row.save_count), + } +} + +const NO_COUNTS: Readonly = { likes: 0, reposts: 0, replies: 0, saves: 0 } + interface RefLink { eventId: string | null reportId: string | null @@ -302,22 +329,118 @@ interface MediaRow { height: number | null } +type LinkedEvent = Omit +type LinkedReport = Omit + +interface LoadedRefs { + refs: Map + counts: Map + links: Map + orgIds: Map + authorIds: string[] +} + +interface ViewerFlags { + liked: Set + saved: Set + repostedTargets: Set +} + +interface RefLookups { + refMedia: Map + refAffiliations: PrimaryAffiliations + organizations: Map + events: Map + reports: Map +} + +interface PostLookups { + authors: Map + media: Map + mentions: Map + loadedRefs: LoadedRefs + flags: ViewerFlags + events: Map + reports: Map + organizations: Map +} + +function refAuthorOf(r: RefRow): PersonDTO | null { + if (r.author_id === null) return null + return { + id: r.author_id, + name: r.display_name ?? "", + handle: r.handle, + bio: r.bio, + avatar: avatarGradient(r.author_id), + ...(r.avatar_url !== null ? { avatarUrl: r.avatar_url } : {}), + followers: 0, + following: 0, + isFollowing: false, + } +} + +function completeRefs(loaded: LoadedRefs, lookups: RefLookups): void { + for (const ref of loaded.refs.values()) { + if (ref.deleted) continue + ref.media = lookups.refMedia.get(ref.id) ?? [] + if (ref.author !== null) ref.author = withAffiliation(ref.author, lookups.refAffiliations) + ref.organization = organizationRefOf(lookups.organizations, loaded.orgIds.get(ref.id)) + const link = loaded.links.get(ref.id) + if (!link) continue + const refEvent = link.eventId !== null ? lookups.events.get(link.eventId) : undefined + const refReport = link.reportId !== null ? lookups.reports.get(link.reportId) : undefined + ref.event = refEvent ? { ...refEvent, linkedAt: link.linkedAt } : null + ref.report = refReport ? { ...refReport, linkedAt: link.linkedAt } : null + } +} + +function assemblePost(r: PostRowSelect, lookups: PostLookups): PostDTO | null { + const author = lookups.authors.get(r.author_id) + if (!author) return null + const { refs, counts: refCounts } = lookups.loadedRefs + const isRepost = r.kind === "repost" && r.repost_of_id !== null + const targetId = isRepost ? r.repost_of_id! : r.id + const editedAt = + r.updated_at.getTime() > r.created_at.getTime() ? r.updated_at.toISOString() : null + const eventBase = r.event_id !== null ? lookups.events.get(r.event_id) : undefined + const reportBase = r.report_id !== null ? lookups.reports.get(r.report_id) : undefined + const counts: PostCounts = isRepost ? (refCounts.get(targetId) ?? { ...NO_COUNTS }) : countsOf(r) + return { + id: r.id, + author, + organization: organizationRefOf(lookups.organizations, r.organization_id), + kind: r.kind, + body: r.body, + createdAt: r.created_at.toISOString(), + editedAt, + counts, + viewer: { + liked: lookups.flags.liked.has(targetId), + reposted: lookups.flags.repostedTargets.has(targetId), + saved: lookups.flags.saved.has(targetId), + }, + media: lookups.media.get(r.id) ?? [], + mentions: lookups.mentions.get(r.id) ?? [], + event: eventBase ? { ...eventBase, linkedAt: r.created_at.toISOString() } : null, + report: reportBase ? { ...reportBase, linkedAt: r.created_at.toISOString() } : null, + repostOf: r.repost_of_id !== null ? (refs.get(r.repost_of_id) ?? null) : null, + replyToId: r.reply_to_id, + replyTo: r.reply_to_id !== null ? (refs.get(r.reply_to_id) ?? null) : null, + threadRootId: r.thread_root_id, + } +} + export interface PostRepoDeps { presignMedia: PresignMedia presignAvatar: (r2Key: string) => Promise affiliations?: AffiliationLoader } -function nameFrom(displayName: string | null, handle: string | null): string { - if (displayName && displayName.trim() !== "") return displayName - if (handle) return `@${handle}` - return "Someone" -} - function excerptOf(body: string | null, hasEvent: boolean, hasReport: boolean): string { - if (body && body.trim() !== "") return body.slice(0, 140) - if (hasEvent) return "Shared an event" - if (hasReport) return "Shared a report" + if (body && body.trim() !== "") return body.slice(0, POST_EXCERPT_MAX_CHARS) + if (hasEvent) return SHARED_EVENT_EXCERPT + if (hasReport) return SHARED_REPORT_EXCERPT return "" } @@ -600,8 +723,8 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return out } - async function loadEvents(ids: string[]): Promise>> { - const out = new Map>() + async function loadEvents(ids: string[]): Promise> { + const out = new Map() if (ids.length === 0) return out const rows = await sql` SELECT @@ -656,10 +779,20 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return out } - async function loadReports( - ids: string[], - ): Promise>> { - const out = new Map>() + async function reportThumbUrl(r: ReportRow): Promise { + if (r.thumb_key !== null) { + const { thumbUrl, url } = await deps.presignMedia(r.thumb_r2_key ?? "", r.thumb_key) + return thumbUrl ?? url + } + if (r.thumb_r2_key !== null) { + const { url } = await deps.presignMedia(r.thumb_r2_key, null) + return url + } + return null + } + + async function loadReports(ids: string[]): Promise> { + const out = new Map() if (ids.length === 0) return out const rows = await sql` SELECT @@ -678,19 +811,12 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep WHERE r.id = ANY(${ids}::uuid[]) AND ${publicReportFilter(sql)} ` const resolved = await mapWithLimit(rows, PRESIGN_CONCURRENCY, async (r) => { - let thumbUrl: string | null = null - if (r.thumb_key !== null) { - const { thumbUrl: t, url } = await deps.presignMedia(r.thumb_r2_key ?? "", r.thumb_key) - thumbUrl = t ?? url - } else if (r.thumb_r2_key !== null) { - const { url } = await deps.presignMedia(r.thumb_r2_key, null) - thumbUrl = url - } - const ref: Omit = { + const thumbUrl = await reportThumbUrl(r) + const ref: LinkedReport = { id: r.id, category: r.category, ...(r.type !== null ? { type: r.type } : {}), - title: r.title ?? "Report", + title: r.title ?? UNTITLED_REPORT_TITLE, status: r.status, lat: r.lat, lng: r.lng, @@ -703,16 +829,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return out } - async function loadRefs( - ids: string[], - viewerId: string, - ): Promise<{ - refs: Map - counts: Map - links: Map - orgIds: Map - authorIds: string[] - }> { + async function loadRefs(ids: string[], viewerId: string): Promise { const out = new Map() const counts = new Map() const links = new Map() @@ -737,20 +854,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep ` for (const r of rows) { const deleted = r.deleted_at !== null || r.visibility !== "public" - const author: PersonDTO | null = - r.author_id !== null && !deleted - ? { - id: r.author_id, - name: r.display_name ?? "", - handle: r.handle, - bio: r.bio, - avatar: avatarGradient(r.author_id), - ...(r.avatar_url !== null ? { avatarUrl: r.avatar_url } : {}), - followers: 0, - following: 0, - isFollowing: false, - } - : null + const author = deleted ? null : refAuthorOf(r) if (author !== null) authorIds.push(author.id) if (!deleted && r.organization_id != null) orgIds.set(r.id, r.organization_id) out.set(r.id, { @@ -765,12 +869,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep event: null, report: null, }) - counts.set(r.id, { - likes: Number(r.like_count), - reposts: Number(r.repost_count), - replies: Number(r.reply_count), - saves: Number(r.save_count), - }) + counts.set(r.id, countsOf(r)) if (!deleted) { links.set(r.id, { eventId: r.event_id, @@ -813,10 +912,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return out } - async function loadViewerFlags( - rows: PostRowSelect[], - viewerId: string, - ): Promise<{ liked: Set; saved: Set; repostedTargets: Set }> { + async function loadViewerFlags(rows: PostRowSelect[], viewerId: string): Promise { const ids = rows.map((r) => r.id) const subjectIds = rows.map((r) => r.kind === "repost" && r.repost_of_id ? r.repost_of_id : r.id, @@ -849,18 +945,15 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep const postIds = rows.map((r) => r.id) const authorIds = [...new Set(rows.map((r) => r.author_id))] const refIds = presentIds(rows.flatMap((r) => [r.repost_of_id, r.reply_to_id])) - const [authors, media, mentions, refsResult, flags] = await Promise.all([ + const [authors, media, mentions, loadedRefs, flags] = await Promise.all([ loadAuthors(authorIds, viewerId), loadMedia(postIds), loadMentionsFor(sql, "post_mentions", postIds, "post_id"), loadRefs(refIds, viewerId), loadViewerFlags(rows, viewerId), ]) - const refs = refsResult.refs - const refCounts = refsResult.counts - const refLinks = refsResult.links - const linkValues = [...refLinks.values()] + const linkValues = [...loadedRefs.links.values()] const eventIds = presentIds([ ...rows.map((r) => r.event_id), ...linkValues.map((l) => l.eventId), @@ -869,75 +962,31 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep ...rows.map((r) => r.report_id), ...linkValues.map((l) => l.reportId), ]) - const readableRefIds = [...refs.values()].filter((r) => !r.deleted).map((r) => r.id) - const orgIds = [...rows.map((r) => r.organization_id), ...refsResult.orgIds.values()] + const readableRefIds = [...loadedRefs.refs.values()].filter((r) => !r.deleted).map((r) => r.id) + const orgIds = [...rows.map((r) => r.organization_id), ...loadedRefs.orgIds.values()] const [events, reports, refMedia, organizations, refAffiliations] = await Promise.all([ loadEvents(eventIds), loadReports(reportIds), loadMedia(readableRefIds), loadOrganizations(orgIds), - loadAffiliations(refsResult.authorIds, viewerId), + loadAffiliations(loadedRefs.authorIds, viewerId), ]) - for (const ref of refs.values()) { - if (ref.deleted) continue - ref.media = refMedia.get(ref.id) ?? [] - if (ref.author !== null) ref.author = withAffiliation(ref.author, refAffiliations) - ref.organization = organizationRefOf(organizations, refsResult.orgIds.get(ref.id)) - const link = refLinks.get(ref.id) - if (!link) continue - const refEvent = link.eventId !== null ? events.get(link.eventId) : undefined - const refReport = link.reportId !== null ? reports.get(link.reportId) : undefined - ref.event = refEvent ? { ...refEvent, linkedAt: link.linkedAt } : null - ref.report = refReport ? { ...refReport, linkedAt: link.linkedAt } : null - } - - const out: PostDTO[] = [] - for (const r of rows) { - const author = authors.get(r.author_id) - if (!author) continue - const isRepost = r.kind === "repost" && r.repost_of_id !== null - const targetId = isRepost ? r.repost_of_id! : r.id - const editedAt = - r.updated_at.getTime() > r.created_at.getTime() ? r.updated_at.toISOString() : null - const eventBase = r.event_id !== null ? events.get(r.event_id) : undefined - const reportBase = r.report_id !== null ? reports.get(r.report_id) : undefined - const repostOf = r.repost_of_id !== null ? (refs.get(r.repost_of_id) ?? null) : null - const postMentions: UserMentionDTO[] = mentions.get(r.id) ?? [] - const counts: PostCounts = isRepost - ? (refCounts.get(targetId) ?? { likes: 0, reposts: 0, replies: 0, saves: 0 }) - : { - likes: Number(r.like_count), - reposts: Number(r.repost_count), - replies: Number(r.reply_count), - saves: Number(r.save_count), - } - const dto: PostDTO = { - id: r.id, - author, - organization: organizationRefOf(organizations, r.organization_id), - kind: r.kind, - body: r.body, - createdAt: r.created_at.toISOString(), - editedAt, - counts, - viewer: { - liked: flags.liked.has(targetId), - reposted: flags.repostedTargets.has(targetId), - saved: flags.saved.has(targetId), - }, - media: media.get(r.id) ?? [], - mentions: postMentions, - event: eventBase ? { ...eventBase, linkedAt: r.created_at.toISOString() } : null, - report: reportBase ? { ...reportBase, linkedAt: r.created_at.toISOString() } : null, - repostOf, - replyToId: r.reply_to_id, - replyTo: r.reply_to_id !== null ? (refs.get(r.reply_to_id) ?? null) : null, - threadRootId: r.thread_root_id, - } - out.push(dto) + completeRefs(loadedRefs, { refMedia, refAffiliations, organizations, events, reports }) + const lookups: PostLookups = { + authors, + media, + mentions, + loadedRefs, + flags, + events, + reports, + organizations, } - return out + return rows.flatMap((r) => { + const dto = assemblePost(r, lookups) + return dto === null ? [] : [dto] + }) } async function latestAnswersByAuthor( @@ -976,7 +1025,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep async function resolveLiveTarget(tx: Queryable, postId: string): Promise { const targetId = await resolveOriginalTarget(tx, postId) - if (targetId === null) throw AppError.notFound("Post not found") + if (targetId === null) throw AppError.notFound(POST_NOT_FOUND_MESSAGE) return targetId } @@ -1014,7 +1063,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep SELECT display_name, handle FROM users WHERE id = ${userId} LIMIT 1 ` const r = rows[0] - return r ? nameFrom(r.display_name, r.handle) : "Someone" + return r ? actorDisplayName(r.display_name, r.handle) : FALLBACK_ACTOR_NAME }, async canPostAsOrganization(organizationId: string, userId: string): Promise { @@ -1121,7 +1170,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep WHERE id = ${replyToId} AND deleted_at IS NULL RETURNING id ` - if (bumped.length === 0) throw AppError.notFound("Post not found") + if (bumped.length === 0) throw AppError.notFound(POST_NOT_FOUND_MESSAGE) } return postId @@ -1285,12 +1334,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep cursor !== null ? sql`AND ${keysetPredicate(sql, sql`p.created_at`, sql`p.id`, cursor)}` : sql`` - const filterClause = - args.filter === "events" - ? sql`AND p.event_id IS NOT NULL` - : args.filter === "fixes" - ? sql`AND EXISTS (SELECT 1 FROM reports fr WHERE fr.id = p.report_id AND fr.status = 'resolved')` - : sql`` + const filterClause = feedFilterClause(sql, args.filter) const rows = await sql` SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`p.created_at`)} AS cursor_at FROM posts p @@ -1320,12 +1364,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep cursor !== null ? sql`AND ${keysetPredicate(sql, sql`p.created_at`, sql`p.id`, cursor)}` : sql`` - const filterClause = - args.filter === "events" - ? sql`AND p.event_id IS NOT NULL` - : args.filter === "fixes" - ? sql`AND EXISTS (SELECT 1 FROM reports fr WHERE fr.id = p.report_id AND fr.status = 'resolved')` - : sql`` + const filterClause = feedFilterClause(sql, args.filter) const rows = await sql` SELECT ${postColumns(sql)}, ${keysetInstant(sql, sql`p.created_at`)} AS cursor_at FROM posts p diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index 7bb9add7..a8bd2894 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -88,13 +88,24 @@ function isLegacyTimeCursor(cursor: string | null | undefined): boolean { return parseTimeCursor(cursor) !== null } -export const FEED_FANOUT_CONCURRENCY = 16 +const FEED_FANOUT_CONCURRENCY = 16 -export const FEED_DISTANCE_RESOLUTION_KM = 1 +const FEED_DISTANCE_RESOLUTION_KM = 1 -export const FEED_SEED_SPAN = 2 ** 31 +const FEED_SEED_SPAN = 2 ** 31 -export const PUBLIC_FEED_SEED_KEY = "public" +const PUBLIC_FEED_SEED_KEY = "public" + +const POST_NOT_FOUND_MESSAGE = "Post not found" + +type ComposeKind = "post" | "reply" | "quote" + +interface ComposeTargets { + kind: ComposeKind + replyParentId: string | null + replyParentAuthor: string | null + quoteTargetAuthor: string | null +} function coarseDistanceKm(distanceKm: number | null): number | null { if (distanceKm === null || !Number.isFinite(distanceKm)) return null @@ -403,7 +414,7 @@ export function makePostService(deps: PostServiceDeps): PostService { async function hydrateOrThrow(id: string, viewerId: string): Promise { const dto = await deps.repo.getPostDTO(id, viewerId) - if (!dto) throw AppError.notFound("Post not found") + if (!dto) throw AppError.notFound(POST_NOT_FOUND_MESSAGE) return dto } @@ -422,57 +433,95 @@ export function makePostService(deps: PostServiceDeps): PostService { async function requireReadable(id: string, viewerId: string): Promise { const subject = await readableSubject(id, viewerId) - if (subject === null) throw AppError.notFound("Post not found") + if (subject === null) throw AppError.notFound(POST_NOT_FOUND_MESSAGE) return subject } - return { - async createPost( - input: PostComposeInput, - authorId: string, - guestAnonSessionId?: string, - ): Promise { - assertNoSlur(input.body ?? null, "body") + async function resolveComposeTargets( + input: PostComposeInput, + authorId: string, + ): Promise { + assertNoSlur(input.body ?? null, "body") - if (input.kind === "repost") { - throw AppError.validation({ kind: "Use POST /posts/:id/repost to repost." }) - } - if (input.organizationId !== undefined) { - if (!(await deps.repo.canPostAsOrganization(input.organizationId, authorId))) { - throw AppError.forbidden("You can only post as an organization you belong to.") - } - } - if (input.replyToId !== undefined && input.repostOfId !== undefined) { - throw AppError.validation({ - replyToId: "A post is either a reply or a quote, not both.", - }) + if (input.kind === "repost") { + throw AppError.validation({ kind: "Use POST /posts/:id/repost to repost." }) + } + if (input.organizationId !== undefined) { + if (!(await deps.repo.canPostAsOrganization(input.organizationId, authorId))) { + throw AppError.forbidden("You can only post as an organization you belong to.") } - const kind = - input.replyToId !== undefined ? "reply" : input.repostOfId !== undefined ? "quote" : "post" - - let replyParentId: string | null = null - let replyParentAuthor: string | null = null - if (input.replyToId !== undefined) { - const parent = await requireReadable(input.replyToId, authorId) - replyParentId = parent.id - replyParentAuthor = parent.authorId + } + if (input.replyToId !== undefined && input.repostOfId !== undefined) { + throw AppError.validation({ + replyToId: "A post is either a reply or a quote, not both.", + }) + } + const kind: ComposeKind = + input.replyToId !== undefined ? "reply" : input.repostOfId !== undefined ? "quote" : "post" + + let replyParentId: string | null = null + let replyParentAuthor: string | null = null + if (input.replyToId !== undefined) { + const parent = await requireReadable(input.replyToId, authorId) + replyParentId = parent.id + replyParentAuthor = parent.authorId + } + let quoteTargetAuthor: string | null = null + if (input.repostOfId !== undefined) { + const target = await requireReadable(input.repostOfId, authorId) + quoteTargetAuthor = target.authorId + } + return { kind, replyParentId, replyParentAuthor, quoteTargetAuthor } + } + + async function assertAttachable(input: PostComposeInput, authorId: string): Promise { + if (input.eventId !== undefined) { + if (!(await deps.repo.isEventMember(input.eventId, authorId))) { + throw AppError.forbidden("You can only attach an event you host or attend.") } - let quoteTargetAuthor: string | null = null - if (input.repostOfId !== undefined) { - const target = await requireReadable(input.repostOfId, authorId) - quoteTargetAuthor = target.authorId + } + if (input.reportId !== undefined) { + if (!(await deps.repo.isReportAttachable(input.reportId))) { + throw AppError.notFound("Report not found") } + } + } - if (input.eventId !== undefined) { - if (!(await deps.repo.isEventMember(input.eventId, authorId))) { - throw AppError.forbidden("You can only attach an event you host or attend.") - } - } - if (input.reportId !== undefined) { - if (!(await deps.repo.isReportAttachable(input.reportId))) { - throw AppError.notFound("Report not found") - } + async function notifyCreated( + authorId: string, + postId: string, + targets: ComposeTargets, + mentions: readonly { id: string }[], + ): Promise { + const actorName = await deps.repo.actorNameOf(authorId) + const { replyParentAuthor, quoteTargetAuthor } = targets + if (replyParentAuthor !== null && (await shouldNotify(authorId, replyParentAuthor))) { + await safeNotify(() => + deps.notifier!.onPostReply({ recipientId: replyParentAuthor, actorName, postId }), + ) + } + if (quoteTargetAuthor !== null && (await shouldNotify(authorId, quoteTargetAuthor))) { + await safeNotify(() => + deps.notifier!.onPostQuote({ recipientId: quoteTargetAuthor, actorName, postId }), + ) + } + for (const m of mentions) { + if (await shouldNotify(authorId, m.id)) { + await safeNotify(() => + deps.notifier!.onPostMention({ recipientId: m.id, actorName, postId }), + ) } + } + } + + return { + async createPost( + input: PostComposeInput, + authorId: string, + guestAnonSessionId?: string, + ): Promise { + const targets = await resolveComposeTargets(input, authorId) + await assertAttachable(input, authorId) const mentions = await resolveMentionTargets(deps.sql, { handles: [], @@ -483,7 +532,7 @@ export function makePostService(deps: PostServiceDeps): PostService { const postId = await deps.repo.createPost({ authorId, guestAnonSessionId, - kind, + kind: targets.kind, body: input.body ?? null, replyToId: input.replyToId ?? null, repostOfId: input.repostOfId ?? null, @@ -494,30 +543,13 @@ export function makePostService(deps: PostServiceDeps): PostService { organizationId: input.organizationId ?? null, }) - const actorName = await deps.repo.actorNameOf(authorId) - if (replyParentAuthor !== null && (await shouldNotify(authorId, replyParentAuthor))) { - await safeNotify(() => - deps.notifier!.onPostReply({ recipientId: replyParentAuthor!, actorName, postId }), - ) - } - if (quoteTargetAuthor !== null && (await shouldNotify(authorId, quoteTargetAuthor))) { - await safeNotify(() => - deps.notifier!.onPostQuote({ recipientId: quoteTargetAuthor!, actorName, postId }), - ) - } - for (const m of mentions) { - if (await shouldNotify(authorId, m.id)) { - await safeNotify(() => - deps.notifier!.onPostMention({ recipientId: m.id, actorName, postId }), - ) - } - } + await notifyCreated(authorId, postId, targets, mentions) - if (kind === "post" || kind === "quote") { + if (targets.kind === "post" || targets.kind === "quote") { await announceNewPost(postId, authorId) } - if (replyParentId !== null) { - await announceCountChange(replyParentId, authorId) + if (targets.replyParentId !== null) { + await announceCountChange(targets.replyParentId, authorId) } return hydrateOrThrow(postId, authorId) @@ -530,11 +562,11 @@ export function makePostService(deps: PostServiceDeps): PostService { async deletePost(id: string, viewerId: string): Promise<{ ok: true }> { const brief = await deps.repo.getPostBrief(id) - if (!brief || brief.deletedAt !== null) throw AppError.notFound("Post not found") + if (!brief || brief.deletedAt !== null) throw AppError.notFound(POST_NOT_FOUND_MESSAGE) if (brief.authorId !== viewerId) { // A post the caller cannot read must answer like a missing one, or 403 confirms it exists. if (!isVisible(brief) || (await isBlocked(viewerId, brief.authorId))) { - throw AppError.notFound("Post not found") + throw AppError.notFound(POST_NOT_FOUND_MESSAGE) } throw AppError.forbidden("You can only delete your own post.") } @@ -625,7 +657,7 @@ export function makePostService(deps: PostServiceDeps): PostService { return hydrateOrThrow(targetId, viewerId) } const target = removed ? await deps.repo.getPostBrief(targetId) : null - if (target === null) throw AppError.notFound("Post not found") + if (target === null) throw AppError.notFound(POST_NOT_FOUND_MESSAGE) return withdrawnPostDTO(target, nowMs()) }, diff --git a/services/api/src/services/public-author.ts b/services/api/src/services/public-author.ts index 546ad0d4..a0da5333 100644 --- a/services/api/src/services/public-author.ts +++ b/services/api/src/services/public-author.ts @@ -43,3 +43,11 @@ export function publicAuthorIdentity(input: { deleted: false, } } + +export const FALLBACK_ACTOR_NAME = "Someone" + +export function actorDisplayName(displayName: string | null, handle: string | null): string { + if (displayName && displayName.trim() !== "") return displayName + if (handle) return `@${handle}` + return FALLBACK_ACTOR_NAME +} diff --git a/services/api/src/services/push-token-policy.ts b/services/api/src/services/push-token-policy.ts index add5dedb..e52a5f17 100644 --- a/services/api/src/services/push-token-policy.ts +++ b/services/api/src/services/push-token-policy.ts @@ -7,6 +7,12 @@ import { expandIpv6Hextets } from "../adapters/net-ipv6.js" export const PUSH_DNS_TIMEOUT_MS = 2_000 export const PUSH_DNS_TRIES = 1 +const IPV6_BRACKETS_RE = /^\[|\]$/g +const LOCALHOST = "localhost" +const NON_PUBLIC_HOST_SUFFIXES = [".localhost", ".internal", ".local"] as const +const IPV4_OCTET_MAX = 255 +const IPV6_HEXTET_RE = /^[0-9a-f]{1,4}$/ + export type PushAddressResolver = (host: string) => Promise export const lookupAddresses: PushAddressResolver = async (host) => { @@ -55,9 +61,10 @@ export async function resolveSafePushTarget( if (u.protocol !== "https:") return null host = u.hostname.toLowerCase() if (host.length === 0) return null - host = host.replace(/^\[|\]$/g, "") - if (host === "localhost" || host.endsWith(".localhost")) return null - if (host.endsWith(".internal") || host.endsWith(".local")) return null + host = host.replace(IPV6_BRACKETS_RE, "") + if (host === LOCALHOST || NON_PUBLIC_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) { + return null + } } catch { return null } @@ -91,11 +98,15 @@ function isPublicAddress(addr: string): boolean { return false } +function isOctet(n: number): boolean { + return Number.isInteger(n) && n >= 0 && n <= IPV4_OCTET_MAX +} + function isPublicIpv4(host: string): boolean { const parts = host.split(".") if (parts.length !== 4) return false const o = parts.map((p) => Number(p)) - if (o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false + if (!o.every(isOctet)) return false const [a, b] = o as [number, number, number, number] if (a === 0 || a === 127) return false if (a === 10) return false @@ -116,7 +127,7 @@ function ipv6ToBytes(input: string): number[] | null { const quad = s.slice(cut + 1).split(".") if (quad.length !== 4) return null const nums = quad.map((p) => Number(p)) - if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null + if (!nums.every(isOctet)) return null const hi = ((nums[0]! << 8) | nums[1]!).toString(16) const lo = ((nums[2]! << 8) | nums[3]!).toString(16) s = `${s.slice(0, cut + 1)}${hi}:${lo}` @@ -127,7 +138,7 @@ function ipv6ToBytes(input: string): number[] | null { if (groups.length !== 8) return null const bytes: number[] = [] for (const g of groups) { - if (!/^[0-9a-f]{1,4}$/.test(g)) return null + if (!IPV6_HEXTET_RE.test(g)) return null const v = parseInt(g, 16) bytes.push((v >> 8) & 0xff, v & 0xff) } @@ -162,6 +173,7 @@ const WEB_PUSH_AUTH_BYTES = 16 const BASE64URL_RE = /^[A-Za-z0-9_-]+={0,2}$/ const APNS_TOKEN_RE = /^[0-9a-fA-F]{64,200}$/ const FCM_TOKEN_RE = /^[A-Za-z0-9_:.~%+-]{64,2048}$/ +const EXPO_PUSH_TOKEN_MAX_LENGTH = 512 function decodedByteLength(value: string): number | null { if (!BASE64URL_RE.test(value)) return null @@ -179,54 +191,48 @@ export type PushTokenShape = | { ok: true; kind: "web"; endpoint: string } | { ok: false; field: string; reason: string } +function invalidToken(reason: string): PushTokenShape { + return { ok: false, field: "token", reason } +} + +function classifyWebSubscription(token: string): PushTokenShape { + const subscription = parseSubscription(token) + if (subscription === null) { + return invalidToken( + "web push tokens must be the subscription JSON: {endpoint, keys:{p256dh, auth}}", + ) + } + let url: URL + try { + url = new URL(subscription.endpoint) + } catch { + return invalidToken("subscription endpoint is not a URL") + } + if (url.protocol !== "https:") return invalidToken("subscription endpoint must be https") + if (decodedByteLength(subscription.keys.p256dh) !== WEB_PUSH_P256DH_BYTES) { + return invalidToken(`keys.p256dh must be ${WEB_PUSH_P256DH_BYTES} base64url-encoded bytes`) + } + if (decodedByteLength(subscription.keys.auth) !== WEB_PUSH_AUTH_BYTES) { + return invalidToken(`keys.auth must be ${WEB_PUSH_AUTH_BYTES} base64url-encoded bytes`) + } + return { ok: true, kind: "web", endpoint: subscription.endpoint } +} + export function classifyPushToken(platform: PushPlatform, token: string): PushTokenShape { if (isExpoPushToken(token)) { - return token.endsWith("]") && token.length <= 512 + return token.endsWith("]") && token.length <= EXPO_PUSH_TOKEN_MAX_LENGTH ? { ok: true, kind: "expo" } - : { ok: false, field: "token", reason: "malformed Expo push token" } - } - if (platform === "web") { - const subscription = parseSubscription(token) - if (subscription === null) { - return { - ok: false, - field: "token", - reason: "web push tokens must be the subscription JSON: {endpoint, keys:{p256dh, auth}}", - } - } - let url: URL - try { - url = new URL(subscription.endpoint) - } catch { - return { ok: false, field: "token", reason: "subscription endpoint is not a URL" } - } - if (url.protocol !== "https:") { - return { ok: false, field: "token", reason: "subscription endpoint must be https" } - } - if (decodedByteLength(subscription.keys.p256dh) !== WEB_PUSH_P256DH_BYTES) { - return { - ok: false, - field: "token", - reason: `keys.p256dh must be ${WEB_PUSH_P256DH_BYTES} base64url-encoded bytes`, - } - } - if (decodedByteLength(subscription.keys.auth) !== WEB_PUSH_AUTH_BYTES) { - return { - ok: false, - field: "token", - reason: `keys.auth must be ${WEB_PUSH_AUTH_BYTES} base64url-encoded bytes`, - } - } - return { ok: true, kind: "web", endpoint: subscription.endpoint } + : invalidToken("malformed Expo push token") } + if (platform === "web") return classifyWebSubscription(token) if (platform === "ios") { return APNS_TOKEN_RE.test(token) && token.length % 2 === 0 ? { ok: true, kind: "apns" } - : { ok: false, field: "token", reason: "APNs device tokens are hex (64-200 characters)" } + : invalidToken("APNs device tokens are hex (64-200 characters)") } return FCM_TOKEN_RE.test(token) ? { ok: true, kind: "fcm" } - : { ok: false, field: "token", reason: "not a recognizable FCM registration token" } + : invalidToken("not a recognizable FCM registration token") } export function parseSubscription( diff --git a/services/api/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index a3a0ce1c..e8fd1c2f 100644 --- a/services/api/src/services/social-repository.drizzle.ts +++ b/services/api/src/services/social-repository.drizzle.ts @@ -1,6 +1,7 @@ import type postgres from "postgres" import type { Queryable, Sql } from "../db/client.js" import type { + PeoplePage, PersonView, ProfileEventsPage, ProfileEventsPageArgs, @@ -85,10 +86,7 @@ export function toPersonView(r: PersonRowSelect): PersonView { } } -function pagePeople( - rows: PersonRowSelectWithFollow[], - limit: number, -): { items: Array; nextCursor: string | null } { +function pagePeople(rows: PersonRowSelectWithFollow[], limit: number): PeoplePage { const { items, nextCursor } = pageWith(rows, limit, (last) => encodeNameCursor({ name: last.display_name, id: last.id }), ) @@ -236,10 +234,7 @@ function profileEventRows( type ConnectionRow = PersonRowSelectWithFollow & { cursor_at: string | null } -function pageConnections( - rows: ConnectionRow[], - limit: number, -): { items: Array; nextCursor: string | null } { +function pageConnections(rows: ConnectionRow[], limit: number): PeoplePage { const { items, nextCursor } = paginateKeyset(rows, limit, (last) => ({ atText: last.cursor_at, id: last.id, @@ -258,7 +253,7 @@ async function connectionsPage( sql: Sql, args: { viewerId: string | null; cursor: string | null; limit: number }, joinPredicate: ReturnType, -): Promise<{ items: Array; nextCursor: string | null }> { +): Promise { const cursor = parseKeysetCursor(args.cursor) const viewerId = args.viewerId const cursorFilter = @@ -480,15 +475,13 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { } return { - async listPeople(args): Promise<{ - items: Array - nextCursor: string | null - }> { + async listPeople(args): Promise { const cursor = parseNameCursor(args.cursor) const viewerId = args.viewerId + const term = args.q !== null ? "%" + escapeLike(args.q) + "%" : null const qFilter = - args.q !== null - ? sql`AND ((u.handle::text) ILIKE ${"%" + escapeLike(args.q) + "%"} ESCAPE '\\' OR u.display_name ILIKE ${"%" + escapeLike(args.q) + "%"} ESCAPE '\\')` + term !== null + ? sql`AND ((u.handle::text) ILIKE ${term} ESCAPE '\\' OR u.display_name ILIKE ${term} ESCAPE '\\')` : sql`` const selfFilter = viewerId !== null ? sql`AND u.id <> ${viewerId}` : sql`` const cursorFilter = @@ -546,17 +539,11 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { `.then((rows) => rows.map((r) => ({ ...toPersonView(r), isFollowing: false }))) }, - async listFollowers(args): Promise<{ - items: Array - nextCursor: string | null - }> { + async listFollowers(args): Promise { return connectionsPage(sql, args, sql`f.followee_id = ${args.id} AND f.follower_id = u.id`) }, - async listFollowing(args): Promise<{ - items: Array - nextCursor: string | null - }> { + async listFollowing(args): Promise { return connectionsPage(sql, args, sql`f.follower_id = ${args.id} AND f.followee_id = u.id`) }, diff --git a/services/api/src/services/social-service.ts b/services/api/src/services/social-service.ts index ab1fabc5..635f4a5a 100644 --- a/services/api/src/services/social-service.ts +++ b/services/api/src/services/social-service.ts @@ -13,13 +13,16 @@ import type { import { toCleanupDTO, type CleanupRecord } from "./cleanup-service.js" import { attachAffiliations, type AffiliationLoader } from "./affiliation.js" -export const PEOPLE_DEFAULT_LIMIT = 20 +const PEOPLE_DEFAULT_LIMIT = 20 -export const SUGGESTIONS_CACHE_LIMIT = 20 -export const SUGGESTIONS_CACHE_TTL_SEC = 5 * 60 +const SUGGESTIONS_CACHE_LIMIT = 20 +const SUGGESTIONS_CACHE_TTL_SEC = 5 * 60 +const SUGGESTIONS_CACHE_KEY_PREFIX = "social:suggest:v1:" + +export const PERSON_NOT_FOUND_MESSAGE = "Person not found" export function suggestionsCacheKey(viewerId: string): string { - return `social:suggest:v1:${viewerId}` + return `${SUGGESTIONS_CACHE_KEY_PREFIX}${viewerId}` } export async function dropSuggestionsFor( @@ -39,7 +42,7 @@ export async function dropSuggestionsFor( export const PROFILE_PAST_EVENTS_LIMIT = 20 -export const PROFILE_UPCOMING_EVENTS_LIMIT = 20 +const PROFILE_UPCOMING_EVENTS_LIMIT = 20 export type ProfileWithBlock = UserProfileDTO & { blockedByMe?: boolean } @@ -78,27 +81,29 @@ export interface UpcomingEventsArgs { limit: number } +export interface PeoplePage { + items: Array + nextCursor: string | null +} + +export interface ConnectionsArgs { + id: string + viewerId: string | null + cursor: string | null + limit: number +} + export interface SocialRepository { listPeople(args: { viewerId: string | null q: string | null cursor: string | null limit: number - }): Promise<{ items: Array; nextCursor: string | null }> + }): Promise - listFollowers(args: { - id: string - viewerId: string | null - cursor: string | null - limit: number - }): Promise<{ items: Array; nextCursor: string | null }> + listFollowers(args: ConnectionsArgs): Promise - listFollowing(args: { - id: string - viewerId: string | null - cursor: string | null - limit: number - }): Promise<{ items: Array; nextCursor: string | null }> + listFollowing(args: ConnectionsArgs): Promise suggestFollows(args: { viewerId: string @@ -231,40 +236,28 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { } } - async function dropSuggestionsCache(viewerId: string): Promise { - const cache = deps.suggestionsCache - if (cache === undefined) return - try { - await cache.del(suggestionsCacheKey(viewerId)) - } catch (err) { - deps.logger?.warn({ err }, "follow suggestions cache invalidation failed (ignored)") - } - } - async function viewerFollows(targetId: string, viewer: SocialViewer): Promise { if (viewer.userId === null || viewer.userId === targetId) return false return deps.repo.isFollowing(viewer.userId, targetId) } + // A viewer who blocked the subject gets an empty listing; one the subject blocked gets the same + // not-found as a missing person, so the block is not disclosed. + async function listingHiddenByBlock(id: string, viewer: SocialViewer): Promise { + if (viewer.userId === null || viewer.userId === id || !deps.blockState) return false + const { blockedByViewer, blockedByTarget } = await deps.blockState(viewer.userId, id) + if (blockedByViewer) return true + if (blockedByTarget) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) + return false + } + async function pageConnections( - fn: (args: { - id: string - viewerId: string | null - cursor: string | null - limit: number - }) => Promise<{ - items: Array - nextCursor: string | null - }>, + fn: (args: ConnectionsArgs) => Promise, id: string, viewer: SocialViewer, req: ConnectionsListQuery, ): Promise { - if (viewer.userId !== null && viewer.userId !== id && deps.blockState) { - const { blockedByViewer, blockedByTarget } = await deps.blockState(viewer.userId, id) - if (blockedByViewer) return { items: [], nextCursor: null } - if (blockedByTarget) throw AppError.notFound("Person not found") - } + if (await listingHiddenByBlock(id, viewer)) return { items: [], nextCursor: null } const { items, nextCursor } = await fn({ id, viewerId: viewer.userId, @@ -357,7 +350,7 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { if (!isSelf && viewer.userId !== null && deps.blockState) { const { blockedByViewer, blockedByTarget } = await deps.blockState(viewer.userId, view.id) if (blockedByViewer) return blockedProfileShell(view) - if (blockedByTarget) throw AppError.notFound("Person not found") + if (blockedByTarget) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) } return buildProfile(view, viewer, isSelf) } @@ -421,12 +414,12 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { throw AppError.validation({ targetId: "You cannot follow yourself." }) } if (deps.isBlockedEitherWay && (await deps.isBlockedEitherWay(viewerId, targetId))) { - throw AppError.notFound("Person not found") + throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) } const { exists, created } = await deps.repo.addFollow(viewerId, targetId) - if (!exists) throw AppError.notFound("Person not found") + if (!exists) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) - await dropSuggestionsCache(viewerId) + await dropSuggestionsFor(deps.suggestionsCache, [viewerId], deps.logger) const followers = await deps.repo.followerCount(targetId) if (created && deps.notifier) { @@ -459,18 +452,18 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { throw AppError.validation({ targetId: "You cannot unfollow yourself." }) } if (deps.blockState && (await deps.blockState(viewerId, targetId)).blockedByTarget) { - throw AppError.notFound("Person not found") + throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) } const { exists } = await deps.repo.removeFollow(viewerId, targetId) - if (!exists) throw AppError.notFound("Person not found") - await dropSuggestionsCache(viewerId) + if (!exists) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) + await dropSuggestionsFor(deps.suggestionsCache, [viewerId], deps.logger) const followers = await deps.repo.followerCount(targetId) return { isFollowing: false, followers } }, async getProfile(id: string, viewer: SocialViewer): Promise<{ profile: ProfileWithBlock }> { const view = await deps.repo.findPersonById(id) - if (!view) throw AppError.notFound("Person not found") + if (!view) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return { profile: await resolveProfile(view, viewer) } }, @@ -479,13 +472,13 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { viewer: SocialViewer, ): Promise<{ profile: ProfileWithBlock }> { const view = await deps.repo.findPersonByHandle(handle) - if (!view) throw AppError.notFound("Person not found") + if (!view) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return { profile: await resolveProfile(view, viewer) } }, async getMyProfile(viewerId: string): Promise<{ profile: ProfileWithBlock }> { const view = await deps.repo.findPersonById(viewerId) - if (!view) throw AppError.notFound("Person not found") + if (!view) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return { profile: await buildProfile(view, { userId: viewerId }, true) } }, @@ -494,11 +487,7 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { viewer: SocialViewer, req: PaginationQuery, ): Promise { - if (viewer.userId !== null && viewer.userId !== id && deps.blockState) { - const { blockedByViewer, blockedByTarget } = await deps.blockState(viewer.userId, id) - if (blockedByViewer) return { items: [], nextCursor: null } - if (blockedByTarget) throw AppError.notFound("Person not found") - } + if (await listingHiddenByBlock(id, viewer)) return { items: [], nextCursor: null } const { items, nextCursor } = await deps.repo.pastEventsPageFor(id, { cursor: req.cursor ?? null, limit: req.limit ?? PROFILE_PAST_EVENTS_LIMIT, @@ -509,7 +498,7 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { async resolveHandleToId(handle: string): Promise { const view = await deps.repo.findPersonByHandle(handle) - if (!view) throw AppError.notFound("Person not found") + if (!view) throw AppError.notFound(PERSON_NOT_FOUND_MESSAGE) return view.id }, } diff --git a/services/api/src/services/user-search.drizzle.ts b/services/api/src/services/user-search.drizzle.ts index 3d2048c8..b9465575 100644 --- a/services/api/src/services/user-search.drizzle.ts +++ b/services/api/src/services/user-search.drizzle.ts @@ -3,6 +3,23 @@ import { avatarGradient } from "@civfix/shared" import type { UserSearchResultDTO } from "@civfix/shared" import { escapeLike } from "./admin/like.js" +interface UserSearchRow { + id: string + handle: string + display_name: string + avatar_url: string | null +} + +function toSearchResult(r: UserSearchRow): UserSearchResultDTO { + return { + id: r.id, + handle: r.handle, + displayName: r.display_name, + avatar: avatarGradient(r.id), + ...(r.avatar_url !== null ? { avatarUrl: r.avatar_url } : {}), + } +} + // Locked product rule: DM search never surfaces the viewer, deleted or handle-less users, accounts with // DMs turned off, or anyone blocked in either direction. The result shape carries no email, bio or // follower counts. The route has already stripped a leading `@` from `q`. @@ -13,9 +30,7 @@ export async function searchByHandlePrefix( limit: number, ): Promise { const prefix = escapeLike(q) + "%" - const rows = await sql< - { id: string; handle: string; display_name: string; avatar_url: string | null }[] - >` + const rows = await sql` SELECT u.id, u.handle, u.display_name, u.avatar_url FROM users u WHERE u.deleted_at IS NULL @@ -33,13 +48,7 @@ export async function searchByHandlePrefix( ORDER BY u.handle ASC LIMIT ${limit} ` - return rows.map((r) => ({ - id: r.id, - handle: r.handle, - displayName: r.display_name, - avatar: avatarGradient(r.id), - ...(r.avatar_url !== null ? { avatarUrl: r.avatar_url } : {}), - })) + return rows.map(toSearchResult) } // Keeps DM-disabled accounts (anyone is taggable) but, like DM search, never suggests a user blocked in @@ -52,9 +61,7 @@ export async function searchMentionable( limit: number, ): Promise { const term = "%" + escapeLike(q) + "%" - const rows = await sql< - { id: string; handle: string; display_name: string; avatar_url: string | null }[] - >` + const rows = await sql` SELECT u.id, u.handle, u.display_name, u.avatar_url FROM users u WHERE u.deleted_at IS NULL @@ -71,11 +78,5 @@ export async function searchMentionable( ORDER BY u.handle ASC, u.display_name ASC LIMIT ${limit} ` - return rows.map((r) => ({ - id: r.id, - handle: r.handle, - displayName: r.display_name, - avatar: avatarGradient(r.id), - ...(r.avatar_url !== null ? { avatarUrl: r.avatar_url } : {}), - })) + return rows.map(toSearchResult) } From 50c649e2648a0b20896d3ade6c765a6c057cf50f Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 13:24:59 +0000 Subject: [PATCH 2/3] in-file cleanup: host registration, split the registration repository --- .../src/routes/host/registrations.routes.ts | 2 +- .../api/src/routes/host/tickets.routes.ts | 7 +- .../api/src/services/host/checkin-service.ts | 6 +- .../api/src/services/host/counter-budget.ts | 28 + services/api/src/services/host/event-day.ts | 15 +- .../api/src/services/host/event-fields.ts | 6 +- .../src/services/host/question-validation.ts | 158 +- .../host/registration-checkin.drizzle.ts | 370 +++ .../api/src/services/host/registration-dto.ts | 28 +- .../src/services/host/registration-jobs.ts | 23 +- .../host/registration-load.drizzle.ts | 238 ++ .../host/registration-pages.drizzle.ts | 258 ++ .../host/registration-questions.drizzle.ts | 121 + .../src/services/host/registration-queues.ts | 2 - .../host/registration-register.drizzle.ts | 615 ++++ .../host/registration-repository.drizzle.ts | 2862 +---------------- .../host/registration-repository.memory.ts | 165 +- .../services/host/registration-retention.ts | 2 +- .../host/registration-roster.drizzle.ts | 534 +++ .../src/services/host/registration-service.ts | 93 +- .../api/src/services/host/registration-sql.ts | 62 +- .../host/registration-ticket-types.drizzle.ts | 334 ++ .../host/registration-waitlist.drizzle.ts | 663 ++++ .../src/services/host/registration-wiring.ts | 121 +- .../api/src/services/host/ticket-token.ts | 24 +- .../src/services/host/ticket-type-service.ts | 36 +- .../api/src/services/host/waitlist-service.ts | 6 +- 27 files changed, 3588 insertions(+), 3191 deletions(-) create mode 100644 services/api/src/services/host/counter-budget.ts create mode 100644 services/api/src/services/host/registration-checkin.drizzle.ts create mode 100644 services/api/src/services/host/registration-load.drizzle.ts create mode 100644 services/api/src/services/host/registration-pages.drizzle.ts create mode 100644 services/api/src/services/host/registration-questions.drizzle.ts create mode 100644 services/api/src/services/host/registration-register.drizzle.ts create mode 100644 services/api/src/services/host/registration-roster.drizzle.ts create mode 100644 services/api/src/services/host/registration-ticket-types.drizzle.ts create mode 100644 services/api/src/services/host/registration-waitlist.drizzle.ts diff --git a/services/api/src/routes/host/registrations.routes.ts b/services/api/src/routes/host/registrations.routes.ts index 580f8cc6..404bb49c 100644 --- a/services/api/src/routes/host/registrations.routes.ts +++ b/services/api/src/routes/host/registrations.routes.ts @@ -37,7 +37,7 @@ import { type HostRouteContext, } from "./_host-routes.js" -export const WalkupBodySchema = trimTextFields(CreateWalkupRegistrationRequestSchema, "name") +const WalkupBodySchema = trimTextFields(CreateWalkupRegistrationRequestSchema, "name") function projectionFor(standing: HostStanding): RegistrationProjection { return { diff --git a/services/api/src/routes/host/tickets.routes.ts b/services/api/src/routes/host/tickets.routes.ts index 170ca700..1db82294 100644 --- a/services/api/src/routes/host/tickets.routes.ts +++ b/services/api/src/routes/host/tickets.routes.ts @@ -12,8 +12,7 @@ import { } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" -import { requireAuth } from "../../auth/context.js" -import { resolveAuthContext } from "../../auth/context.js" +import { requireAuth, resolveAuthContext } from "../../auth/context.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" import { @@ -28,10 +27,10 @@ import { export function registerHostTicketRoutes( app: FastifyInstance, - _container: Container, + container: Container, ctx: HostRouteContext, ): void { - const csrfProtect = _container.csrf.protect + const csrfProtect = container.csrf.protect route( app, diff --git a/services/api/src/services/host/checkin-service.ts b/services/api/src/services/host/checkin-service.ts index 9f06eb32..18014ecf 100644 --- a/services/api/src/services/host/checkin-service.ts +++ b/services/api/src/services/host/checkin-service.ts @@ -23,7 +23,9 @@ import type { import type { RegistrationAudit, RegistrationService } from "./registration-service.js" import { normalizeTicketToken, type TicketTokenSigner } from "./ticket-token.js" -export const CHECKIN_NOSHOW_SWEEP_BATCH = 1000 +const CHECKIN_NOSHOW_SWEEP_BATCH = 1000 + +const TRAILING_SLASHES = /\/+$/ export interface GuestTicketLookup { ( @@ -99,7 +101,7 @@ export function makeCheckinService(deps: CheckinServiceDeps): CheckinService { icsUrl: deps.publicApiUrl === undefined || deps.publicApiUrl.length === 0 ? null - : `${deps.publicApiUrl.replace(/\/+$/, "")}/v1/cleanups/${cleanupId}/ics`, + : `${deps.publicApiUrl.replace(TRAILING_SLASHES, "")}/v1/cleanups/${cleanupId}/ics`, } } diff --git a/services/api/src/services/host/counter-budget.ts b/services/api/src/services/host/counter-budget.ts new file mode 100644 index 00000000..689c77a6 --- /dev/null +++ b/services/api/src/services/host/counter-budget.ts @@ -0,0 +1,28 @@ +import { AppError } from "@civfix/shared" +import type { CounterStore } from "../../abuse/counter-store.js" + +export interface CounterBudget { + key: string + windowSeconds: number + cap: number + unavailableLog: string + unavailableMessage: string + exceededMessage: string +} + +/** Fails closed: an unreachable counter store refuses the action rather than lifting the cap. */ +export async function reserveCounterBudget( + counters: CounterStore | undefined, + budget: CounterBudget, + logger?: { warn(obj: unknown, msg?: string): void }, +): Promise { + if (counters === undefined) return + let used: number + try { + used = await counters.incr(budget.key, budget.windowSeconds) + } catch (err) { + logger?.warn({ err }, budget.unavailableLog) + throw AppError.rateLimited(budget.unavailableMessage) + } + if (used > budget.cap) throw AppError.rateLimited(budget.exceededMessage) +} diff --git a/services/api/src/services/host/event-day.ts b/services/api/src/services/host/event-day.ts index 02b300ea..45394574 100644 --- a/services/api/src/services/host/event-day.ts +++ b/services/api/src/services/host/event-day.ts @@ -1,7 +1,16 @@ +const ISO_DAY_LENGTH = "YYYY-MM-DD".length + +// en-CA formats a date as YYYY-MM-DD, the same shape as the UTC fallback. +const ISO_DAY_LOCALE = "en-CA" + +function utcDayKey(at: Date): string { + return at.toISOString().slice(0, ISO_DAY_LENGTH) +} + export function eventDayKey(at: Date, timezone: string | null): string { - if (timezone === null || timezone.length === 0) return at.toISOString().slice(0, 10) + if (timezone === null || timezone.length === 0) return utcDayKey(at) try { - return new Intl.DateTimeFormat("en-CA", { + return new Intl.DateTimeFormat(ISO_DAY_LOCALE, { timeZone: timezone, year: "numeric", month: "2-digit", @@ -9,6 +18,6 @@ export function eventDayKey(at: Date, timezone: string | null): string { }).format(at) } catch { // Intl throws RangeError on a zone name this runtime does not know; a UTC day beats no day. - return at.toISOString().slice(0, 10) + return utcDayKey(at) } } diff --git a/services/api/src/services/host/event-fields.ts b/services/api/src/services/host/event-fields.ts index 9487b518..f7917442 100644 --- a/services/api/src/services/host/event-fields.ts +++ b/services/api/src/services/host/event-fields.ts @@ -9,7 +9,9 @@ import { MAX_EVENT_DURATION_MS, MIN_EVENT_DURATION_MS } from "../cleanup-rules.j export const DEFAULT_EVENT_TIME_ZONE = "America/Los_Angeles" -export const EVENT_REMINDER_OFFSET_CHOICES: readonly number[] = [60, 180, 1440, 2880, 10080] +const MINUTES_PER_HOUR = 60 + +const EVENT_REMINDER_OFFSET_CHOICES: readonly number[] = [60, 180, 1440, 2880, 10080] const FALLBACK_TIMEZONES: readonly string[] = [ "UTC", @@ -91,7 +93,7 @@ export function assertEventWindow(input: { } if (durationMs > MAX_EVENT_DURATION_MS) { throw AppError.validation({ - endsAt: `an event can run for at most ${MAX_EVENT_DURATION_MINUTES / 60} hours`, + endsAt: `an event can run for at most ${MAX_EVENT_DURATION_MINUTES / MINUTES_PER_HOUR} hours`, }) } } diff --git a/services/api/src/services/host/question-validation.ts b/services/api/src/services/host/question-validation.ts index d3c36b71..9953046e 100644 --- a/services/api/src/services/host/question-validation.ts +++ b/services/api/src/services/host/question-validation.ts @@ -20,14 +20,70 @@ function conditionSatisfied( return false } -export function validateAnswers( - questions: readonly QuestionRecord[], +type AnswerCheck = { write: AnswerWrite } | { error: string } + +type AnswerValue = EventAnswerInput["value"] + +const REQUIRED = "required" + +function checkTextAnswer(question: QuestionRecord, value: AnswerValue): AnswerCheck { + if (typeof value !== "string") return { error: "must be text" } + const trimmed = value.trim() + const max = question.kind === "short_text" ? MAX_SHORT_TEXT_ANSWER : MAX_LONG_TEXT_ANSWER + if (trimmed.length > max) return { error: `must be at most ${max} characters` } + if (question.required && trimmed.length === 0) return { error: REQUIRED } + return { write: { questionId: question.id, valueText: trimmed, valueJson: null } } +} + +function checkSingleSelect(question: QuestionRecord, value: AnswerValue): AnswerCheck { + if (typeof value !== "string" || !question.options.some((option) => option.value === value)) { + return { error: "must be one of the offered options" } + } + return { write: { questionId: question.id, valueText: value, valueJson: null } } +} + +function checkMultiSelect(question: QuestionRecord, value: AnswerValue): AnswerCheck { + if (!Array.isArray(value)) return { error: "must be a list of offered options" } + if (value.length > MAX_QUESTION_OPTIONS) return { error: "too many selections" } + const allowed = new Set(question.options.map((option) => option.value)) + if (value.some((entry) => typeof entry !== "string" || !allowed.has(entry))) { + return { error: "must be a list of offered options" } + } + if (new Set(value).size !== value.length) return { error: "lists the same option twice" } + if (question.maxSelections !== null && value.length > question.maxSelections) { + return { error: `select at most ${question.maxSelections}` } + } + if (question.required && value.length === 0) return { error: REQUIRED } + return { write: { questionId: question.id, valueText: null, valueJson: value } } +} + +function checkBooleanAnswer(question: QuestionRecord, value: AnswerValue): AnswerCheck { + if (typeof value !== "boolean") return { error: "must be true or false" } + if (question.required && !value) return { error: REQUIRED } + return { write: { questionId: question.id, valueText: null, valueJson: value } } +} + +function checkAnswer(question: QuestionRecord, value: AnswerValue): AnswerCheck { + switch (question.kind) { + case "short_text": + case "long_text": + return checkTextAnswer(question, value) + case "single_select": + return checkSingleSelect(question, value) + case "multi_select": + return checkMultiSelect(question, value) + case "checkbox": + case "consent": + return checkBooleanAnswer(question, value) + } +} + +function indexSubmitted( + live: ReadonlyMap, answers: readonly EventAnswerInput[], -): AnswerValidation { - const fields: Record = {} - const live = new Map(questions.filter((q) => q.archivedAt === null).map((q) => [q.id, q])) + fields: Record, +): Map { const submitted = new Map() - for (const answer of answers) { if (!live.has(answer.questionId)) { fields[answer.questionId] = "unknown question" @@ -39,7 +95,16 @@ export function validateAnswers( } submitted.set(answer.questionId, answer) } + return submitted +} +export function validateAnswers( + questions: readonly QuestionRecord[], + answers: readonly EventAnswerInput[], +): AnswerValidation { + const fields: Record = {} + const live = new Map(questions.filter((q) => q.archivedAt === null).map((q) => [q.id, q])) + const submitted = indexSubmitted(live, answers, fields) const writes: AnswerWrite[] = [] for (const question of live.values()) { @@ -47,7 +112,7 @@ export function validateAnswers( const applies = conditionSatisfied(question, submitted) if (answer === undefined) { - if (question.required && applies) fields[question.id] = "required" + if (question.required && applies) fields[question.id] = REQUIRED continue } if (!applies) { @@ -55,82 +120,9 @@ export function validateAnswers( continue } - const value = answer.value - switch (question.kind) { - case "short_text": - case "long_text": { - if (typeof value !== "string") { - fields[question.id] = "must be text" - break - } - const trimmed = value.trim() - const max = question.kind === "short_text" ? MAX_SHORT_TEXT_ANSWER : MAX_LONG_TEXT_ANSWER - if (trimmed.length > max) { - fields[question.id] = `must be at most ${max} characters` - break - } - if (question.required && trimmed.length === 0) { - fields[question.id] = "required" - break - } - writes.push({ questionId: question.id, valueText: trimmed, valueJson: null }) - break - } - case "single_select": { - if (typeof value !== "string") { - fields[question.id] = "must be one of the offered options" - break - } - if (!question.options.some((option) => option.value === value)) { - fields[question.id] = "must be one of the offered options" - break - } - writes.push({ questionId: question.id, valueText: value, valueJson: null }) - break - } - case "multi_select": { - if (!Array.isArray(value)) { - fields[question.id] = "must be a list of offered options" - break - } - if (value.length > MAX_QUESTION_OPTIONS) { - fields[question.id] = "too many selections" - break - } - const allowed = new Set(question.options.map((option) => option.value)) - if (value.some((entry) => typeof entry !== "string" || !allowed.has(entry))) { - fields[question.id] = "must be a list of offered options" - break - } - if (new Set(value).size !== value.length) { - fields[question.id] = "lists the same option twice" - break - } - if (question.maxSelections !== null && value.length > question.maxSelections) { - fields[question.id] = `select at most ${question.maxSelections}` - break - } - if (question.required && value.length === 0) { - fields[question.id] = "required" - break - } - writes.push({ questionId: question.id, valueText: null, valueJson: value }) - break - } - case "checkbox": - case "consent": { - if (typeof value !== "boolean") { - fields[question.id] = "must be true or false" - break - } - if (question.required && !value) { - fields[question.id] = "required" - break - } - writes.push({ questionId: question.id, valueText: null, valueJson: value }) - break - } - } + const checked = checkAnswer(question, answer.value) + if ("error" in checked) fields[question.id] = checked.error + else writes.push(checked.write) } if (Object.keys(fields).length > 0) return { ok: false, fields } diff --git a/services/api/src/services/host/registration-checkin.drizzle.ts b/services/api/src/services/host/registration-checkin.drizzle.ts new file mode 100644 index 00000000..0e082d10 --- /dev/null +++ b/services/api/src/services/host/registration-checkin.drizzle.ts @@ -0,0 +1,370 @@ +import type { CheckinMethod } from "@civfix/shared" +import type { Sql } from "../../db/client.js" +import { toSeatRecord, waitlistEntryNotBanned, type SeatRowSelect } from "./registration-sql.js" +import { loadRegistrationById } from "./registration-load.drizzle.js" +import { MAX_TICKET_TYPES } from "./registration-ticket-types.drizzle.js" +import type { + CheckinCountersRecord, + CheckinResultRecord, + HostRegistrationRepository, + HostedEventCounts, + RegistrationRecord, + SeatRecord, +} from "./registration-repository.types.js" + +export const ARRIVAL_BUCKET_MINUTES = 15 + +const ARRIVAL_BUCKET_SECONDS = ARRIVAL_BUCKET_MINUTES * 60 + +export type CheckinMethods = Pick< + HostRegistrationRepository, + | "checkInByToken" + | "checkInSeat" + | "undoCheckIn" + | "markNoShows" + | "sweepNoShows" + | "checkinCounters" + | "hostedEventCounts" +> + +type CheckedInSeatRow = SeatRowSelect & { first_time: boolean } + +interface CheckinTotalsRow { + registered: number + checked_in: number + no_show: number + waitlisted: number + capacity: number | null +} + +interface CheckinByTypeRow { + ticket_type_id: string + name: string + registered: number + checked_in: number + waitlisted: number + capacity: number | null +} + +export function emptyCheckinResult(outcome: CheckinResultRecord["outcome"]): CheckinResultRecord { + return { + outcome, + firstTime: false, + seat: null, + registration: null, + attendeeName: null, + ticketTypeName: null, + partySize: null, + checkedInAt: null, + } +} + +export function buildCheckinResult( + outcome: CheckinResultRecord["outcome"], + firstTime: boolean, + seat: SeatRecord, + registration: RegistrationRecord | null, +): CheckinResultRecord { + return { + outcome, + firstTime, + seat, + registration, + attendeeName: seat.attendeeName ?? registration?.guestName ?? null, + ticketTypeName: registration?.ticketTypeName ?? null, + partySize: registration?.partySize ?? null, + checkedInAt: seat.checkedInAt, + } +} + +async function checkedInResult( + sql: Sql, + cleanupId: string, + row: CheckedInSeatRow, +): Promise { + return buildCheckinResult( + row.first_time ? "checked_in" : "already", + row.first_time, + toSeatRecord(row), + await loadRegistrationById(sql, cleanupId, row.registration_id), + ) +} + +async function unscannableTokenResult( + sql: Sql, + cleanupId: string, + tokenHash: string, +): Promise { + const probe = await sql< + { cleanup_id: string; status: SeatRecord["status"]; no_show_at: Date | null }[] + >` + SELECT cleanup_id, status, no_show_at FROM cleanup_registration_seats + WHERE ticket_token_hash = ${tokenHash} + LIMIT 1 + ` + const seat = probe[0] + if (seat === undefined) return emptyCheckinResult("unknown_token") + if (seat.cleanup_id !== cleanupId) return emptyCheckinResult("wrong_event") + if (seat.status === "cancelled") return emptyCheckinResult("cancelled") + if (seat.no_show_at !== null) return emptyCheckinResult("no_show") + return emptyCheckinResult("unknown_token") +} + +async function checkinTotals(sql: Sql, cleanupId: string): Promise { + const totals = await sql` + SELECT + COALESCE(( + SELECT sum(r.party_size)::int FROM cleanup_registrations r + WHERE r.cleanup_id = ${cleanupId} AND r.status = 'registered' + ), 0) AS registered, + COALESCE(( + SELECT count(*)::int FROM cleanup_registration_seats s + WHERE s.cleanup_id = ${cleanupId} AND s.status = 'active' AND s.checked_in_at IS NOT NULL + ), 0) AS checked_in, + COALESCE(( + SELECT count(*)::int FROM cleanup_registration_seats s + WHERE s.cleanup_id = ${cleanupId} AND s.status = 'active' AND s.no_show_at IS NOT NULL + ), 0) AS no_show, + COALESCE(( + SELECT sum(w.party_size)::int FROM cleanup_waitlist w + WHERE w.cleanup_id = ${cleanupId} AND w.status IN ('waiting', 'offered') + AND ${waitlistEntryNotBanned(sql)} + ), 0) AS waitlisted, + ( + SELECT CASE + WHEN count(*) = 0 THEN (SELECT c.capacity FROM cleanups c WHERE c.id = ${cleanupId}) + WHEN bool_or(t.capacity IS NULL) THEN NULL + ELSE sum(t.capacity)::int + END + FROM cleanup_ticket_types t WHERE t.cleanup_id = ${cleanupId} + ) AS capacity + ` + return totals[0] +} + +async function checkinByTicketType(sql: Sql, cleanupId: string): Promise { + return sql` + SELECT t.id AS ticket_type_id, + t.name, + COALESCE(( + SELECT sum(r.party_size)::int FROM cleanup_registrations r + WHERE r.ticket_type_id = t.id AND r.status = 'registered' + ), 0) AS registered, + COALESCE(( + SELECT count(*)::int FROM cleanup_registration_seats s + JOIN cleanup_registrations r2 ON r2.id = s.registration_id + WHERE r2.ticket_type_id = t.id AND s.status = 'active' AND s.checked_in_at IS NOT NULL + ), 0) AS checked_in, + COALESCE(( + SELECT sum(w.party_size)::int FROM cleanup_waitlist w + WHERE w.ticket_type_id = t.id AND w.status IN ('waiting', 'offered') + AND ${waitlistEntryNotBanned(sql)} + ), 0) AS waitlisted, + t.capacity + FROM cleanup_ticket_types t + WHERE t.cleanup_id = ${cleanupId} + ORDER BY t.sort_order, t.id + LIMIT ${MAX_TICKET_TYPES} + ` +} + +async function arrivalBuckets(sql: Sql, cleanupId: string): Promise<{ at: Date; n: number }[]> { + return sql<{ at: Date; n: number }[]>` + SELECT to_timestamp( + floor(extract(epoch FROM s.checked_in_at) / ${ARRIVAL_BUCKET_SECONDS}) + * ${ARRIVAL_BUCKET_SECONDS} + ) AS at, + count(*)::int AS n + FROM cleanup_registration_seats s + WHERE s.cleanup_id = ${cleanupId} AND s.checked_in_at IS NOT NULL + GROUP BY 1 + ORDER BY 1 + LIMIT 200 + ` +} + +export function makeCheckinMethods(sql: Sql): CheckinMethods { + return { + async checkInByToken(args: { + cleanupId: string + tokenHash: string + actorId: string + method: CheckinMethod + now: Date + }): Promise { + const updated = await sql` + WITH target AS ( + SELECT id, checked_in_at IS NULL AS first_time + FROM cleanup_registration_seats + WHERE ticket_token_hash = ${args.tokenHash} + AND cleanup_id = ${args.cleanupId} + AND status = 'active' + AND no_show_at IS NULL + FOR UPDATE + ) + UPDATE cleanup_registration_seats s + SET checked_in_at = COALESCE(s.checked_in_at, ${args.now}), + checked_in_by = COALESCE(s.checked_in_by, ${args.actorId}), + checkin_method = COALESCE(s.checkin_method, ${args.method}) + FROM target t + WHERE s.id = t.id + RETURNING s.id, s.registration_id, s.seat_index, s.attendee_name, s.status, + s.checked_in_at, s.checked_in_by, s.checkin_method, s.checkin_coarsened_at, + s.no_show_at, t.first_time + ` + const row = updated[0] + if (row !== undefined) return checkedInResult(sql, args.cleanupId, row) + return unscannableTokenResult(sql, args.cleanupId, args.tokenHash) + }, + + async checkInSeat(args: { + cleanupId: string + seatId: string + actorId: string + method: CheckinMethod + now: Date + }): Promise { + const updated = await sql` + WITH target AS ( + SELECT id, checked_in_at IS NULL AS first_time + FROM cleanup_registration_seats + WHERE id = ${args.seatId} + AND cleanup_id = ${args.cleanupId} + AND status = 'active' + FOR UPDATE + ) + UPDATE cleanup_registration_seats s + SET checked_in_at = COALESCE(s.checked_in_at, ${args.now}), + checked_in_by = COALESCE(s.checked_in_by, ${args.actorId}), + checkin_method = COALESCE(s.checkin_method, ${args.method}), + no_show_at = NULL + FROM target t + WHERE s.id = t.id + RETURNING s.id, s.registration_id, s.seat_index, s.attendee_name, s.status, + s.checked_in_at, s.checked_in_by, s.checkin_method, s.checkin_coarsened_at, + s.no_show_at, t.first_time + ` + const row = updated[0] + if (row !== undefined) return checkedInResult(sql, args.cleanupId, row) + const probe = await sql<{ status: SeatRecord["status"] }[]>` + SELECT status FROM cleanup_registration_seats + WHERE id = ${args.seatId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 + ` + return emptyCheckinResult(probe[0] === undefined ? "unknown_token" : "cancelled") + }, + + async undoCheckIn(args: { cleanupId: string; seatId: string }): Promise { + const rows = await sql` + UPDATE cleanup_registration_seats + SET checked_in_at = NULL, checked_in_by = NULL, checkin_method = NULL, + checkin_coarsened_at = NULL + WHERE id = ${args.seatId} AND cleanup_id = ${args.cleanupId} + RETURNING id, registration_id, seat_index, attendee_name, status, checked_in_at, + checked_in_by, checkin_method, checkin_coarsened_at, no_show_at + ` + const row = rows[0] + return row === undefined ? null : toSeatRecord(row) + }, + + async markNoShows(args: { + cleanupId: string + seatIds: readonly string[] | null + now: Date + }): Promise { + const idFilter = + args.seatIds === null ? sql`` : sql`AND id = ANY(${[...args.seatIds]}::uuid[])` + const rows = await sql<{ id: string }[]>` + UPDATE cleanup_registration_seats + SET no_show_at = ${args.now} + WHERE cleanup_id = ${args.cleanupId} + AND status = 'active' + AND checked_in_at IS NULL + AND no_show_at IS NULL + ${idFilter} + RETURNING id + ` + return rows.length + }, + + async sweepNoShows(args: { now: Date; limit: number }): Promise { + const rows = await sql<{ id: string }[]>` + UPDATE cleanup_registration_seats s + SET no_show_at = ${args.now} + WHERE s.id IN ( + SELECT s2.id + FROM cleanup_registration_seats s2 + JOIN cleanups c ON c.id = s2.cleanup_id + WHERE s2.status = 'active' + AND s2.checked_in_at IS NULL + AND s2.no_show_at IS NULL + AND c.status <> 'cancelled' + AND c.ends_at <= ${args.now} - interval '2 hours' + ORDER BY s2.created_at + LIMIT ${args.limit} + ) + RETURNING s.id + ` + return rows.length + }, + + async checkinCounters(cleanupId: string): Promise { + const row = await checkinTotals(sql, cleanupId) + const byType = await checkinByTicketType(sql, cleanupId) + const arrivals = await arrivalBuckets(sql, cleanupId) + return { + registered: row?.registered ?? 0, + checkedIn: row?.checked_in ?? 0, + waitlisted: row?.waitlisted ?? 0, + noShow: row?.no_show ?? 0, + capacity: row?.capacity ?? null, + byTicketType: byType.map((t) => ({ + ticketTypeId: t.ticket_type_id, + name: t.name, + registered: t.registered, + checkedIn: t.checked_in, + waitlisted: t.waitlisted, + capacity: t.capacity, + })), + arrivals: arrivals.map((a) => ({ at: a.at, count: a.n })), + } + }, + + async hostedEventCounts( + cleanupIds: readonly string[], + ): Promise> { + const out = new Map() + if (cleanupIds.length === 0) return out + const rows = await sql< + { cleanup_id: string; registered: number; waitlisted: number; checked_in: number }[] + >` + SELECT c.id AS cleanup_id, + COALESCE(reg.n, 0) AS registered, + COALESCE(wl.n, 0) AS waitlisted, + COALESCE(ci.n, 0) AS checked_in + FROM unnest(${[...cleanupIds]}::uuid[]) AS c(id) + LEFT JOIN LATERAL ( + SELECT sum(r.party_size)::int AS n FROM cleanup_registrations r + WHERE r.cleanup_id = c.id AND r.status = 'registered' + ) reg ON true + LEFT JOIN LATERAL ( + SELECT sum(w.party_size)::int AS n FROM cleanup_waitlist w + WHERE w.cleanup_id = c.id AND w.status IN ('waiting', 'offered') + AND ${waitlistEntryNotBanned(sql)} + ) wl ON true + LEFT JOIN LATERAL ( + SELECT count(*)::int AS n FROM cleanup_registration_seats s + WHERE s.cleanup_id = c.id AND s.status = 'active' AND s.checked_in_at IS NOT NULL + ) ci ON true + ` + for (const row of rows) { + out.set(row.cleanup_id, { + registeredCount: row.registered, + waitlistCount: row.waitlisted, + checkedInCount: row.checked_in, + }) + } + return out + }, + } +} diff --git a/services/api/src/services/host/registration-dto.ts b/services/api/src/services/host/registration-dto.ts index a028db71..5616ae75 100644 --- a/services/api/src/services/host/registration-dto.ts +++ b/services/api/src/services/host/registration-dto.ts @@ -31,7 +31,7 @@ import { eventWindowOf, hasEventEnded } from "../cleanup-rules.js" export const REGISTRATION_ROSTER_DEFAULT_LIMIT = 25 -export function toRegistrantPerson(identity: RegistrantIdentity): PersonDTO { +function toRegistrantPerson(identity: RegistrantIdentity): PersonDTO { const userId = identity.userId as string if (identity.deletedAt !== null) { return { @@ -63,13 +63,17 @@ function iso(at: Date | null): string | null { return at === null ? null : at.toISOString() } -export function salesOpenAt(record: TicketTypeRecord, now: Date): boolean { +function dateOrNull(value: string | null | undefined): Date | null { + return value === null || value === undefined ? null : new Date(value) +} + +function salesOpenAt(record: TicketTypeRecord, now: Date): boolean { if (record.salesOpensAt !== null && now < record.salesOpensAt) return false if (record.salesClosesAt !== null && now >= record.salesClosesAt) return false return true } -export function remainingSeats(record: TicketTypeRecord): number | null { +function remainingSeats(record: TicketTypeRecord): number | null { if (record.capacity === null) return null return Math.max(record.capacity - record.reservedSeats, 0) } @@ -173,7 +177,7 @@ export function toEventRegistrationDTO( } } -export function toMyRegistrationRef( +function toMyRegistrationRef( record: RegistrationRecord, waitlistPosition: number | null, canCancel: boolean, @@ -285,12 +289,12 @@ export function registrationStateOf( return open.some((t) => t.waitlistEnabled) ? "waitlist" : "full" } -export interface AttachRegistrationFieldsDeps { +interface AttachRegistrationFieldsDeps { repo: HostRegistrationRepository now?: () => Date } -export async function attachRegistrationFieldsWith( +async function attachRegistrationFieldsWith( deps: AttachRegistrationFieldsDeps, dtos: CleanupDTO[], viewerUserId: string | null, @@ -313,15 +317,9 @@ export async function attachRegistrationFieldsWith( { status: dto.status, scheduledAt: new Date(dto.scheduledAt), - endsAt: dto.endsAt === null || dto.endsAt === undefined ? null : new Date(dto.endsAt), - registrationOpensAt: - dto.registrationOpensAt === null || dto.registrationOpensAt === undefined - ? null - : new Date(dto.registrationOpensAt), - registrationClosesAt: - dto.registrationClosesAt === null || dto.registrationClosesAt === undefined - ? null - : new Date(dto.registrationClosesAt), + endsAt: dateOrNull(dto.endsAt), + registrationOpensAt: dateOrNull(dto.registrationOpensAt), + registrationClosesAt: dateOrNull(dto.registrationClosesAt), }, types, now, diff --git a/services/api/src/services/host/registration-jobs.ts b/services/api/src/services/host/registration-jobs.ts index 186b9af3..5eda9b81 100644 --- a/services/api/src/services/host/registration-jobs.ts +++ b/services/api/src/services/host/registration-jobs.ts @@ -8,9 +8,7 @@ import { import { makeContainerRegistrationServices } from "./registration-wiring.js" import type { WaitlistPromoteJob } from "./waitlist-service.js" -export { CHECKIN_NOSHOW_SWEEP_JOB, WAITLIST_EXPIRE_SWEEP_JOB, WAITLIST_PROMOTE_JOB } - -export function parseWaitlistPromoteJob(data: unknown): WaitlistPromoteJob | null { +function parseWaitlistPromoteJob(data: unknown): WaitlistPromoteJob | null { if (typeof data !== "object" || data === null) return null const ticketTypeId = (data as { ticketTypeId?: unknown }).ticketTypeId if (typeof ticketTypeId !== "string" || ticketTypeId.length === 0) return null @@ -22,6 +20,7 @@ export async function registerRegistrationJobs( logger?: FastifyBaseLogger, ): Promise { const env = container.env + const services = () => makeContainerRegistrationServices(container, undefined, logger) await container.jobs.work(WAITLIST_PROMOTE_JOB, async (job) => { const data = parseWaitlistPromoteJob(job.data) @@ -29,11 +28,7 @@ export async function registerRegistrationJobs( logger?.warn({ jobId: job.id }, "waitlist.promote: malformed job data (skipped)") return } - const offered = await makeContainerRegistrationServices( - container, - undefined, - logger, - ).waitlist.runPromote(data) + const offered = await services().waitlist.runPromote(data) if (offered > 0) { logger?.info({ ticketTypeId: data.ticketTypeId, offered }, "waitlist.promote: offers sent") } @@ -41,21 +36,13 @@ export async function registerRegistrationJobs( await container.jobs.schedule(WAITLIST_EXPIRE_SWEEP_JOB, env.WAITLIST_EXPIRE_CRON) await container.jobs.work(WAITLIST_EXPIRE_SWEEP_JOB, async () => { - const expired = await makeContainerRegistrationServices( - container, - undefined, - logger, - ).waitlist.runExpireSweep() + const expired = await services().waitlist.runExpireSweep() if (expired > 0) logger?.info({ expired }, "waitlist.expire.sweep: offers released") }) await container.jobs.schedule(CHECKIN_NOSHOW_SWEEP_JOB, env.CHECKIN_NOSHOW_CRON) await container.jobs.work(CHECKIN_NOSHOW_SWEEP_JOB, async () => { - const marked = await makeContainerRegistrationServices( - container, - undefined, - logger, - ).checkin.runNoShowSweep() + const marked = await services().checkin.runNoShowSweep() if (marked > 0) logger?.info({ marked }, "checkin.noshow.sweep: seats marked no-show") }) } diff --git a/services/api/src/services/host/registration-load.drizzle.ts b/services/api/src/services/host/registration-load.drizzle.ts new file mode 100644 index 00000000..0769318e --- /dev/null +++ b/services/api/src/services/host/registration-load.drizzle.ts @@ -0,0 +1,238 @@ +import type { Queryable } from "../../db/client.js" +import { cleanupStatusExpr } from "../cleanup-sql.js" +import { + pageColumns, + pageJoins, + questionColumns, + registrationColumns, + registrationJoins, + seatColumns, + ticketTypeColumns, + ticketTypeJoins, + toPageRecord, + toQuestionRecord, + toRegistrationRecord, + toSeatRecord, + toTicketTypeRecord, + toWaitlistRecord, + waitlistColumns, + waitlistJoins, + type PageRowSelect, + type QuestionRowSelect, + type RegistrationRowSelect, + type SeatRowSelect, + type TicketTypeRowSelect, + type WaitlistRowSelect, +} from "./registration-sql.js" +import type { + EventRegistrationContext, + PageRecord, + QuestionRecord, + RegistrationRecord, + RegistrationSubject, + SeatRecord, + TicketTypeRecord, + WaitlistRecord, +} from "./registration-repository.types.js" + +interface EventContextRow { + id: string + status: EventRegistrationContext["status"] + visibility: EventRegistrationContext["visibility"] + capacity: number | null + title: string + description: string | null + reference_code: string | null + lat: number + lng: number + scheduled_at: Date + ends_at: Date | null + timezone: string | null + address: string | null + registration_opens_at: Date | null + registration_closes_at: Date | null + page_slug: string | null + organizer_user_id: string + organization_id: string | null +} + +export function subjectIs(tag: Queryable, subject: RegistrationSubject) { + return subject.kind === "user" + ? tag`user_id = ${subject.userId}` + : tag`guest_id = ${subject.guestId}` +} + +export async function isBannedIn( + tag: Queryable, + cleanupId: string, + userId: string, +): Promise { + const banned = await tag<{ one: number }[]>` + SELECT 1 AS one FROM cleanup_bans + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} + LIMIT 1 + ` + return banned.length > 0 +} + +export async function loadTicketTypes( + tag: Queryable, + cleanupIds: readonly string[], +): Promise { + if (cleanupIds.length === 0) return [] + const rows = await tag` + SELECT ${ticketTypeColumns(tag)} + FROM cleanup_ticket_types t + ${ticketTypeJoins(tag)} + WHERE t.cleanup_id = ANY(${[...cleanupIds]}::uuid[]) + ORDER BY t.cleanup_id, t.sort_order, t.id + ` + return rows.map(toTicketTypeRecord) +} + +export async function loadTicketType( + tag: Queryable, + cleanupId: string, + ticketTypeId: string, +): Promise { + const rows = await tag` + SELECT ${ticketTypeColumns(tag)} + FROM cleanup_ticket_types t + ${ticketTypeJoins(tag)} + WHERE t.id = ${ticketTypeId} AND t.cleanup_id = ${cleanupId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : toTicketTypeRecord(row) +} + +async function loadSeats( + tag: Queryable, + registrationIds: readonly string[], +): Promise> { + const out = new Map() + if (registrationIds.length === 0) return out + const rows = await tag` + SELECT ${seatColumns(tag)} + FROM cleanup_registration_seats + WHERE registration_id = ANY(${[...registrationIds]}::uuid[]) + ORDER BY registration_id, seat_index + ` + for (const row of rows) { + const record = toSeatRecord(row) + const bucket = out.get(record.registrationId) + if (bucket === undefined) out.set(record.registrationId, [record]) + else bucket.push(record) + } + return out +} + +export async function hydrate( + tag: Queryable, + rows: readonly RegistrationRowSelect[], +): Promise { + const seats = await loadSeats( + tag, + rows.map((r) => r.id), + ) + return rows.map((r) => toRegistrationRecord(r, seats.get(r.id) ?? [])) +} + +export async function loadRegistrationById( + tag: Queryable, + cleanupId: string, + registrationId: string, +): Promise { + const rows = await tag` + SELECT ${registrationColumns(tag)} + FROM cleanup_registrations r + ${registrationJoins(tag)} + WHERE r.id = ${registrationId} AND r.cleanup_id = ${cleanupId} + LIMIT 1 + ` + const hydrated = await hydrate(tag, rows) + return hydrated[0] ?? null +} + +function toEventContext(row: EventContextRow): EventRegistrationContext { + return { + cleanupId: row.id, + status: row.status, + visibility: row.visibility, + capacity: row.capacity, + title: row.title, + description: row.description, + referenceCode: row.reference_code, + lat: row.lat, + lng: row.lng, + scheduledAt: row.scheduled_at, + endsAt: row.ends_at, + timezone: row.timezone, + address: row.address, + registrationOpensAt: row.registration_opens_at, + registrationClosesAt: row.registration_closes_at, + pageSlug: row.page_slug, + organizerUserId: row.organizer_user_id, + organizationId: row.organization_id, + } +} + +export async function eventContextIn( + tag: Queryable, + cleanupId: string, +): Promise { + const rows = await tag` + SELECT c.id, ${cleanupStatusExpr(tag)} AS status, c.visibility, c.capacity, c.title, + c.description, c.reference_code, + ST_X(c.geom) AS lng, ST_Y(c.geom) AS lat, + c.scheduled_at, c.ends_at, c.timezone, c.address, + c.registration_opens_at, c.registration_closes_at, c.page_slug, + c.organizer_user_id, c.organization_id + FROM cleanups c + WHERE c.id = ${cleanupId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : toEventContext(row) +} + +export async function loadWaitlistEntry( + tag: Queryable, + cleanupId: string, + waitlistId: string, +): Promise { + const rows = await tag` + SELECT ${waitlistColumns(tag)} + FROM cleanup_waitlist w + ${waitlistJoins(tag)} + WHERE w.id = ${waitlistId} AND w.cleanup_id = ${cleanupId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : toWaitlistRecord(row) +} + +export async function loadPage(tag: Queryable, cleanupId: string): Promise { + const rows = await tag` + SELECT ${pageColumns(tag)} + FROM cleanups c + ${pageJoins(tag)} + WHERE c.id = ${cleanupId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : toPageRecord(row) +} + +export async function loadLiveQuestions( + tag: Queryable, + cleanupId: string, +): Promise { + const rows = await tag` + SELECT ${questionColumns(tag)} + FROM cleanup_questions + WHERE cleanup_id = ${cleanupId} AND archived_at IS NULL + ORDER BY sort_order, id + ` + return rows.map(toQuestionRecord) +} diff --git a/services/api/src/services/host/registration-pages.drizzle.ts b/services/api/src/services/host/registration-pages.drizzle.ts new file mode 100644 index 00000000..d5674344 --- /dev/null +++ b/services/api/src/services/host/registration-pages.drizzle.ts @@ -0,0 +1,258 @@ +import type { Queryable, Sql, TransactionSql } from "../../db/client.js" +import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" +import { publicServedKeyExpr } from "../media-served-key.js" +import { + isUniqueViolationOn, + pageColumns, + pageJoins, + toPageRecord, + type PageRowSelect, +} from "./registration-sql.js" +import { + eventContextIn, + loadLiveQuestions, + loadPage, + loadTicketTypes, +} from "./registration-load.drizzle.js" +import type { + HostRegistrationRepository, + PageRecord, + PublicPageRecord, + PublishPageOutcome, + SavePageArgs, + SavePageOutcome, +} from "./registration-repository.types.js" + +const PAGE_SLUG_CONSTRAINT = "cleanups_page_slug_uidx" + +const DEFAULT_THEME_ACCENT = "bloom" + +export type PageMethods = Pick< + HostRegistrationRepository, + "getPage" | "mediaKeysFor" | "savePage" | "publishPage" | "slugTaken" | "getPublicPage" +> + +type PublishPageArgs = Parameters[0] + +async function claimPageMediaInTx( + tx: Queryable, + cleanupId: string, + mediaIds: readonly string[], + asCover: "event_cover" | null, + claimantUserId: string, +): Promise { + const wanted = [...new Set(mediaIds)] + if (wanted.length === 0) return [] + const claimed = await tx<{ id: string }[]>` + UPDATE media_assets + SET purpose = ${ + asCover === null + ? tx`CASE WHEN media_assets.purpose IN ('event_cover', 'event_gallery') + THEN media_assets.purpose ELSE 'event_gallery' END` + : tx`'event_cover'` + } + WHERE media_assets.id = ANY(${wanted}::uuid[]) + AND media_assets.purpose <> 'verification' + AND media_assets.report_id IS NULL + AND media_assets.post_id IS NULL + AND media_assets.chat_message_id IS NULL + AND ( + media_assets.status = 'ready' + OR (media_assets.status = 'validating' AND media_assets.finalized_at IS NOT NULL) + ) + AND NOT (${mediaBoundElsewhere(tx, cleanupId)}) + AND ( + (${mediaBoundToCleanup(tx, cleanupId)}) + OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) + ) + RETURNING media_assets.id + ` + return claimed.map((row) => row.id) +} + +async function claimCoverIn(tx: TransactionSql, args: SavePageArgs): Promise { + if (args.coverMediaId === undefined) return true + if (args.coverMediaId !== null) { + const claimed = await claimPageMediaInTx( + tx, + args.cleanupId, + [args.coverMediaId], + "event_cover", + args.actorUserId, + ) + if (claimed.length !== 1) return false + } + await tx` + UPDATE cleanups SET cover_media_id = ${args.coverMediaId} + WHERE id = ${args.cleanupId} + ` + return true +} + +async function syncPageMediaIn( + tx: TransactionSql, + cleanupId: string, + blockIds: readonly string[], +): Promise { + await tx` + DELETE FROM cleanup_page_media + WHERE cleanup_id = ${cleanupId} + AND NOT (media_id = ANY(${blockIds}::uuid[])) + ` + if (blockIds.length === 0) return + await tx` + INSERT INTO cleanup_page_media (cleanup_id, media_id) + SELECT ${cleanupId}::uuid, t.id FROM unnest(${blockIds}::uuid[]) AS t(id) + ON CONFLICT (cleanup_id, media_id) DO NOTHING + ` +} + +async function upsertPageRowIn(tx: TransactionSql, args: SavePageArgs): Promise { + const blocks = tx.json(args.blocks as unknown as Parameters[0]) + const seo = + args.seo === undefined ? null : tx.json(args.seo as unknown as Parameters[0]) + await tx` + INSERT INTO cleanup_pages ( + cleanup_id, status, theme_accent, blocks, seo, created_at, updated_at + ) VALUES ( + ${args.cleanupId}, 'draft', ${args.themeAccent ?? DEFAULT_THEME_ACCENT}, ${blocks}, + COALESCE(${seo}, '{}'::jsonb), ${args.now}, ${args.now} + ) + ON CONFLICT (cleanup_id) DO UPDATE SET + theme_accent = COALESCE(${args.themeAccent ?? null}, cleanup_pages.theme_accent), + blocks = EXCLUDED.blocks, + seo = COALESCE(${seo}, cleanup_pages.seo), + updated_at = ${args.now} + ` +} + +async function savePageIn(tx: TransactionSql, args: SavePageArgs): Promise { + const locked = await tx<{ id: string }[]>` + SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE + ` + if (locked[0] === undefined) return { kind: "not_found" } + + const blockIds = [...new Set(args.blockMediaIds)] + if (blockIds.length > 0) { + const claimed = await claimPageMediaInTx(tx, args.cleanupId, blockIds, null, args.actorUserId) + if (claimed.length !== blockIds.length) return { kind: "block_media_not_found" } + } + + if (!(await claimCoverIn(tx, args))) return { kind: "cover_not_found" } + await syncPageMediaIn(tx, args.cleanupId, blockIds) + + if (args.slug !== undefined) { + await tx` + UPDATE cleanups SET page_slug = ${args.slug} WHERE id = ${args.cleanupId} + ` + } + + await upsertPageRowIn(tx, args) + + const record = await loadPage(tx, args.cleanupId) + if (record === null) return { kind: "not_found" } + return { kind: "saved", record } +} + +async function pageRefusalAfterNoUpdate( + sql: Sql, + args: PublishPageArgs, +): Promise { + if (!args.published) return { kind: "not_found" } + const flagged = await sql<{ flagged_at: Date | null }[]>` + SELECT flagged_at FROM cleanup_pages WHERE cleanup_id = ${args.cleanupId} LIMIT 1 + ` + const row = flagged[0] + return row !== undefined && row.flagged_at !== null ? { kind: "flagged" } : { kind: "not_found" } +} + +export function makePageMethods(sql: Sql): PageMethods { + return { + async getPage(cleanupId: string): Promise { + return loadPage(sql, cleanupId) + }, + + async mediaKeysFor( + cleanupId: string, + mediaIds: readonly string[], + ): Promise> { + const out = new Map() + if (mediaIds.length === 0) return out + const rows = await sql<{ id: string; served_key: string }[]>` + SELECT media_assets.id, ${publicServedKeyExpr(sql, "media_assets")} AS served_key + FROM media_assets + WHERE media_assets.id = ANY(${[...new Set(mediaIds)]}::uuid[]) + AND media_assets.status = 'ready' + AND media_assets.served_key IS NOT NULL + AND (${mediaBoundToCleanup(sql, cleanupId)}) + ` + for (const row of rows) out.set(row.id, row.served_key) + return out + }, + + async savePage(args: SavePageArgs): Promise { + try { + return await sql.begin((tx) => savePageIn(tx, args)) + } catch (err) { + if (isUniqueViolationOn(err, PAGE_SLUG_CONSTRAINT)) return { kind: "slug_taken" } + throw err + } + }, + + async publishPage(args: PublishPageArgs): Promise { + // The flag is re-checked in the write: an operator may flag the page after the service read it. + const rows = await sql<{ cleanup_id: string }[]>` + UPDATE cleanup_pages + SET status = ${args.published ? "published" : "unpublished"}, + published_at = ${args.published ? args.now : sql`published_at`}, + published_by = ${args.published ? args.actorId : sql`published_by`}, + updated_at = ${args.now} + WHERE cleanup_id = ${args.cleanupId} + AND (NOT ${args.published} OR flagged_at IS NULL) + RETURNING cleanup_id + ` + if (rows.length === 0) return pageRefusalAfterNoUpdate(sql, args) + const record = await loadPage(sql, args.cleanupId) + return record === null ? { kind: "not_found" } : { kind: "published", record } + }, + + async slugTaken(cleanupId: string, slug: string): Promise { + const rows = await sql<{ id: string }[]>` + SELECT id FROM cleanups + WHERE page_slug = ${slug} AND id <> ${cleanupId} + LIMIT 1 + ` + return rows.length > 0 + }, + + async getPublicPage(slug: string): Promise { + const rows = await sql< + (PageRowSelect & { donation_url: string | null; logo_key: string | null })[] + >` + SELECT ${pageColumns(sql)}, + c.donation_url, + ${publicServedKeyExpr(sql, "lm")} AS logo_key + FROM cleanups c + ${pageJoins(sql)} + LEFT JOIN organizations o ON o.id = c.organization_id AND o.deleted_at IS NULL + LEFT JOIN media_assets lm ON lm.id = o.logo_media_id AND lm.status = 'ready' + WHERE c.page_slug = ${slug} + LIMIT 1 + ` + const row = rows[0] + if (row === undefined) return null + const event = await eventContextIn(sql, row.cleanup_id) + if (event === null) return null + return { + page: toPageRecord(row), + event, + ticketTypes: await loadTicketTypes(sql, [row.cleanup_id]), + questions: await loadLiveQuestions(sql, row.cleanup_id), + organizationId: event.organizationId, + donationUrl: row.donation_url, + logoKey: row.logo_key, + } + }, + } +} diff --git a/services/api/src/services/host/registration-questions.drizzle.ts b/services/api/src/services/host/registration-questions.drizzle.ts new file mode 100644 index 00000000..7a7ef251 --- /dev/null +++ b/services/api/src/services/host/registration-questions.drizzle.ts @@ -0,0 +1,121 @@ +import type { Sql, TransactionSql } from "../../db/client.js" +import { + questionColumns, + toAnswerRecord, + toQuestionRecord, + type AnswerRowSelect, + type QuestionRowSelect, +} from "./registration-sql.js" +import { loadLiveQuestions } from "./registration-load.drizzle.js" +import type { + AnswerRecord, + DesiredQuestion, + HostRegistrationRepository, + QuestionRecord, +} from "./registration-repository.types.js" + +export type QuestionMethods = Pick< + HostRegistrationRepository, + "listQuestions" | "reconcileQuestions" | "listAnswers" +> + +async function upsertQuestion( + tx: TransactionSql, + cleanupId: string, + q: DesiredQuestion, + now: Date, +): Promise { + const options = tx.json(q.options as Parameters[0]) + const showIf = q.showIf === null ? null : tx.json(q.showIf as Parameters[0]) + if (q.id !== null) { + await tx` + UPDATE cleanup_questions SET + ticket_type_id = ${q.ticketTypeId}, + kind = ${q.kind}, + prompt = ${q.prompt}, + help_text = ${q.helpText}, + required = ${q.required}, + options = ${options}, + max_selections = ${q.maxSelections}, + consent_text = ${q.consentText}, + show_if = ${showIf}, + sort_order = ${q.sortOrder}, + archived_at = NULL, + updated_at = ${now} + WHERE id = ${q.id} AND cleanup_id = ${cleanupId} + ` + return + } + await tx` + INSERT INTO cleanup_questions ( + cleanup_id, ticket_type_id, kind, prompt, help_text, required, + options, max_selections, consent_text, show_if, sort_order, created_at, updated_at + ) VALUES ( + ${cleanupId}, ${q.ticketTypeId}, ${q.kind}, ${q.prompt}, ${q.helpText}, + ${q.required}, ${options}, ${q.maxSelections}, ${q.consentText}, ${showIf}, + ${q.sortOrder}, ${now}, ${now} + ) + ` +} + +export function makeQuestionMethods(sql: Sql): QuestionMethods { + return { + async listQuestions( + cleanupId: string, + opts?: { ticketTypeId?: string | null; includeArchived?: boolean }, + ): Promise { + const archivedFilter = opts?.includeArchived === true ? sql`` : sql`AND archived_at IS NULL` + const scoped = opts !== undefined && "ticketTypeId" in opts + const ticketTypeId = opts?.ticketTypeId ?? null + const typeFilter = !scoped + ? sql`` + : ticketTypeId === null + ? sql`AND ticket_type_id IS NULL` + : sql`AND (ticket_type_id IS NULL OR ticket_type_id = ${ticketTypeId})` + const rows = await sql` + SELECT ${questionColumns(sql)} + FROM cleanup_questions + WHERE cleanup_id = ${cleanupId} + ${archivedFilter} + ${typeFilter} + ORDER BY sort_order, id + ` + return rows.map(toQuestionRecord) + }, + + async reconcileQuestions( + cleanupId: string, + desired: readonly DesiredQuestion[], + now: Date, + ): Promise { + return sql.begin(async (tx) => { + const locked = await tx<{ id: string }[]>` + SELECT id FROM cleanups WHERE id = ${cleanupId} LIMIT 1 FOR SHARE + ` + if (locked[0] === undefined) return [] + + const keep = desired.map((q) => q.id).filter((id): id is string => id !== null) + await tx` + UPDATE cleanup_questions + SET archived_at = ${now}, updated_at = ${now} + WHERE cleanup_id = ${cleanupId} + AND archived_at IS NULL + AND NOT (id = ANY(${keep}::uuid[])) + ` + for (const q of desired) await upsertQuestion(tx, cleanupId, q, now) + return loadLiveQuestions(tx, cleanupId) + }) + }, + + async listAnswers(cleanupId: string, registrationId: string): Promise { + const rows = await sql` + SELECT a.question_id, q.prompt, a.value_text, a.value_json, a.scrubbed_at + FROM cleanup_answers a + JOIN cleanup_questions q ON q.id = a.question_id + WHERE a.cleanup_id = ${cleanupId} AND a.registration_id = ${registrationId} + ORDER BY q.sort_order, q.id + ` + return rows.map(toAnswerRecord) + }, + } +} diff --git a/services/api/src/services/host/registration-queues.ts b/services/api/src/services/host/registration-queues.ts index cdfafdc9..7e4799dc 100644 --- a/services/api/src/services/host/registration-queues.ts +++ b/services/api/src/services/host/registration-queues.ts @@ -9,5 +9,3 @@ export const REGISTRATION_QUEUE_NAMES = [ WAITLIST_EXPIRE_SWEEP_JOB, CHECKIN_NOSHOW_SWEEP_JOB, ] as const - -export type RegistrationQueueName = (typeof REGISTRATION_QUEUE_NAMES)[number] diff --git a/services/api/src/services/host/registration-register.drizzle.ts b/services/api/src/services/host/registration-register.drizzle.ts new file mode 100644 index 00000000..880770f7 --- /dev/null +++ b/services/api/src/services/host/registration-register.drizzle.ts @@ -0,0 +1,615 @@ +import { AppError } from "@civfix/shared" +import type { EventVisibility } from "@civfix/shared" +import type { Queryable, Sql, TransactionSql } from "../../db/client.js" +import { constantTimeStringEqual } from "../../auth/crypto.js" +import { deterministicUuid } from "../deterministic-uuid.js" +import { isEventPubliclyVisible } from "./authz.js" +import { isReservedSeatsBackstopViolation, isUniqueViolationOn } from "./registration-sql.js" +import { isBannedIn, loadRegistrationById, subjectIs } from "./registration-load.drizzle.js" +import type { + EventRegistrationContext, + HostRegistrationRepository, + RegisterSnapshot, + RegisterTxArgs, + RegisterTxOutcome, + RegistrationSubject, + TicketTypeRecord, + WalkupCheckIn, + WalkupRegisterArgs, +} from "./registration-repository.types.js" + +export const REGISTER_IDEMPOTENCY_SCOPE = "event.register" + +const SLOT_NOT_FOUND_MESSAGE = "That slot no longer exists." + +const SLOT_FULL_MESSAGE = "That slot is already full." + +const REGISTER_IDEMPOTENCY_CONSTRAINT = "idempotency_key_scope_owner_uk" + +const ACTIVE_REGISTRATION_CONSTRAINTS = [ + "cleanup_registrations_active_user_uidx", + "cleanup_registrations_active_guest_uidx", +] + +export type RegisterMethods = Pick + +interface LockedRegistrationEvent { + status: EventRegistrationContext["status"] + visibility: EventRegistrationContext["visibility"] + registration_opens_at: Date | null + registration_closes_at: Date | null + capacity: number | null +} + +interface RegisterTicketTypeRow { + id: string + capacity: number | null + reserved_seats: number + sales_opens_at: Date | null + sales_closes_at: Date | null + visibility: TicketTypeRecord["visibility"] + access_code_hash: string | null + max_party_size: number +} + +type TicketTypePick = { refusal: RegisterTxOutcome } | { ticketType: RegisterTicketTypeRow | null } + +function ticketTypeNotFound(): TicketTypePick { + return { refusal: { kind: "ticket_type_not_found" } } +} + +class RegistrationRefusal extends Error { + readonly outcome: RegisterTxOutcome + + constructor(outcome: RegisterTxOutcome) { + super("registration refused after the transaction began writing") + this.name = "RegistrationRefusal" + this.outcome = outcome + } +} + +export function subjectOwner(subject: RegistrationSubject): string { + return subject.kind === "user" ? `user:${subject.userId}` : `guest:${subject.guestId}` +} + +// A guest has no standing that could open a private event, so a guest's own sign-up answers exactly as an +// unknown event does. A host seating a walk-up, and a guest already waiting when the event went private, +// act on standing the event granted earlier and keep working. +export function guestSelfRegistrationOnPrivateEvent( + args: Pick, + visibility: EventVisibility, +): boolean { + return ( + args.subject.kind === "guest" && args.source === "self" && !isEventPubliclyVisible(visibility) + ) +} + +export function withinSalesWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { + if (opensAt !== null && now < opensAt) return false + if (closesAt !== null && now >= closesAt) return false + return true +} + +/** + * Takes the slot row lock before counting, as the standalone slot claim in cleanup-repository does, so + * registrations on different ticket types (which share no other lock) cannot both fill the last place. + * A refusal throws so the whole registration rolls back rather than succeeding without the slot. + */ +async function claimSlotIn( + tx: TransactionSql, + args: { cleanupId: string; userId: string; slotId: string }, +): Promise { + const slots = await tx<{ capacity: number | null }[]>` + SELECT capacity FROM cleanup_slots + WHERE id = ${args.slotId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 + FOR UPDATE + ` + const slot = slots[0] + if (slot === undefined) throw AppError.notFound(SLOT_NOT_FOUND_MESSAGE) + + const mine = await tx<{ slot_id: string }[]>` + SELECT slot_id FROM cleanup_slot_claims + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} + LIMIT 1 + ` + if (mine[0]?.slot_id === args.slotId) return + + if (slot.capacity !== null) { + const counted = await tx<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_slot_claims WHERE slot_id = ${args.slotId} + ` + if ((counted[0]?.n ?? 0) >= slot.capacity) throw AppError.conflict(SLOT_FULL_MESSAGE) + } + + await tx` + INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id) + VALUES (${args.cleanupId}, ${args.userId}, ${args.slotId}) + ON CONFLICT (cleanup_id, user_id) + DO UPDATE SET slot_id = EXCLUDED.slot_id, claimed_at = now() + ` +} + +function idempotencyOwnerOf(args: RegisterTxArgs): string { + return args.idempotencyOwner ?? subjectOwner(args.subject) +} + +function registerIdempotencyKey(args: RegisterTxArgs): string { + return deterministicUuid([ + REGISTER_IDEMPOTENCY_SCOPE, + idempotencyOwnerOf(args), + args.idempotencyKey, + ]) +} + +async function findRegisterSnapshot( + tag: Queryable, + args: RegisterTxArgs, +): Promise { + const rows = await tag<{ response_snapshot: RegisterSnapshot }[]>` + SELECT response_snapshot FROM idempotency_keys + WHERE key = ${registerIdempotencyKey(args)} + AND scope = ${REGISTER_IDEMPOTENCY_SCOPE} + AND user_or_anon IS NOT DISTINCT FROM ${idempotencyOwnerOf(args)} + LIMIT 1 + ` + return rows[0]?.response_snapshot +} + +async function replayOf( + tag: Queryable, + args: RegisterTxArgs, + snapshot: RegisterSnapshot, +): Promise { + return { + kind: "replayed", + registration: await loadRegistrationById(tag, args.cleanupId, snapshot.registrationId), + } +} + +async function replaySnapshot(tag: Queryable, args: RegisterTxArgs): Promise { + const snapshot = await findRegisterSnapshot(tag, args) + if (snapshot === undefined) return { kind: "already_registered" } + return replayOf(tag, args, snapshot) +} + +export async function registerErrorOutcome( + tag: Queryable, + err: unknown, + args: RegisterTxArgs, +): Promise { + if (err instanceof RegistrationRefusal) return err.outcome + if (isUniqueViolationOn(err, REGISTER_IDEMPOTENCY_CONSTRAINT)) return replaySnapshot(tag, args) + // A concurrent twin carrying the same key blocks on the active-registration index and fails + // there before its idempotency insert can; once the winner commits its snapshot is readable. + if (isUniqueViolationOn(err, ...ACTIVE_REGISTRATION_CONSTRAINTS)) return replaySnapshot(tag, args) + if (isReservedSeatsBackstopViolation(err)) { + throw AppError.internal( + "Registration could not be completed. The capacity guard rejected the write.", + ) + } + return null +} + +// A same-key twin (a double tap) waits here until the first attempt commits, then reads its snapshot +// and replays; without it the twin could queue on a seat lock and answer "full" to the user who holds +// the seat. Only twins share this lock, and it precedes every lock registerIn takes, so it adds no +// lock-order edge. +async function lockRegisterIdempotency(tx: TransactionSql, args: RegisterTxArgs): Promise { + await tx` + SELECT pg_advisory_xact_lock(hashtext('register_idempotency:' || ${registerIdempotencyKey(args)})) + ` +} + +async function lockRegistrationEvent( + tx: TransactionSql, + cleanupId: string, +): Promise { + const locked = await tx` + SELECT status, visibility, registration_opens_at, registration_closes_at, capacity + FROM cleanups WHERE id = ${cleanupId} LIMIT 1 FOR SHARE + ` + return locked[0] +} + +async function eventGateRefusal( + tx: TransactionSql, + event: LockedRegistrationEvent, + args: RegisterTxArgs, +): Promise { + if (event.status === "cancelled") return { kind: "closed" } + if (!withinSalesWindow(args.now, event.registration_opens_at, event.registration_closes_at)) { + return { kind: "registration_closed" } + } + if (args.subject.kind === "user" && (await isBannedIn(tx, args.cleanupId, args.subject.userId))) { + return { kind: "banned" } + } + const active = await tx<{ id: string }[]>` + SELECT id FROM cleanup_registrations + WHERE cleanup_id = ${args.cleanupId} + AND status = 'registered' + AND ${subjectIs(tx, args.subject)} + LIMIT 1 + ` + return active.length > 0 ? { kind: "already_registered" } : null +} + +function selectTicketType( + types: readonly RegisterTicketTypeRow[], + args: RegisterTxArgs, +): TicketTypePick { + if (args.ticketTypeId === null && types.length > 1) return ticketTypeNotFound() + const ticketType = + args.ticketTypeId === null ? (types[0] ?? null) : types.find((t) => t.id === args.ticketTypeId) + if (ticketType === undefined) return ticketTypeNotFound() + if (ticketType !== null && ticketType.visibility === "hidden" && args.source === "self") { + return ticketTypeNotFound() + } + return { ticketType } +} + +async function pickTicketType(tx: TransactionSql, args: RegisterTxArgs): Promise { + const types = await tx` + SELECT id, capacity, reserved_seats, sales_opens_at, sales_closes_at, + visibility, access_code_hash, max_party_size + FROM cleanup_ticket_types + WHERE cleanup_id = ${args.cleanupId} + ORDER BY sort_order, id + ` + return selectTicketType(types, args) +} + +function ticketTypeGateRefusal( + ticketType: RegisterTicketTypeRow, + args: RegisterTxArgs, + partySize: number, +): RegisterTxOutcome | null { + if (ticketType.visibility === "access_code" && args.waitlistId === null) { + if (args.accessCodeHash === null) return { kind: "access_code_required" } + if ( + ticketType.access_code_hash === null || + !constantTimeStringEqual(args.accessCodeHash, ticketType.access_code_hash) + ) { + return { kind: "access_code_invalid" } + } + } + if (partySize > ticketType.max_party_size) return { kind: "party_too_large" } + if ( + args.waitlistId === null && + !withinSalesWindow(args.now, ticketType.sales_opens_at, ticketType.sales_closes_at) + ) { + return { kind: "sales_closed" } + } + return null +} + +/** A claimed waitlist offer already holds its seats, so only a fresh sign-up reserves capacity here. */ +async function reserveCapacity( + tx: TransactionSql, + args: RegisterTxArgs, + eventCapacity: number | null, + ticketType: RegisterTicketTypeRow | null, + partySize: number, +): Promise { + if (args.waitlistId !== null) return null + if (ticketType === null) { + if (eventCapacity === null) return null + await tx`SELECT pg_advisory_xact_lock(hashtext('event_capacity:' || ${args.cleanupId}))` + const taken = await tx<{ held: number }[]>` + SELECT COALESCE(sum(party_size), 0)::int AS held + FROM cleanup_registrations + WHERE cleanup_id = ${args.cleanupId} AND status = 'registered' + ` + return (taken[0]?.held ?? 0) + partySize > eventCapacity ? { kind: "full" } : null + } + const reserved = await tx<{ id: string }[]>` + UPDATE cleanup_ticket_types + SET reserved_seats = reserved_seats + ${partySize}, updated_at = ${args.now} + WHERE id = ${ticketType.id} + AND cleanup_id = ${args.cleanupId} + AND ${partySize} <= max_party_size + AND (sales_opens_at IS NULL OR sales_opens_at <= ${args.now}) + AND (sales_closes_at IS NULL OR sales_closes_at > ${args.now}) + AND (capacity IS NULL OR reserved_seats + ${partySize} <= capacity) + RETURNING id + ` + if (reserved.length === 0) throw new RegistrationRefusal({ kind: "full" }) + return null +} + +async function insertRegistrationRow( + tx: TransactionSql, + args: RegisterTxArgs, + ticketTypeId: string | null, +): Promise { + const inserted = await tx<{ id: string }[]>` + INSERT INTO cleanup_registrations ( + cleanup_id, ticket_type_id, user_id, guest_id, party_size, status, source, registered_at + ) VALUES ( + ${args.cleanupId}, + ${ticketTypeId}, + ${args.subject.kind === "user" ? args.subject.userId : null}, + ${args.subject.kind === "guest" ? args.subject.guestId : null}, + ${args.seats.length}, + 'registered', + ${args.source}, + ${args.now} + ) + RETURNING id + ` + const registrationId = inserted[0]?.id + if (registrationId === undefined) throw new Error("registration insert returned no row") + return registrationId +} + +async function insertSeatRows( + tx: TransactionSql, + args: RegisterTxArgs, + registrationId: string, +): Promise { + const seatRows = args.seats.map((seat, index) => ({ + id: seat.id, + cleanup_id: args.cleanupId, + registration_id: registrationId, + seat_index: index, + attendee_name: seat.attendeeName, + ticket_token_hash: seat.tokenHash, + created_at: args.now, + })) + await tx` + INSERT INTO cleanup_registration_seats ${tx( + seatRows, + "id", + "cleanup_id", + "registration_id", + "seat_index", + "attendee_name", + "ticket_token_hash", + "created_at", + )} + ` +} + +async function insertAnswerRows( + tx: TransactionSql, + args: RegisterTxArgs, + registrationId: string, +): Promise { + if (args.answers.length === 0) return + const answerRows = args.answers.map((answer) => ({ + cleanup_id: args.cleanupId, + registration_id: registrationId, + question_id: answer.questionId, + value_text: answer.valueText, + value_json: + answer.valueJson === null ? null : tx.json(answer.valueJson as Parameters[0]), + created_at: args.now, + })) + await tx` + INSERT INTO cleanup_answers ${tx( + answerRows, + "cleanup_id", + "registration_id", + "question_id", + "value_text", + "value_json", + "created_at", + )} + ` +} + +async function insertConsentRow( + tx: TransactionSql, + args: RegisterTxArgs, + registrationId: string, +): Promise { + if (args.consent === null) return + await tx` + INSERT INTO event_consents ( + cleanup_id, subject_type, user_id, guest_id, registration_id, + terms_version, disclosure_version, host_contact_opt_in, sms_opt_in, surface, + accepted_at + ) VALUES ( + ${args.cleanupId}, + ${args.subject.kind}, + ${args.subject.kind === "user" ? args.subject.userId : null}, + ${args.subject.kind === "guest" ? args.subject.guestId : null}, + ${registrationId}, + ${args.consent.termsVersion}, + ${args.consent.disclosureVersion}, + ${args.consent.hostContactOptIn}, + ${args.consent.smsOptIn}, + ${args.consent.surface}, + ${args.now} + ) + ` +} + +async function insertRegistrationRows( + tx: TransactionSql, + args: RegisterTxArgs, + ticketTypeId: string | null, +): Promise { + if (args.subject.kind === "user") { + await tx` + INSERT INTO cleanup_members (cleanup_id, user_id, role) + VALUES (${args.cleanupId}, ${args.subject.userId}, 'member') + ON CONFLICT (cleanup_id, user_id) DO NOTHING + ` + } + const registrationId = await insertRegistrationRow(tx, args, ticketTypeId) + await insertSeatRows(tx, args, registrationId) + await insertAnswerRows(tx, args, registrationId) + await insertConsentRow(tx, args, registrationId) + return registrationId +} + +async function linkWaitlistEntry( + tx: TransactionSql, + args: RegisterTxArgs, + registrationId: string, +): Promise { + if (args.waitlistId === null) return + await tx` + UPDATE cleanup_waitlist + SET promoted_registration_id = ${registrationId} + WHERE id = ${args.waitlistId} + AND cleanup_id = ${args.cleanupId} + AND status = 'claimed' + ` +} + +async function recordRegisterIdempotency( + tx: TransactionSql, + args: RegisterTxArgs, + registrationId: string, +): Promise { + const snapshot: RegisterSnapshot = { registrationId } + await tx` + INSERT INTO idempotency_keys (key, scope, user_or_anon, response_snapshot) + VALUES ( + ${registerIdempotencyKey(args)}, + ${REGISTER_IDEMPOTENCY_SCOPE}, + ${idempotencyOwnerOf(args)}, + ${tx.json(snapshot as unknown as Parameters[0])} + ) + ` +} + +/** + * Every step shares the transaction. A refusal found before the first write returns; one found after it + * throws, so the writes roll back. + */ +export async function registerIn( + tx: TransactionSql, + args: RegisterTxArgs, +): Promise { + await lockRegisterIdempotency(tx, args) + const event = await lockRegistrationEvent(tx, args.cleanupId) + if (event === undefined || guestSelfRegistrationOnPrivateEvent(args, event.visibility)) { + return { kind: "not_found" } + } + + // A retry of a committed registration replays it even when a gate below has closed since. + const replay = await findRegisterSnapshot(tx, args) + if (replay !== undefined) return replayOf(tx, args, replay) + + const eventRefusal = await eventGateRefusal(tx, event, args) + if (eventRefusal !== null) return eventRefusal + + const picked = await pickTicketType(tx, args) + if ("refusal" in picked) return picked.refusal + const { ticketType } = picked + const partySize = args.seats.length + const typeRefusal = + ticketType === null ? null : ticketTypeGateRefusal(ticketType, args, partySize) + if (typeRefusal !== null) return typeRefusal + + const capacityRefusal = await reserveCapacity(tx, args, event.capacity, ticketType, partySize) + if (capacityRefusal !== null) return capacityRefusal + + // Ticket type, then slot, then member and registration rows: the standalone slot claim takes the + // slot before its member and registration writes, so the reverse order here could deadlock. + if (args.slotId !== null && args.subject.kind === "user") { + await claimSlotIn(tx, { + cleanupId: args.cleanupId, + userId: args.subject.userId, + slotId: args.slotId, + }) + } + + const registrationId = await insertRegistrationRows(tx, args, ticketType?.id ?? null) + await linkWaitlistEntry(tx, args, registrationId) + await recordRegisterIdempotency(tx, args, registrationId) + + const registration = await loadRegistrationById(tx, args.cleanupId, registrationId) + if (registration === null) throw new Error("registration reload returned no row") + return { kind: "registered", registration } +} + +async function insertWalkupGuest(tx: TransactionSql, args: WalkupRegisterArgs): Promise { + const rows = await tx<{ id: string }[]>` + INSERT INTO cleanup_guests ( + cleanup_id, name, channel, email, phone, contact_key, manage_token_hash, + verified_at, contact_scrubbed_at + ) VALUES ( + ${args.cleanupId}, ${args.name}, 'email', NULL, NULL, NULL, + ${args.manageTokenHash}, ${args.now}, ${args.now} + ) + RETURNING id + ` + const guestId = rows[0]?.id + if (guestId === undefined) throw new Error("walk-up guest insert returned no row") + return guestId +} + +function walkupRegisterArgs(args: WalkupRegisterArgs, guestId: string): RegisterTxArgs { + return { + cleanupId: args.cleanupId, + subject: { kind: "guest", guestId }, + ticketTypeId: args.ticketTypeId, + seats: args.seats, + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "walkup", + idempotencyKey: args.idempotencyKey, + idempotencyOwner: args.idempotencyOwner, + waitlistId: null, + now: args.now, + } +} + +async function checkInWalkupSeats( + tx: TransactionSql, + args: WalkupRegisterArgs, + registrationId: string, + checkIn: WalkupCheckIn, +): Promise { + await tx` + UPDATE cleanup_registration_seats + SET checked_in_at = ${args.now}, + checked_in_by = ${checkIn.actorId}, + checkin_method = ${checkIn.method} + WHERE registration_id = ${registrationId} + AND cleanup_id = ${args.cleanupId} + AND status = 'active' + AND checked_in_at IS NULL + ` +} + +export function makeRegisterMethods(sql: Sql): RegisterMethods { + return { + async registerTx(args: RegisterTxArgs): Promise { + try { + return await sql.begin((tx) => registerIn(tx, args)) + } catch (err) { + const mapped = await registerErrorOutcome(sql, err, args) + if (mapped !== null) return mapped + throw err + } + }, + + async registerWalkupTx(args: WalkupRegisterArgs): Promise { + let registerArgs: RegisterTxArgs | null = null + try { + return await sql.begin(async (tx) => { + registerArgs = walkupRegisterArgs(args, await insertWalkupGuest(tx, args)) + const outcome = await registerIn(tx, registerArgs) + if (outcome.kind !== "registered") throw new RegistrationRefusal(outcome) + if (args.checkIn === undefined || args.checkIn === null) return outcome + await checkInWalkupSeats(tx, args, outcome.registration.id, args.checkIn) + const checkedIn = await loadRegistrationById(tx, args.cleanupId, outcome.registration.id) + if (checkedIn === null) throw new Error("walk-up reload returned no row") + return { kind: "registered" as const, registration: checkedIn } + }) + } catch (err) { + if (registerArgs === null) throw err + const mapped = await registerErrorOutcome(sql, err, registerArgs) + if (mapped !== null) return mapped + throw err + } + }, + } +} diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 9bffe939..99e26086 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -1,2854 +1,32 @@ -import { AppError } from "@civfix/shared" -import type { CheckinMethod, EventVisibility, RegistrationRosterSort } from "@civfix/shared" -import type { Queryable, Sql, TransactionSql } from "../../db/client.js" -import { constantTimeStringEqual } from "../../auth/crypto.js" -import { - encodeKeysetCursor, - encodeNameCursor, - keysetInstant, - keysetPredicate, - pageWith, - paginateKeyset, - parseKeysetCursor, - parseNameCursor, -} from "../../db/cursor-helpers.js" -import { DEFAULT_EVENT_DURATION_MS, eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" -import { cleanupStatusExpr } from "../cleanup-sql.js" -import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "../media-bindings.js" -import { userUploader } from "../media-uploader.js" -import { likeContains } from "../admin/like.js" -import { isEventPubliclyVisible } from "./authz.js" -import { publicServedKeyExpr } from "../media-served-key.js" -import { deterministicUuid } from "../deterministic-uuid.js" -import { - isCheckViolationOn, - isReservedSeatsBackstopViolation, - isUniqueViolationOn, - questionColumns, - registrationColumns, - registrationJoins, - SALES_WINDOW_CONSTRAINT, - seatColumns, - ticketTypeColumns, - ticketTypeJoins, - toAnswerRecord, - toPageRecord, - toQuestionRecord, - toRegistrationRecord, - toSeatRecord, - toTicketTypeRecord, - toWaitlistRecord, - waitlistColumns, - waitlistEntryNotBanned, - waitlistJoins, - type AnswerRowSelect, - type PageRowSelect, - type QuestionRowSelect, - type RegistrationRowSelect, - type SeatRowSelect, - type TicketTypeRowSelect, - type WaitlistRowSelect, -} from "./registration-sql.js" +import type { Sql } from "../../db/client.js" +import { eventContextIn } from "./registration-load.drizzle.js" +import { makeTicketTypeMethods } from "./registration-ticket-types.drizzle.js" +import { makeQuestionMethods } from "./registration-questions.drizzle.js" +import { makeRegisterMethods } from "./registration-register.drizzle.js" +import { makeRosterMethods } from "./registration-roster.drizzle.js" +import { makeWaitlistMethods } from "./registration-waitlist.drizzle.js" +import { makeCheckinMethods } from "./registration-checkin.drizzle.js" +import { makePageMethods } from "./registration-pages.drizzle.js" import type { - AnswerRecord, - CancelRegistrationOutcome, - RemoveRegistrationOutcome, - CheckinCountersRecord, - CheckinResultRecord, - ClaimWaitlistOutcome, - CreateTicketTypeOutcome, - DeleteTicketTypeOutcome, - DesiredQuestion, EventRegistrationContext, HostRegistrationRepository, - HostedEventCounts, - JoinWaitlistOutcome, - PageRecord, - PublicPageRecord, - PublishPageOutcome, - QuestionRecord, - RegisterSnapshot, - RegisterTxArgs, - RegisterTxOutcome, - RegistrationRecord, - RegistrationSubject, - ReorderTicketTypesOutcome, - RosterPage, - RosterQuery, - SavePageArgs, - SavePageOutcome, - SeatRecord, - TicketTypeCapacityFit, - TicketTypeRecord, - TicketTypeWriteArgs, - TransferRegistrationOutcome, - UpdateTicketTypeOutcome, - WalkupRegisterArgs, - WaitlistOffer, - WaitlistRecord, } from "./registration-repository.types.js" -export const MAX_TICKET_TYPES = 20 - -export const REGISTER_IDEMPOTENCY_SCOPE = "event.register" - -export const ARRIVAL_BUCKET_MINUTES = 15 - -const DEFAULT_EVENT_DURATION_SEC = DEFAULT_EVENT_DURATION_MS / 1000 - -export const SLOT_NOT_FOUND_MESSAGE = "That slot no longer exists." - -export const SLOT_FULL_MESSAGE = "That slot is already full." - -const ACTIVE_REGISTRATION_CONSTRAINTS = [ - "cleanup_registrations_active_user_uidx", - "cleanup_registrations_active_guest_uidx", -] - -const rosterCheckedInKey = (tag: Sql) => tag`COALESCE(ci.first_at, 'epoch'::timestamptz)` - -const ROSTER_SORTS = Object.freeze({ - registered_at_desc: (tag: Sql) => tag`r.registered_at DESC, r.id DESC`, - registered_at_asc: (tag: Sql) => tag`r.registered_at ASC, r.id ASC`, - name_asc: (tag: Sql) => - tag`lower(COALESCE(u.display_name, g.name, '')) ASC, r.registered_at DESC, r.id DESC`, - checked_in_at_desc: (tag: Sql) => - tag`${rosterCheckedInKey(tag)} DESC, r.registered_at DESC, r.id DESC`, -}) satisfies Readonly unknown>> - -/** - * Both instants are the microsecond text Postgres rendered (or a legacy cursor spelled), bound back as - * text: a Date anchor drops the microseconds and skips every row sharing the anchor's millisecond. - */ -interface CheckedInRosterCursor { - checkedInAtText: string - registeredAtText: string | null - id: string -} - -type RosterRowSelect = RegistrationRowSelect & { cursor_at: string; checked_in_cursor_at: string } - -// Carries every ORDER BY key: everyone not yet checked in shares the epoch sort key, so a cursor -// without registered_at cannot say where inside that tie the previous page stopped. -function encodeCheckedInRosterCursor(row: RosterRowSelect): string { - return `${row.checked_in_cursor_at}|${encodeKeysetCursor(row.cursor_at, row.id)}` -} - -function parseCheckedInRosterCursor(cursor: string): CheckedInRosterCursor | null { - const parts = cursor.split("|") - if (parts.length <= 2) { - const legacy = parseKeysetCursor(cursor, { direction: "desc" }) - return legacy === null - ? null - : { checkedInAtText: legacy.atText, registeredAtText: null, id: legacy.id } - } - if (parts.length !== 3) return null - const [checkedInText, registeredText, id] = parts - const checkedIn = parseKeysetCursor(`${checkedInText}|${id}`) - const registered = parseKeysetCursor(`${registeredText}|${id}`) - if (checkedIn === null || registered === null) return null - return { - checkedInAtText: checkedIn.atText, - registeredAtText: registered.atText, - id: registered.id, - } -} - -async function isBannedIn(tag: Queryable, cleanupId: string, userId: string): Promise { - const banned = await tag<{ one: number }[]>` - SELECT 1 AS one FROM cleanup_bans - WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} - LIMIT 1 - ` - return banned.length > 0 -} - -/** - * Takes the slot row lock before counting, as the standalone slot claim in cleanup-repository does, so - * registrations on different ticket types (which share no other lock) cannot both fill the last place. - * A refusal throws so the whole registration rolls back rather than succeeding without the slot. - */ -async function claimSlotIn( - tx: TransactionSql, - args: { cleanupId: string; userId: string; slotId: string }, -): Promise { - const slots = await tx<{ capacity: number | null }[]>` - SELECT capacity FROM cleanup_slots - WHERE id = ${args.slotId} AND cleanup_id = ${args.cleanupId} - LIMIT 1 - FOR UPDATE - ` - const slot = slots[0] - if (slot === undefined) throw AppError.notFound(SLOT_NOT_FOUND_MESSAGE) - - const mine = await tx<{ slot_id: string }[]>` - SELECT slot_id FROM cleanup_slot_claims - WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} - LIMIT 1 - ` - if (mine[0]?.slot_id === args.slotId) return - - if (slot.capacity !== null) { - const counted = await tx<{ n: number }[]>` - SELECT count(*)::int AS n FROM cleanup_slot_claims WHERE slot_id = ${args.slotId} - ` - if ((counted[0]?.n ?? 0) >= slot.capacity) throw AppError.conflict(SLOT_FULL_MESSAGE) - } - - await tx` - INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id) - VALUES (${args.cleanupId}, ${args.userId}, ${args.slotId}) - ON CONFLICT (cleanup_id, user_id) - DO UPDATE SET slot_id = EXCLUDED.slot_id, claimed_at = now() - ` -} - -/** - * An EXISTS probe, not a join: a join under FOR UPDATE would also lock the cleanups row and invert the - * cleanups -> cleanup_ticket_types -> cleanup_waitlist lock order every writer takes. The end instant - * mirrors hasEventEnded (ends_at, else scheduled_at plus the default duration) on the DB clock. - */ -function waitlistEventStillLive(tag: Queryable) { - return tag`EXISTS ( - SELECT 1 FROM cleanups c WHERE c.id = w.cleanup_id - AND c.status <> 'cancelled' - AND COALESCE(c.ends_at, c.scheduled_at + make_interval(secs => ${DEFAULT_EVENT_DURATION_SEC})) - > now() - )` -} - -export async function cancelWaitlistEntriesIn( - tag: Queryable, - args: { - cleanupId: string - ticketTypeId: string | null - subject: RegistrationSubject - now: Date - }, -): Promise<{ left: number; releasedTicketTypeIds: string[] }> { - const typeFilter = - args.ticketTypeId === null ? tag`` : tag`AND ticket_type_id = ${args.ticketTypeId}` - const rows = await tag<{ id: string; released_ticket_type_id: string | null }[]>` - WITH left_entries AS ( - UPDATE cleanup_waitlist - SET status = 'cancelled' - WHERE cleanup_id = ${args.cleanupId} - AND status IN ('waiting', 'offered') - ${typeFilter} - AND ${ - args.subject.kind === "user" - ? tag`user_id = ${args.subject.userId}` - : tag`guest_id = ${args.subject.guestId}` - } - RETURNING id, ticket_type_id, party_size, offered_at - ), released AS ( - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), - updated_at = ${args.now} - FROM left_entries e - WHERE t.id = e.ticket_type_id AND e.offered_at IS NOT NULL - RETURNING t.id - ) - SELECT id, - CASE WHEN offered_at IS NULL THEN NULL ELSE ticket_type_id END - AS released_ticket_type_id - FROM left_entries - ` - return { - left: rows.length, - releasedTicketTypeIds: [ - ...new Set( - rows.map((row) => row.released_ticket_type_id).filter((id): id is string => id !== null), - ), - ], - } -} - -export interface AppliedBan { - cancelledRegistrations: { id: string; ticketTypeId: string | null }[] - /** Ticket types that got seats back, from cancelled registrations or released waitlist offers. */ - releasedTicketTypeIds: string[] -} - -/** - * The one ban path behind removing an attendee from the event and removing them from the roster. - * The event row lock comes first: every registration, join and waitlist path reads the ban under - * FOR SHARE on the same row, so one racing the ban either commits before it (and is undone here) or - * reads the ban. Waitlist rows are cancelled before registrations, the cleanup_waitlist -> - * cleanup_ticket_types order the expiry sweep and leaveWaitlist take, so the two cannot deadlock. - */ -export async function applyBanIn( - tx: Queryable, - args: { cleanupId: string; userId: string; actorId: string; now: Date }, -): Promise { - await tx` - SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR NO KEY UPDATE - ` - await tx` - INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) - VALUES (${args.cleanupId}, ${args.userId}, ${args.actorId}) - ON CONFLICT (cleanup_id, user_id) DO NOTHING - ` - await tx` - DELETE FROM cleanup_members - WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} AND role = 'member' - ` - const waitlist = await cancelWaitlistEntriesIn(tx, { - cleanupId: args.cleanupId, - ticketTypeId: null, - subject: { kind: "user", userId: args.userId }, - now: args.now, - }) - const cancelled = await tx<{ id: string; ticket_type_id: string | null }[]>` - WITH cancelled AS ( - UPDATE cleanup_registrations - SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} - WHERE cleanup_id = ${args.cleanupId} - AND user_id = ${args.userId} - AND status = 'registered' - RETURNING id, ticket_type_id, party_size - ), seats AS ( - UPDATE cleanup_registration_seats s - SET status = 'cancelled' - FROM cancelled c - WHERE s.registration_id = c.id AND s.status = 'active' - RETURNING s.id - ), held AS ( - SELECT ticket_type_id, sum(party_size)::int AS seats - FROM cancelled - WHERE ticket_type_id IS NOT NULL - GROUP BY ticket_type_id - ), released AS ( - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - h.seats, 0), - updated_at = ${args.now} - FROM held h - WHERE t.id = h.ticket_type_id - RETURNING t.id - ) - SELECT id, ticket_type_id FROM cancelled - ` - await tx` - DELETE FROM cleanup_slot_claims - WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} - ` - const registrationTypes = cancelled - .map((row) => row.ticket_type_id) - .filter((id): id is string => id !== null) - return { - cancelledRegistrations: cancelled.map((row) => ({ - id: row.id, - ticketTypeId: row.ticket_type_id, - })), - releasedTicketTypeIds: [...new Set([...waitlist.releasedTicketTypeIds, ...registrationTypes])], - } -} - -class RegistrationRefusal extends Error { - readonly outcome: RegisterTxOutcome - - constructor(outcome: RegisterTxOutcome) { - super("registration refused after the transaction began writing") - this.name = "RegistrationRefusal" - this.outcome = outcome - } -} - -class ClaimRefusal extends Error { - readonly outcome: ClaimWaitlistOutcome - - constructor(outcome: ClaimWaitlistOutcome) { - super("waitlist claim refused after the transaction began writing") - this.name = "ClaimRefusal" - this.outcome = outcome - } -} - -async function claimPageMediaInTx( - tx: Queryable, - cleanupId: string, - mediaIds: readonly string[], - asCover: "event_cover" | null, - claimantUserId: string, -): Promise { - const wanted = [...new Set(mediaIds)] - if (wanted.length === 0) return [] - const claimed = await tx<{ id: string }[]>` - UPDATE media_assets - SET purpose = ${ - asCover === null - ? tx`CASE WHEN media_assets.purpose IN ('event_cover', 'event_gallery') - THEN media_assets.purpose ELSE 'event_gallery' END` - : tx`'event_cover'` - } - WHERE media_assets.id = ANY(${wanted}::uuid[]) - AND media_assets.purpose <> 'verification' - AND media_assets.report_id IS NULL - AND media_assets.post_id IS NULL - AND media_assets.chat_message_id IS NULL - AND ( - media_assets.status = 'ready' - OR (media_assets.status = 'validating' AND media_assets.finalized_at IS NOT NULL) - ) - AND NOT (${mediaBoundElsewhere(tx, cleanupId)}) - AND ( - (${mediaBoundToCleanup(tx, cleanupId)}) - OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) - ) - RETURNING media_assets.id - ` - return claimed.map((row) => row.id) -} - -function subjectOwner(subject: RegistrationSubject): string { - return subject.kind === "user" ? `user:${subject.userId}` : `guest:${subject.guestId}` -} - -export function registrationIdempotencyOwner(subject: RegistrationSubject): string { - return subjectOwner(subject) -} - -// A guest has no standing that could open a private event, so a guest's own sign-up answers exactly as an -// unknown event does. A host seating a walk-up, and a guest already waiting when the event went private, -// act on standing the event granted earlier and keep working. -export function guestSelfRegistrationOnPrivateEvent( - args: Pick, - visibility: EventVisibility, -): boolean { - return ( - args.subject.kind === "guest" && args.source === "self" && !isEventPubliclyVisible(visibility) - ) -} - -function withinSalesWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { - if (opensAt !== null && now < opensAt) return false - if (closesAt !== null && now >= closesAt) return false - return true -} - -export function emptyCheckinResult(outcome: CheckinResultRecord["outcome"]): CheckinResultRecord { - return { - outcome, - firstTime: false, - seat: null, - registration: null, - attendeeName: null, - ticketTypeName: null, - partySize: null, - checkedInAt: null, - } -} - -export function buildCheckinResult( - outcome: CheckinResultRecord["outcome"], - firstTime: boolean, - seat: SeatRecord, - registration: RegistrationRecord | null, -): CheckinResultRecord { - return { - outcome, - firstTime, - seat, - registration, - attendeeName: seat.attendeeName ?? registration?.guestName ?? null, - ticketTypeName: registration?.ticketTypeName ?? null, - partySize: registration?.partySize ?? null, - checkedInAt: seat.checkedInAt, - } -} - -export function waitlistEntryAsRegistration(entry: WaitlistRecord): RegistrationRecord { - return { - id: entry.id, - cleanupId: entry.cleanupId, - ticketTypeId: entry.ticketTypeId, - ticketTypeName: entry.ticketTypeName, - userId: entry.userId, - guestId: entry.guestId, - guestName: entry.guestName, - identity: entry.identity, - partySize: entry.partySize, - status: "registered", - source: "waitlist", - hostNote: null, - registeredAt: entry.createdAt, - cancelledAt: null, - checkedInAt: null, - slotId: null, - slotTitle: null, - seats: [], - answersPreview: null, - } -} +export { REGISTER_IDEMPOTENCY_SCOPE } from "./registration-register.drizzle.js" +export { applyBanIn } from "./registration-roster.drizzle.js" +export { cancelWaitlistEntriesIn } from "./registration-waitlist.drizzle.js" export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistrationRepository { - async function loadTicketTypes( - tag: Queryable, - cleanupIds: readonly string[], - ): Promise { - if (cleanupIds.length === 0) return [] - const rows = await tag` - SELECT ${ticketTypeColumns(tag)} - FROM cleanup_ticket_types t - ${ticketTypeJoins(tag)} - WHERE t.cleanup_id = ANY(${[...cleanupIds]}::uuid[]) - ORDER BY t.cleanup_id, t.sort_order, t.id - ` - return rows.map(toTicketTypeRecord) - } - - async function loadTicketType( - tag: Queryable, - cleanupId: string, - ticketTypeId: string, - ): Promise { - const rows = await tag` - SELECT ${ticketTypeColumns(tag)} - FROM cleanup_ticket_types t - ${ticketTypeJoins(tag)} - WHERE t.id = ${ticketTypeId} AND t.cleanup_id = ${cleanupId} - LIMIT 1 - ` - const row = rows[0] - return row === undefined ? null : toTicketTypeRecord(row) - } - - async function loadSeats( - tag: Queryable, - registrationIds: readonly string[], - ): Promise> { - const out = new Map() - if (registrationIds.length === 0) return out - const rows = await tag` - SELECT ${seatColumns(tag)} - FROM cleanup_registration_seats - WHERE registration_id = ANY(${[...registrationIds]}::uuid[]) - ORDER BY registration_id, seat_index - ` - for (const row of rows) { - const record = toSeatRecord(row) - const bucket = out.get(record.registrationId) - if (bucket === undefined) out.set(record.registrationId, [record]) - else bucket.push(record) - } - return out - } - - async function hydrate( - tag: Queryable, - rows: readonly RegistrationRowSelect[], - ): Promise { - const seats = await loadSeats( - tag, - rows.map((r) => r.id), - ) - return rows.map((r) => toRegistrationRecord(r, seats.get(r.id) ?? [])) - } - - async function loadRegistrationById( - tag: Queryable, - cleanupId: string, - registrationId: string, - ): Promise { - const rows = await tag` - SELECT ${registrationColumns(tag)} - FROM cleanup_registrations r - ${registrationJoins(tag)} - WHERE r.id = ${registrationId} AND r.cleanup_id = ${cleanupId} - LIMIT 1 - ` - const hydrated = await hydrate(tag, rows) - return hydrated[0] ?? null - } - - async function eventContextIn( - tag: Queryable, - cleanupId: string, - ): Promise { - const rows = await tag< - { - id: string - status: EventRegistrationContext["status"] - visibility: EventRegistrationContext["visibility"] - capacity: number | null - title: string - description: string | null - reference_code: string | null - lat: number - lng: number - scheduled_at: Date - ends_at: Date | null - timezone: string | null - address: string | null - registration_opens_at: Date | null - registration_closes_at: Date | null - page_slug: string | null - organizer_user_id: string - organization_id: string | null - }[] - >` - SELECT c.id, ${cleanupStatusExpr(tag)} AS status, c.visibility, c.capacity, c.title, - c.description, c.reference_code, - ST_X(c.geom) AS lng, ST_Y(c.geom) AS lat, - c.scheduled_at, c.ends_at, c.timezone, c.address, - c.registration_opens_at, c.registration_closes_at, c.page_slug, - c.organizer_user_id, c.organization_id - FROM cleanups c - WHERE c.id = ${cleanupId} - LIMIT 1 - ` - const row = rows[0] - if (row === undefined) return null - return { - cleanupId: row.id, - status: row.status, - visibility: row.visibility, - capacity: row.capacity, - title: row.title, - description: row.description, - referenceCode: row.reference_code, - lat: row.lat, - lng: row.lng, - scheduledAt: row.scheduled_at, - endsAt: row.ends_at, - timezone: row.timezone, - address: row.address, - registrationOpensAt: row.registration_opens_at, - registrationClosesAt: row.registration_closes_at, - pageSlug: row.page_slug, - organizerUserId: row.organizer_user_id, - organizationId: row.organization_id, - } - } - - async function applyQuestionOwnership( - tag: Queryable, - cleanupId: string, - ticketTypeId: string, - questionIds: readonly string[], - ): Promise { - await tag` - UPDATE cleanup_questions - SET ticket_type_id = NULL, updated_at = now() - WHERE cleanup_id = ${cleanupId} - AND ticket_type_id = ${ticketTypeId} - AND NOT (id = ANY(${[...questionIds]}::uuid[])) - ` - if (questionIds.length === 0) return - await tag` - UPDATE cleanup_questions - SET ticket_type_id = ${ticketTypeId}, updated_at = now() - WHERE cleanup_id = ${cleanupId} - AND id = ANY(${[...questionIds]}::uuid[]) - ` - } - - async function ticketTypeCapacityFit( - tx: TransactionSql, - cleanupId: string, - eventCapacity: number | null, - nextCapacity: number | null, - excludeTicketTypeId: string | null, - ): Promise { - if (eventCapacity === null) return { ok: true } - const exclude = excludeTicketTypeId === null ? tx`` : tx`AND id <> ${excludeTicketTypeId}` - const rows = await tx<{ used: number | null; unlimited: boolean | null }[]>` - SELECT sum(capacity)::int AS used, bool_or(capacity IS NULL) AS unlimited - FROM cleanup_ticket_types - WHERE cleanup_id = ${cleanupId} - ${exclude} - ` - const used = rows[0]?.used ?? 0 - if (rows[0]?.unlimited === true) return { ok: false, eventCapacity, used } - if (nextCapacity === null) return { ok: false, eventCapacity, used } - if (used + nextCapacity > eventCapacity) return { ok: false, eventCapacity, used } - return { ok: true } - } - - async function loadWaitlistEntry( - tag: Queryable, - cleanupId: string, - waitlistId: string, - ): Promise { - const rows = await tag` - SELECT ${waitlistColumns(tag)} - FROM cleanup_waitlist w - ${waitlistJoins(tag)} - WHERE w.id = ${waitlistId} AND w.cleanup_id = ${cleanupId} - LIMIT 1 - ` - const row = rows[0] - return row === undefined ? null : toWaitlistRecord(row) - } - - async function releaseWaitlistHold( - tx: TransactionSql, - cleanupId: string, - waitlistId: string, - now: Date, - ): Promise { - await tx` - WITH expired AS ( - UPDATE cleanup_waitlist SET status = 'expired' - WHERE id = ${waitlistId} AND cleanup_id = ${cleanupId} - RETURNING ticket_type_id, party_size, offered_at - ) - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), - updated_at = ${now} - FROM expired e - WHERE t.id = e.ticket_type_id AND e.offered_at IS NOT NULL - ` - } - - async function classifyUnclaimable( - tx: TransactionSql, - cleanupId: string, - waitlistId: string, - subject: RegistrationSubject, - now: Date, - ): Promise { - const rows = await tx< - { - status: WaitlistRecord["status"] - claim_expires_at: Date | null - user_id: string | null - guest_id: string | null - promoted_registration_id: string | null - }[] - >` - SELECT status, claim_expires_at, user_id, guest_id, promoted_registration_id - FROM cleanup_waitlist - WHERE id = ${waitlistId} AND cleanup_id = ${cleanupId} - LIMIT 1 FOR UPDATE - ` - const row = rows[0] - if (row === undefined) return { kind: "not_found" } - const owns = - subject.kind === "user" ? row.user_id === subject.userId : row.guest_id === subject.guestId - if (row.status === "claimed" && owns && row.promoted_registration_id !== null) { - const registration = await loadRegistrationById(tx, cleanupId, row.promoted_registration_id) - if (registration !== null) return { kind: "claimed", registration } - } - if (row.status === "expired") return { kind: "expired" } - if (row.status !== "offered") return { kind: "not_offered" } - if (row.claim_expires_at !== null && row.claim_expires_at <= now) { - await releaseWaitlistHold(tx, cleanupId, waitlistId, now) - return { kind: "expired" } - } - return { kind: "not_offered" } - } - - async function listWaitlistIn( - tag: Queryable, - args: { - cleanupId: string - ticketTypeId: string | null - status: WaitlistRecord["status"] | null - cursor: string | null - limit: number - }, - ): Promise<{ rows: WaitlistRecord[]; nextCursor: string | null }> { - const typeFilter = - args.ticketTypeId === null ? tag`` : tag`AND w.ticket_type_id = ${args.ticketTypeId}` - const statusFilter = - args.status === null - ? tag`AND w.status IN ('waiting', 'offered')` - : tag`AND w.status = ${args.status}` - // A banned user can never be seated, so their open rows are not part of the host's queue; closed - // rows stay listed as history. - const bannedFilter = - args.status === null || args.status === "waiting" || args.status === "offered" - ? tag`AND ${waitlistEntryNotBanned(tag)}` - : tag`` - const cursor = parseKeysetCursor(args.cursor, { direction: "asc" }) - const cursorFilter = - cursor === null - ? tag`` - : tag`AND ${keysetPredicate(tag, tag`w.created_at`, tag`w.id`, cursor, { direction: "asc" })}` - const rows = await tag<(WaitlistRowSelect & { cursor_at: string })[]>` - SELECT ${waitlistColumns(tag)}, ${keysetInstant(tag, tag`w.created_at`)} AS cursor_at - FROM cleanup_waitlist w - ${waitlistJoins(tag)} - WHERE w.cleanup_id = ${args.cleanupId} - ${typeFilter} - ${statusFilter} - ${cursorFilter} - ${bannedFilter} - ORDER BY w.created_at ASC, w.id ASC - LIMIT ${args.limit + 1} - ` - const { items, nextCursor } = paginateKeyset(rows, args.limit, (last) => ({ - atText: last.cursor_at, - id: last.id, - })) - return { rows: items.map(toWaitlistRecord), nextCursor } - } - - async function loadPage(tag: Queryable, cleanupId: string): Promise { - const rows = await tag` - SELECT c.id AS cleanup_id, - c.page_slug AS slug, - COALESCE(p.status, 'draft') AS status, - COALESCE(p.theme_accent, 'bloom') AS theme_accent, - p.blocks, - p.seo, - c.cover_media_id, - ${publicServedKeyExpr(tag, "m")} AS cover_key, - c.visibility, - p.published_at, - p.updated_at, - p.flagged_at, - p.flag_reason, - COALESCE(p.view_count, 0) AS view_count - FROM cleanups c - LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id - LEFT JOIN media_assets m ON m.id = c.cover_media_id AND m.status = 'ready' - WHERE c.id = ${cleanupId} - LIMIT 1 - ` - const row = rows[0] - return row === undefined ? null : toPageRecord(row) - } - - function idempotencyOwnerOf(args: RegisterTxArgs): string { - return args.idempotencyOwner ?? subjectOwner(args.subject) - } - - function registerIdempotencyKey(args: RegisterTxArgs): string { - return deterministicUuid([ - REGISTER_IDEMPOTENCY_SCOPE, - idempotencyOwnerOf(args), - args.idempotencyKey, - ]) - } - - async function findRegisterSnapshot( - tag: Queryable, - args: RegisterTxArgs, - ): Promise { - const rows = await tag<{ response_snapshot: RegisterSnapshot }[]>` - SELECT response_snapshot FROM idempotency_keys - WHERE key = ${registerIdempotencyKey(args)} - AND scope = ${REGISTER_IDEMPOTENCY_SCOPE} - AND user_or_anon IS NOT DISTINCT FROM ${idempotencyOwnerOf(args)} - LIMIT 1 - ` - return rows[0]?.response_snapshot - } - - async function replayOf( - tag: Queryable, - args: RegisterTxArgs, - snapshot: RegisterSnapshot, - ): Promise { - return { - kind: "replayed", - registration: await loadRegistrationById(tag, args.cleanupId, snapshot.registrationId), - } - } - - async function replaySnapshot(tag: Queryable, args: RegisterTxArgs): Promise { - const snapshot = await findRegisterSnapshot(tag, args) - if (snapshot === undefined) return { kind: "already_registered" } - return replayOf(tag, args, snapshot) - } - - async function registerErrorOutcome( - err: unknown, - args: RegisterTxArgs, - ): Promise { - if (err instanceof RegistrationRefusal) return err.outcome - if (isUniqueViolationOn(err, "idempotency_key_scope_owner_uk")) return replaySnapshot(sql, args) - // A concurrent twin carrying the same key blocks on the active-registration index and fails - // there before its idempotency insert can; once the winner commits its snapshot is readable. - if (isUniqueViolationOn(err, ...ACTIVE_REGISTRATION_CONSTRAINTS)) - return replaySnapshot(sql, args) - if (isReservedSeatsBackstopViolation(err)) { - throw AppError.internal( - "Registration could not be completed. The capacity guard rejected the write.", - ) - } - return null - } - - async function registerIn(tx: TransactionSql, args: RegisterTxArgs): Promise { - const partySize = args.seats.length - // A same-key twin (a double tap) waits here until the first attempt commits, then reads its - // snapshot and replays; without it the twin could queue on a seat lock and answer "full" to the - // user who holds the seat. Only twins share this lock, and it precedes every lock taken below, so - // it adds no lock-order edge. - await tx` - SELECT pg_advisory_xact_lock(hashtext('register_idempotency:' || ${registerIdempotencyKey(args)})) - ` - const locked = await tx< - { - status: EventRegistrationContext["status"] - visibility: EventRegistrationContext["visibility"] - registration_opens_at: Date | null - registration_closes_at: Date | null - capacity: number | null - }[] - >` - SELECT status, visibility, registration_opens_at, registration_closes_at, capacity - FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE - ` - const event = locked[0] - if (event === undefined) return { kind: "not_found" as const } - if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) { - return { kind: "not_found" as const } - } - - // A retry of a committed registration replays it even when a gate below has closed since. - const replay = await findRegisterSnapshot(tx, args) - if (replay !== undefined) return replayOf(tx, args, replay) - - if (event.status === "cancelled") return { kind: "closed" as const } - if (!withinSalesWindow(args.now, event.registration_opens_at, event.registration_closes_at)) { - return { kind: "registration_closed" as const } - } - - if ( - args.subject.kind === "user" && - (await isBannedIn(tx, args.cleanupId, args.subject.userId)) - ) { - return { kind: "banned" as const } - } - - const active = await tx<{ id: string }[]>` - SELECT id FROM cleanup_registrations - WHERE cleanup_id = ${args.cleanupId} - AND status = 'registered' - AND ${ - args.subject.kind === "user" - ? tx`user_id = ${args.subject.userId}` - : tx`guest_id = ${args.subject.guestId}` - } - LIMIT 1 - ` - if (active.length > 0) return { kind: "already_registered" as const } - - const types = await tx< - { - id: string - capacity: number | null - reserved_seats: number - sales_opens_at: Date | null - sales_closes_at: Date | null - visibility: TicketTypeRecord["visibility"] - access_code_hash: string | null - max_party_size: number - }[] - >` - SELECT id, capacity, reserved_seats, sales_opens_at, sales_closes_at, - visibility, access_code_hash, max_party_size - FROM cleanup_ticket_types - WHERE cleanup_id = ${args.cleanupId} - ORDER BY sort_order, id - ` - - let ticketType: (typeof types)[number] | null = null - if (args.ticketTypeId !== null) { - ticketType = types.find((t) => t.id === args.ticketTypeId) ?? null - if (ticketType === null) return { kind: "ticket_type_not_found" as const } - } else if (types.length === 1) { - ticketType = types[0] ?? null - } else if (types.length > 1) { - return { kind: "ticket_type_not_found" as const } - } - - if (ticketType !== null && ticketType.visibility === "hidden" && args.source === "self") { - return { kind: "ticket_type_not_found" as const } - } - - if (ticketType !== null) { - if (ticketType.visibility === "access_code" && args.waitlistId === null) { - if (args.accessCodeHash === null) return { kind: "access_code_required" as const } - if ( - ticketType.access_code_hash === null || - !constantTimeStringEqual(args.accessCodeHash, ticketType.access_code_hash) - ) { - return { kind: "access_code_invalid" as const } - } - } - if (partySize > ticketType.max_party_size) { - return { kind: "party_too_large" as const } - } - if ( - args.waitlistId === null && - !withinSalesWindow(args.now, ticketType.sales_opens_at, ticketType.sales_closes_at) - ) { - return { kind: "sales_closed" as const } - } - } - - if (ticketType === null && event.capacity !== null && args.waitlistId === null) { - await tx`SELECT pg_advisory_xact_lock(hashtext('event_capacity:' || ${args.cleanupId}))` - const taken = await tx<{ held: number }[]>` - SELECT COALESCE(sum(party_size), 0)::int AS held - FROM cleanup_registrations - WHERE cleanup_id = ${args.cleanupId} AND status = 'registered' - ` - if ((taken[0]?.held ?? 0) + partySize > event.capacity) { - return { kind: "full" as const } - } - } - - if (ticketType !== null && args.waitlistId === null) { - const reserved = await tx<{ id: string }[]>` - UPDATE cleanup_ticket_types - SET reserved_seats = reserved_seats + ${partySize}, updated_at = ${args.now} - WHERE id = ${ticketType.id} - AND cleanup_id = ${args.cleanupId} - AND ${partySize} <= max_party_size - AND (sales_opens_at IS NULL OR sales_opens_at <= ${args.now}) - AND (sales_closes_at IS NULL OR sales_closes_at > ${args.now}) - AND (capacity IS NULL OR reserved_seats + ${partySize} <= capacity) - RETURNING id - ` - if (reserved.length === 0) { - throw new RegistrationRefusal({ kind: "full" }) - } - } - - // Ticket type, then slot, then member and registration rows: the standalone slot claim takes the - // slot before its member and registration writes, so the reverse order here could deadlock. - if (args.slotId !== null && args.subject.kind === "user") { - await claimSlotIn(tx, { - cleanupId: args.cleanupId, - userId: args.subject.userId, - slotId: args.slotId, - }) - } - - if (args.subject.kind === "user") { - await tx` - INSERT INTO cleanup_members (cleanup_id, user_id, role) - VALUES (${args.cleanupId}, ${args.subject.userId}, 'member') - ON CONFLICT (cleanup_id, user_id) DO NOTHING - ` - } - - const insertedRegistration = await tx<{ id: string }[]>` - INSERT INTO cleanup_registrations ( - cleanup_id, ticket_type_id, user_id, guest_id, party_size, status, source, registered_at - ) VALUES ( - ${args.cleanupId}, - ${ticketType === null ? null : ticketType.id}, - ${args.subject.kind === "user" ? args.subject.userId : null}, - ${args.subject.kind === "guest" ? args.subject.guestId : null}, - ${partySize}, - 'registered', - ${args.source}, - ${args.now} - ) - RETURNING id - ` - const registrationId = insertedRegistration[0]?.id - if (registrationId === undefined) { - throw new Error("registration insert returned no row") - } - - const seatRows = args.seats.map((seat, index) => ({ - id: seat.id, - cleanup_id: args.cleanupId, - registration_id: registrationId, - seat_index: index, - attendee_name: seat.attendeeName, - ticket_token_hash: seat.tokenHash, - created_at: args.now, - })) - await tx` - INSERT INTO cleanup_registration_seats ${tx( - seatRows, - "id", - "cleanup_id", - "registration_id", - "seat_index", - "attendee_name", - "ticket_token_hash", - "created_at", - )} - ` - - if (args.answers.length > 0) { - const answerRows = args.answers.map((answer) => ({ - cleanup_id: args.cleanupId, - registration_id: registrationId, - question_id: answer.questionId, - value_text: answer.valueText, - value_json: - answer.valueJson === null - ? null - : tx.json(answer.valueJson as Parameters[0]), - created_at: args.now, - })) - await tx` - INSERT INTO cleanup_answers ${tx( - answerRows, - "cleanup_id", - "registration_id", - "question_id", - "value_text", - "value_json", - "created_at", - )} - ` - } - - if (args.consent !== null) { - await tx` - INSERT INTO event_consents ( - cleanup_id, subject_type, user_id, guest_id, registration_id, - terms_version, disclosure_version, host_contact_opt_in, sms_opt_in, surface, - accepted_at - ) VALUES ( - ${args.cleanupId}, - ${args.subject.kind}, - ${args.subject.kind === "user" ? args.subject.userId : null}, - ${args.subject.kind === "guest" ? args.subject.guestId : null}, - ${registrationId}, - ${args.consent.termsVersion}, - ${args.consent.disclosureVersion}, - ${args.consent.hostContactOptIn}, - ${args.consent.smsOptIn}, - ${args.consent.surface}, - ${args.now} - ) - ` - } - - if (args.waitlistId !== null) { - await tx` - UPDATE cleanup_waitlist - SET promoted_registration_id = ${registrationId} - WHERE id = ${args.waitlistId} - AND cleanup_id = ${args.cleanupId} - AND status = 'claimed' - ` - } - - const snapshot: RegisterSnapshot = { registrationId } - await tx` - INSERT INTO idempotency_keys (key, scope, user_or_anon, response_snapshot) - VALUES ( - ${registerIdempotencyKey(args)}, - ${REGISTER_IDEMPOTENCY_SCOPE}, - ${idempotencyOwnerOf(args)}, - ${tx.json(snapshot as unknown as Parameters[0])} - ) - ` - - const registration = await loadRegistrationById(tx, args.cleanupId, registrationId) - if (registration === null) throw new Error("registration reload returned no row") - return { kind: "registered" as const, registration } - } - - async function cancelRegistrationIn( - tag: Queryable, - args: { cleanupId: string; registrationId: string; actorId: string | null; now: Date }, - ): Promise { - const cancelled = await tag<{ id: string; ticket_type_id: string | null }[]>` - WITH cancelled AS ( - UPDATE cleanup_registrations - SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} - WHERE id = ${args.registrationId} - AND cleanup_id = ${args.cleanupId} - AND status = 'registered' - RETURNING id, ticket_type_id, party_size - ), seats AS ( - UPDATE cleanup_registration_seats s - SET status = 'cancelled' - FROM cancelled c - WHERE s.registration_id = c.id AND s.status = 'active' - RETURNING s.id - ), released AS ( - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - c.party_size, 0), - updated_at = ${args.now} - FROM cancelled c - WHERE t.id = c.ticket_type_id - RETURNING t.id - ) - SELECT id, ticket_type_id FROM cancelled - ` - const row = cancelled[0] - if (row === undefined) { - const existing = await loadRegistrationById(tag, args.cleanupId, args.registrationId) - return existing === null ? { kind: "not_found" } : { kind: "already_cancelled" } - } - const registration = await loadRegistrationById(tag, args.cleanupId, row.id) - if (registration === null) return { kind: "not_found" } - return { kind: "cancelled", registration, ticketTypeId: row.ticket_type_id } - } - - async function runRegisterTx(args: RegisterTxArgs): Promise { - try { - return await sql.begin((tx) => registerIn(tx, args)) - } catch (err) { - const mapped = await registerErrorOutcome(err, args) - if (mapped !== null) return mapped - throw err - } - } - - const repo: HostRegistrationRepository = { + return { async eventContext(cleanupId: string): Promise { return eventContextIn(sql, cleanupId) }, - - async listTicketTypes(cleanupId: string): Promise { - return loadTicketTypes(sql, [cleanupId]) - }, - - async listTicketTypesFor( - cleanupIds: readonly string[], - ): Promise> { - const out = new Map() - for (const record of await loadTicketTypes(sql, cleanupIds)) { - const bucket = out.get(record.cleanupId) - if (bucket === undefined) out.set(record.cleanupId, [record]) - else bucket.push(record) - } - return out - }, - - async getTicketType(cleanupId: string, ticketTypeId: string): Promise { - return loadTicketType(sql, cleanupId, ticketTypeId) - }, - - async ticketTypeIdsMatchingAccessCode( - cleanupId: string, - accessCodeHash: string, - ): Promise { - const rows = await sql<{ id: string; access_code_hash: string | null }[]>` - SELECT id, access_code_hash FROM cleanup_ticket_types - WHERE cleanup_id = ${cleanupId} AND visibility = 'access_code' - ORDER BY sort_order, id - LIMIT ${MAX_TICKET_TYPES} - ` - return rows - .filter( - (row) => - row.access_code_hash !== null && - constantTimeStringEqual(accessCodeHash, row.access_code_hash), - ) - .map((row) => row.id) - }, - - async createTicketType(args: TicketTypeWriteArgs): Promise { - try { - return await sql.begin(async (tx) => { - const locked = await tx<{ id: string; capacity: number | null }[]>` - SELECT id, capacity FROM cleanups - WHERE id = ${args.cleanupId} - LIMIT 1 FOR NO KEY UPDATE - ` - const event = locked[0] - if (event === undefined) return { kind: "not_found" as const } - - const counted = await tx<{ n: number }[]>` - SELECT count(*)::int AS n FROM cleanup_ticket_types WHERE cleanup_id = ${args.cleanupId} - ` - if ((counted[0]?.n ?? 0) >= MAX_TICKET_TYPES) return { kind: "too_many" as const } - - const fit = await ticketTypeCapacityFit( - tx, - args.cleanupId, - event.capacity, - args.capacity, - null, - ) - if (!fit.ok) { - return { - kind: "capacity_exceeded" as const, - eventCapacity: fit.eventCapacity, - used: fit.used, - } - } - - const sortOrder = - args.sortOrder ?? - ( - await tx<{ next: number }[]>` - SELECT COALESCE(max(sort_order), -1) + 1 AS next - FROM cleanup_ticket_types WHERE cleanup_id = ${args.cleanupId} - ` - )[0]?.next ?? - 0 - - const inserted = await tx<{ id: string }[]>` - INSERT INTO cleanup_ticket_types ( - cleanup_id, name, description, capacity, sales_opens_at, sales_closes_at, - visibility, access_code_hash, max_party_size, sort_order, waitlist_enabled, - created_at, updated_at - ) VALUES ( - ${args.cleanupId}, ${args.name}, ${args.description}, ${args.capacity}, - ${args.salesOpensAt}, ${args.salesClosesAt}, ${args.visibility}, - ${args.accessCodeHash}, ${args.maxPartySize}, ${sortOrder}, ${args.waitlistEnabled}, - ${args.now}, ${args.now} - ) - RETURNING id - ` - const id = inserted[0]?.id - if (id === undefined) throw new Error("ticket type insert returned no row") - - if (args.questionIds !== null) { - await applyQuestionOwnership(tx, args.cleanupId, id, args.questionIds) - } - - const record = await loadTicketType(tx, args.cleanupId, id) - if (record === null) throw new Error("ticket type reload returned no row") - return { kind: "created" as const, record } - }) - } catch (err) { - if (isUniqueViolationOn(err, "cleanup_ticket_types_cleanup_name_uidx")) { - return { kind: "name_taken" } - } - if (isCheckViolationOn(err, SALES_WINDOW_CONSTRAINT)) return { kind: "sales_window" } - throw err - } - }, - - async updateTicketType( - args: TicketTypeWriteArgs & { ticketTypeId: string; patch: readonly string[] }, - ): Promise { - const patch = new Set(args.patch) - try { - return await sql.begin(async (tx) => { - const event = ( - await tx<{ id: string; capacity: number | null }[]>` - SELECT id, capacity FROM cleanups - WHERE id = ${args.cleanupId} - LIMIT 1 FOR NO KEY UPDATE - ` - )[0] - if (event === undefined) return { kind: "not_found" as const } - - const locked = await tx<{ reserved_seats: number }[]>` - SELECT reserved_seats FROM cleanup_ticket_types - WHERE id = ${args.ticketTypeId} AND cleanup_id = ${args.cleanupId} - LIMIT 1 FOR UPDATE - ` - const current = locked[0] - if (current === undefined) return { kind: "not_found" as const } - - if ( - patch.has("capacity") && - args.capacity !== null && - args.capacity < current.reserved_seats - ) { - return { - kind: "capacity_below_reserved" as const, - reservedSeats: current.reserved_seats, - } - } - - if (patch.has("capacity")) { - const fit = await ticketTypeCapacityFit( - tx, - args.cleanupId, - event.capacity, - args.capacity, - args.ticketTypeId, - ) - if (!fit.ok) { - return { - kind: "capacity_exceeded" as const, - eventCapacity: fit.eventCapacity, - used: fit.used, - } - } - } - - await tx` - UPDATE cleanup_ticket_types SET - name = ${patch.has("name") ? args.name : sql`name`}, - description = ${patch.has("description") ? args.description : sql`description`}, - capacity = ${patch.has("capacity") ? args.capacity : sql`capacity`}, - sales_opens_at = ${patch.has("salesOpensAt") ? args.salesOpensAt : sql`sales_opens_at`}, - sales_closes_at = ${patch.has("salesClosesAt") ? args.salesClosesAt : sql`sales_closes_at`}, - visibility = ${patch.has("visibility") ? args.visibility : sql`visibility`}, - access_code_hash = ${ - args.clearAccessCode - ? null - : args.accessCodeHash !== null - ? args.accessCodeHash - : sql`access_code_hash` - }, - max_party_size = ${patch.has("maxPartySize") ? args.maxPartySize : sql`max_party_size`}, - sort_order = ${patch.has("sortOrder") && args.sortOrder !== null ? args.sortOrder : sql`sort_order`}, - waitlist_enabled = ${patch.has("waitlistEnabled") ? args.waitlistEnabled : sql`waitlist_enabled`}, - updated_at = ${args.now} - WHERE id = ${args.ticketTypeId} AND cleanup_id = ${args.cleanupId} - ` - - if (args.questionIds !== null) { - await applyQuestionOwnership(tx, args.cleanupId, args.ticketTypeId, args.questionIds) - } - - const record = await loadTicketType(tx, args.cleanupId, args.ticketTypeId) - if (record === null) return { kind: "not_found" as const } - return { kind: "updated" as const, record } - }) - } catch (err) { - if (isUniqueViolationOn(err, "cleanup_ticket_types_cleanup_name_uidx")) { - return { kind: "name_taken" } - } - if (isCheckViolationOn(err, SALES_WINDOW_CONSTRAINT)) return { kind: "sales_window" } - throw err - } - }, - - async deleteTicketType( - cleanupId: string, - ticketTypeId: string, - ): Promise { - return sql.begin(async (tx) => { - const locked = await tx<{ id: string }[]>` - SELECT id FROM cleanup_ticket_types - WHERE id = ${ticketTypeId} AND cleanup_id = ${cleanupId} - LIMIT 1 FOR UPDATE - ` - if (locked[0] === undefined) return { kind: "not_found" as const } - - const referenced = await tx<{ one: number }[]>` - SELECT 1 AS one WHERE EXISTS ( - SELECT 1 FROM cleanup_registrations WHERE ticket_type_id = ${ticketTypeId} - ) OR EXISTS ( - SELECT 1 FROM cleanup_waitlist WHERE ticket_type_id = ${ticketTypeId} - ) - ` - if (referenced.length > 0) return { kind: "in_use" as const } - - await tx` - UPDATE cleanup_questions SET ticket_type_id = NULL, updated_at = now() - WHERE cleanup_id = ${cleanupId} AND ticket_type_id = ${ticketTypeId} - ` - await tx` - DELETE FROM cleanup_ticket_types - WHERE id = ${ticketTypeId} AND cleanup_id = ${cleanupId} - ` - return { kind: "deleted" as const } - }) - }, - - async reorderTicketTypes( - cleanupId: string, - ticketTypeIds: readonly string[], - now: Date, - ): Promise { - return sql.begin(async (tx) => { - const existing = await tx<{ id: string }[]>` - SELECT id FROM cleanup_ticket_types WHERE cleanup_id = ${cleanupId} ORDER BY id - ` - const have = new Set(existing.map((r) => r.id)) - const want = new Set(ticketTypeIds) - if (have.size !== want.size || [...want].some((id) => !have.has(id))) { - return { kind: "mismatch" as const } - } - for (const [index, id] of ticketTypeIds.entries()) { - await tx` - UPDATE cleanup_ticket_types SET sort_order = ${index}, updated_at = ${now} - WHERE id = ${id} AND cleanup_id = ${cleanupId} - ` - } - return { kind: "reordered" as const, items: await loadTicketTypes(tx, [cleanupId]) } - }) - }, - - async listQuestions( - cleanupId: string, - opts?: { ticketTypeId?: string | null; includeArchived?: boolean }, - ): Promise { - const archivedFilter = opts?.includeArchived === true ? sql`` : sql`AND archived_at IS NULL` - const scoped = opts !== undefined && "ticketTypeId" in opts - const ticketTypeId = opts?.ticketTypeId ?? null - const typeFilter = !scoped - ? sql`` - : ticketTypeId === null - ? sql`AND ticket_type_id IS NULL` - : sql`AND (ticket_type_id IS NULL OR ticket_type_id = ${ticketTypeId})` - const rows = await sql` - SELECT ${questionColumns(sql)} - FROM cleanup_questions - WHERE cleanup_id = ${cleanupId} - ${archivedFilter} - ${typeFilter} - ORDER BY sort_order, id - ` - return rows.map(toQuestionRecord) - }, - - async reconcileQuestions( - cleanupId: string, - desired: readonly DesiredQuestion[], - now: Date, - ): Promise { - return sql.begin(async (tx) => { - const locked = await tx<{ id: string }[]>` - SELECT id FROM cleanups WHERE id = ${cleanupId} LIMIT 1 FOR SHARE - ` - if (locked[0] === undefined) return [] - - const keep = desired.map((q) => q.id).filter((id): id is string => id !== null) - await tx` - UPDATE cleanup_questions - SET archived_at = ${now}, updated_at = ${now} - WHERE cleanup_id = ${cleanupId} - AND archived_at IS NULL - AND NOT (id = ANY(${keep}::uuid[])) - ` - - for (const q of desired) { - const options = tx.json(q.options as Parameters[0]) - const showIf = - q.showIf === null ? null : tx.json(q.showIf as Parameters[0]) - if (q.id !== null) { - await tx` - UPDATE cleanup_questions SET - ticket_type_id = ${q.ticketTypeId}, - kind = ${q.kind}, - prompt = ${q.prompt}, - help_text = ${q.helpText}, - required = ${q.required}, - options = ${options}, - max_selections = ${q.maxSelections}, - consent_text = ${q.consentText}, - show_if = ${showIf}, - sort_order = ${q.sortOrder}, - archived_at = NULL, - updated_at = ${now} - WHERE id = ${q.id} AND cleanup_id = ${cleanupId} - ` - } else { - await tx` - INSERT INTO cleanup_questions ( - cleanup_id, ticket_type_id, kind, prompt, help_text, required, - options, max_selections, consent_text, show_if, sort_order, created_at, updated_at - ) VALUES ( - ${cleanupId}, ${q.ticketTypeId}, ${q.kind}, ${q.prompt}, ${q.helpText}, - ${q.required}, ${options}, ${q.maxSelections}, ${q.consentText}, ${showIf}, - ${q.sortOrder}, ${now}, ${now} - ) - ` - } - } - - const rows = await tx` - SELECT ${questionColumns(tx)} - FROM cleanup_questions - WHERE cleanup_id = ${cleanupId} AND archived_at IS NULL - ORDER BY sort_order, id - ` - return rows.map(toQuestionRecord) - }) - }, - - async registerWalkupTx(args: WalkupRegisterArgs): Promise { - let registerArgs: RegisterTxArgs | null = null - try { - return await sql.begin(async (tx) => { - const rows = await tx<{ id: string }[]>` - INSERT INTO cleanup_guests ( - cleanup_id, name, channel, email, phone, contact_key, manage_token_hash, - verified_at, contact_scrubbed_at - ) VALUES ( - ${args.cleanupId}, ${args.name}, 'email', NULL, NULL, NULL, - ${args.manageTokenHash}, ${args.now}, ${args.now} - ) - RETURNING id - ` - const guestId = rows[0]?.id - if (guestId === undefined) throw new Error("walk-up guest insert returned no row") - registerArgs = { - cleanupId: args.cleanupId, - subject: { kind: "guest", guestId }, - ticketTypeId: args.ticketTypeId, - seats: args.seats, - accessCodeHash: null, - answers: [], - consent: null, - slotId: null, - source: "walkup", - idempotencyKey: args.idempotencyKey, - idempotencyOwner: args.idempotencyOwner, - waitlistId: null, - now: args.now, - } - const outcome = await registerIn(tx, registerArgs) - if (outcome.kind !== "registered") throw new RegistrationRefusal(outcome) - if (args.checkIn === undefined || args.checkIn === null) return outcome - await tx` - UPDATE cleanup_registration_seats - SET checked_in_at = ${args.now}, - checked_in_by = ${args.checkIn.actorId}, - checkin_method = ${args.checkIn.method} - WHERE registration_id = ${outcome.registration.id} - AND cleanup_id = ${args.cleanupId} - AND status = 'active' - AND checked_in_at IS NULL - ` - const checkedIn = await loadRegistrationById(tx, args.cleanupId, outcome.registration.id) - if (checkedIn === null) throw new Error("walk-up reload returned no row") - return { kind: "registered" as const, registration: checkedIn } - }) - } catch (err) { - if (registerArgs === null) throw err - const mapped = await registerErrorOutcome(err, registerArgs) - if (mapped !== null) return mapped - throw err - } - }, - - registerTx: runRegisterTx, - - async findRegistration( - cleanupId: string, - registrationId: string, - ): Promise { - return loadRegistrationById(sql, cleanupId, registrationId) - }, - - async findMyRegistration( - cleanupId: string, - subject: RegistrationSubject, - ): Promise { - const rows = await sql` - SELECT ${registrationColumns(sql)} - FROM cleanup_registrations r - ${registrationJoins(sql)} - WHERE r.cleanup_id = ${cleanupId} - AND ${ - subject.kind === "user" - ? sql`r.user_id = ${subject.userId}` - : sql`r.guest_id = ${subject.guestId}` - } - ORDER BY (r.status = 'registered') DESC, r.registered_at DESC - LIMIT 1 - ` - const hydrated = await hydrate(sql, rows) - return hydrated[0] ?? null - }, - - async findRegistrationsFor( - cleanupIds: readonly string[], - userId: string, - ): Promise> { - const out = new Map() - if (cleanupIds.length === 0) return out - const rows = await sql` - SELECT ${registrationColumns(sql)} - FROM cleanup_registrations r - ${registrationJoins(sql)} - WHERE r.cleanup_id = ANY(${[...cleanupIds]}::uuid[]) - AND r.user_id = ${userId} - AND r.status = 'registered' - ` - for (const record of await hydrate(sql, rows)) out.set(record.cleanupId, record) - return out - }, - - async listRoster(query: RosterQuery): Promise { - if (query.filter === "waitlisted") { - const page = await listWaitlistIn(sql, { - cleanupId: query.cleanupId, - ticketTypeId: query.ticketTypeId, - status: null, - cursor: query.cursor, - limit: query.limit, - }) - return { - rows: page.rows - .filter((entry) => entry.status === "waiting" || entry.status === "offered") - .map(waitlistEntryAsRegistration), - nextCursor: page.nextCursor, - } - } - - const statusFilter = (() => { - switch (query.filter) { - case "registered": - return sql`AND r.status = 'registered'` - case "cancelled": - return sql`AND r.status = 'cancelled'` - case "guests": - return sql`AND r.status = 'registered' AND r.guest_id IS NOT NULL` - case "members": - return sql`AND r.status = 'registered' AND r.user_id IS NOT NULL` - case "checked_in": - return sql`AND r.status = 'registered' AND ci.first_at IS NOT NULL` - case "not_checked_in": - return sql`AND r.status = 'registered' AND ci.first_at IS NULL` - case "no_show": - return sql`AND r.status = 'registered' AND EXISTS ( - SELECT 1 FROM cleanup_registration_seats s - WHERE s.registration_id = r.id AND s.no_show_at IS NOT NULL - )` - default: - return sql`` - } - })() - - const typeFilter = - query.ticketTypeId === null ? sql`` : sql`AND r.ticket_type_id = ${query.ticketTypeId}` - const slotFilter = query.slotId === null ? sql`` : sql`AND sc.slot_id = ${query.slotId}` - const search = (() => { - if (query.q === null || query.q.length === 0) return sql`` - const pattern = likeContains(query.q) - return sql`AND ( - u.display_name ILIKE ${pattern} ESCAPE '\\' - OR u.handle ILIKE ${pattern} ESCAPE '\\' - OR g.name ILIKE ${pattern} ESCAPE '\\' - OR EXISTS ( - SELECT 1 FROM cleanup_registration_seats s - WHERE s.registration_id = r.id AND s.attendee_name ILIKE ${pattern} ESCAPE '\\' - ) - )` - })() - - const cursorFilter = (() => { - if (query.cursor === null) return sql`` - if (query.sort === "name_asc") { - const cursor = parseNameCursor(query.cursor) - if (cursor === null) return sql`` - return sql`AND (lower(COALESCE(u.display_name, g.name, '')), r.id) > (${cursor.name}, ${cursor.id}::uuid)` - } - if (query.sort === "checked_in_at_desc") { - const cursor = parseCheckedInRosterCursor(query.cursor) - if (cursor === null) return sql`` - if (cursor.registeredAtText === null) { - // A cursor minted before it carried registered_at cannot place itself inside a tie, so it - // resumes at the start of that tie: a row may repeat once, none is skipped. - return sql`AND ${rosterCheckedInKey(sql)} <= ${cursor.checkedInAtText}::timestamptz` - } - return sql`AND (${rosterCheckedInKey(sql)}, r.registered_at, r.id) < (${cursor.checkedInAtText}::timestamptz, ${cursor.registeredAtText}::timestamptz, ${cursor.id}::uuid)` - } - const direction = query.sort === "registered_at_asc" ? "asc" : "desc" - const cursor = parseKeysetCursor(query.cursor, { direction }) - if (cursor === null) return sql`` - return sql`AND ${keysetPredicate(sql, sql`r.registered_at`, sql`r.id`, cursor, { direction })}` - })() - - const rows = await sql` - SELECT ${registrationColumns(sql)}, - ${keysetInstant(sql, sql`r.registered_at`)} AS cursor_at, - ${keysetInstant(sql, rosterCheckedInKey(sql))} AS checked_in_cursor_at - FROM cleanup_registrations r - ${registrationJoins(sql)} - WHERE r.cleanup_id = ${query.cleanupId} - ${statusFilter} - ${typeFilter} - ${slotFilter} - ${search} - ${cursorFilter} - ORDER BY ${ROSTER_SORTS[query.sort](sql)} - LIMIT ${query.limit + 1} - ` - - const { items, nextCursor } = pageWith(rows, query.limit, (last) => { - if (query.sort === "name_asc") { - return encodeNameCursor({ - name: (last.person_display_name ?? last.guest_name ?? "").toLowerCase(), - id: last.id, - }) - } - if (query.sort === "checked_in_at_desc") return encodeCheckedInRosterCursor(last) - return encodeKeysetCursor(last.cursor_at, last.id) - }) - - const page: RosterPage = { rows: await hydrate(sql, items), nextCursor } - if (query.withTotal) { - const counted = await sql<{ n: number }[]>` - SELECT count(*)::int AS n FROM cleanup_registrations - WHERE cleanup_id = ${query.cleanupId} AND status = 'registered' - ` - page.total = counted[0]?.n ?? 0 - } - return page - }, - - async listAnswers(cleanupId: string, registrationId: string): Promise { - const rows = await sql` - SELECT a.question_id, q.prompt, a.value_text, a.value_json, a.scrubbed_at - FROM cleanup_answers a - JOIN cleanup_questions q ON q.id = a.question_id - WHERE a.cleanup_id = ${cleanupId} AND a.registration_id = ${registrationId} - ORDER BY q.sort_order, q.id - ` - return rows.map(toAnswerRecord) - }, - - async setHostNote( - cleanupId: string, - registrationId: string, - note: string | null, - ): Promise { - const rows = await sql<{ id: string }[]>` - UPDATE cleanup_registrations SET host_note = ${note} - WHERE id = ${registrationId} AND cleanup_id = ${cleanupId} - RETURNING id - ` - return rows.length > 0 - }, - - async cancelRegistration(args: { - cleanupId: string - registrationId: string - actorId: string | null - now: Date - }): Promise { - return cancelRegistrationIn(sql, args) - }, - - async removeRegistration(args: { - cleanupId: string - registrationId: string - actorId: string - ban: boolean - now: Date - }): Promise { - return sql.begin(async (tx): Promise => { - const target = await tx<{ user_id: string | null }[]>` - SELECT user_id FROM cleanup_registrations - WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} - LIMIT 1 - ` - if (target.length === 0) return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } - const bannedUserId = args.ban ? (target[0]?.user_id ?? null) : null - if (bannedUserId === null) { - const outcome = await cancelRegistrationIn(tx, args) - return { ...outcome, releasedWaitlistTicketTypeIds: [] } - } - const ban = await applyBanIn(tx, { - cleanupId: args.cleanupId, - userId: bannedUserId, - actorId: args.actorId, - now: args.now, - }) - const releasedWaitlistTicketTypeIds = ban.releasedTicketTypeIds - const removed = ban.cancelledRegistrations.find((r) => r.id === args.registrationId) - if (removed === undefined) { - const outcome = await cancelRegistrationIn(tx, args) - return { ...outcome, releasedWaitlistTicketTypeIds } - } - const registration = await loadRegistrationById(tx, args.cleanupId, removed.id) - if (registration === null) return { kind: "not_found", releasedWaitlistTicketTypeIds } - return { - kind: "cancelled", - registration, - ticketTypeId: removed.ticketTypeId, - releasedWaitlistTicketTypeIds, - } - }) - }, - - async transferRegistration(args: { - cleanupId: string - registrationId: string - ticketTypeId: string - now: Date - }): Promise { - return sql.begin(async (tx) => { - // The event row first, as applyBanIn takes it: a ban holds it while it moves ticket-type seats and - // then cancels registrations, so a transfer that locked its registration before the ticket types - // could wait on the ban from the opposite end. registerIn's FOR SHARE on the same row keeps the - // same order. - const event = await tx<{ id: string }[]>` - SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR NO KEY UPDATE - ` - if (event[0] === undefined) return { kind: "not_found" as const } - const locked = await tx< - { id: string; ticket_type_id: string | null; party_size: number; status: string }[] - >` - SELECT id, ticket_type_id, party_size, status - FROM cleanup_registrations - WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} - LIMIT 1 FOR UPDATE - ` - const current = locked[0] - if (current === undefined || current.status !== "registered") { - return { kind: "not_found" as const } - } - if (current.ticket_type_id === args.ticketTypeId) return { kind: "same_type" as const } - - const wanted = [ - ...new Set( - [args.ticketTypeId, current.ticket_type_id].filter((id): id is string => id !== null), - ), - ].sort() - const types = await tx< - { id: string; max_party_size: number; capacity: number | null; reserved_seats: number }[] - >` - SELECT id, max_party_size, capacity, reserved_seats - FROM cleanup_ticket_types - WHERE cleanup_id = ${args.cleanupId} AND id = ANY(${wanted}::uuid[]) - ORDER BY id - FOR UPDATE - ` - const targetType = types.find((t) => t.id === args.ticketTypeId) - if (targetType === undefined) return { kind: "ticket_type_not_found" as const } - if (current.party_size > targetType.max_party_size) { - return { kind: "party_too_large" as const } - } - - const reserved = await tx<{ id: string }[]>` - UPDATE cleanup_ticket_types - SET reserved_seats = reserved_seats + ${current.party_size}, updated_at = ${args.now} - WHERE id = ${args.ticketTypeId} - AND cleanup_id = ${args.cleanupId} - AND (capacity IS NULL OR reserved_seats + ${current.party_size} <= capacity) - RETURNING id - ` - if (reserved.length === 0) return { kind: "full" as const } - - if (current.ticket_type_id !== null) { - await tx` - UPDATE cleanup_ticket_types - SET reserved_seats = GREATEST(reserved_seats - ${current.party_size}, 0), - updated_at = ${args.now} - WHERE id = ${current.ticket_type_id} AND cleanup_id = ${args.cleanupId} - ` - } - - await tx` - UPDATE cleanup_registrations - SET ticket_type_id = ${args.ticketTypeId}, source = 'transfer' - WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} - ` - - const registration = await loadRegistrationById(tx, args.cleanupId, args.registrationId) - if (registration === null) return { kind: "not_found" as const } - return { - kind: "transferred" as const, - registration, - previousTicketTypeId: current.ticket_type_id, - } - }) - }, - - async joinWaitlist(args: { - cleanupId: string - ticketTypeId: string - subject: RegistrationSubject - partySize: number - accessCodeHash: string | null - now: Date - }): Promise { - try { - return await sql.begin(async (tx) => { - const locked = await tx< - { - status: EventRegistrationContext["status"] - scheduled_at: Date - ends_at: Date | null - now: Date - }[] - >` - SELECT status, scheduled_at, ends_at, now() AS now FROM cleanups - WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE - ` - const event = locked[0] - if (event === undefined) return { kind: "not_found" as const } - if (event.status === "cancelled") return { kind: "closed" as const } - if (hasEventEnded(eventWindowOfRow(event), event.now.getTime())) { - return { kind: "ended" as const } - } - if ( - args.subject.kind === "user" && - (await isBannedIn(tx, args.cleanupId, args.subject.userId)) - ) { - return { kind: "banned" as const } - } - - const typeRows = await tx< - { - id: string - waitlist_enabled: boolean - visibility: TicketTypeRecord["visibility"] - access_code_hash: string | null - }[] - >` - SELECT id, waitlist_enabled, visibility, access_code_hash - FROM cleanup_ticket_types - WHERE id = ${args.ticketTypeId} AND cleanup_id = ${args.cleanupId} - LIMIT 1 - ` - const type = typeRows[0] - if (type === undefined || type.visibility === "hidden") { - return { kind: "ticket_type_not_found" as const } - } - if (!type.waitlist_enabled) return { kind: "waitlist_disabled" as const } - if (type.visibility === "access_code") { - if (args.accessCodeHash === null) return { kind: "access_code_required" as const } - if ( - type.access_code_hash === null || - !constantTimeStringEqual(args.accessCodeHash, type.access_code_hash) - ) { - return { kind: "access_code_invalid" as const } - } - } - - const registered = await tx<{ id: string }[]>` - SELECT id FROM cleanup_registrations - WHERE cleanup_id = ${args.cleanupId} - AND status = 'registered' - AND ${ - args.subject.kind === "user" - ? tx`user_id = ${args.subject.userId}` - : tx`guest_id = ${args.subject.guestId}` - } - LIMIT 1 - ` - if (registered.length > 0) return { kind: "already_registered" as const } - - const existing = await tx<{ id: string }[]>` - SELECT id FROM cleanup_waitlist - WHERE ticket_type_id = ${args.ticketTypeId} - AND status IN ('waiting', 'offered') - AND ${ - args.subject.kind === "user" - ? tx`user_id = ${args.subject.userId}` - : tx`guest_id = ${args.subject.guestId}` - } - LIMIT 1 - ` - const existingId = existing[0]?.id - if (existingId !== undefined) { - const entry = await loadWaitlistEntry(tx, args.cleanupId, existingId) - if (entry === null) return { kind: "not_found" as const } - return { kind: "already_waiting" as const, entry } - } - - const inserted = await tx<{ id: string }[]>` - INSERT INTO cleanup_waitlist ( - cleanup_id, ticket_type_id, user_id, guest_id, party_size, status, created_at - ) VALUES ( - ${args.cleanupId}, ${args.ticketTypeId}, - ${args.subject.kind === "user" ? args.subject.userId : null}, - ${args.subject.kind === "guest" ? args.subject.guestId : null}, - ${args.partySize}, 'waiting', ${args.now} - ) - RETURNING id - ` - const id = inserted[0]?.id - if (id === undefined) throw new Error("waitlist insert returned no row") - const entry = await loadWaitlistEntry(tx, args.cleanupId, id) - if (entry === null) throw new Error("waitlist reload returned no row") - return { kind: "joined" as const, entry } - }) - } catch (err) { - if ( - isUniqueViolationOn( - err, - "cleanup_waitlist_active_user_uidx", - "cleanup_waitlist_active_guest_uidx", - ) - ) { - const existing = await sql<{ id: string }[]>` - SELECT id FROM cleanup_waitlist - WHERE ticket_type_id = ${args.ticketTypeId} - AND status IN ('waiting', 'offered') - AND ${ - args.subject.kind === "user" - ? sql`user_id = ${args.subject.userId}` - : sql`guest_id = ${args.subject.guestId}` - } - LIMIT 1 - ` - const existingId = existing[0]?.id - const entry = - existingId === undefined - ? null - : await loadWaitlistEntry(sql, args.cleanupId, existingId) - if (entry === null) return { kind: "not_found" } - return { kind: "already_waiting", entry } - } - throw err - } - }, - - async leaveWaitlist(args: { - cleanupId: string - ticketTypeId: string | null - subject: RegistrationSubject - now: Date - }): Promise<{ left: number; releasedTicketTypeIds: string[] }> { - return cancelWaitlistEntriesIn(sql, args) - }, - - async listWaitlist(args: { - cleanupId: string - ticketTypeId: string | null - status: WaitlistRecord["status"] | null - cursor: string | null - limit: number - }): Promise<{ rows: WaitlistRecord[]; nextCursor: string | null }> { - return listWaitlistIn(sql, args) - }, - - async findWaitlistEntry(cleanupId: string, waitlistId: string): Promise { - return loadWaitlistEntry(sql, cleanupId, waitlistId) - }, - - async ticketTypeIdsWithWaiting(cleanupId: string): Promise { - const rows = await sql<{ ticket_type_id: string }[]>` - SELECT DISTINCT ticket_type_id FROM cleanup_waitlist - WHERE cleanup_id = ${cleanupId} AND status = 'waiting' - LIMIT 20 - ` - return rows.map((r) => r.ticket_type_id) - }, - - async offerNextWaitlistEntry(args: { - ticketTypeId: string - now: Date - claimWindowMs: number - }): Promise { - const expiresAt = new Date(args.now.getTime() + args.claimWindowMs) - return sql.begin(async (tx) => { - const candidates = await tx< - { - id: string - cleanup_id: string - user_id: string | null - guest_id: string | null - party_size: number - }[] - >` - SELECT w.id, w.cleanup_id, w.user_id, w.guest_id, w.party_size - FROM cleanup_waitlist w - WHERE w.ticket_type_id = ${args.ticketTypeId} AND w.status = 'waiting' - AND ${waitlistEntryNotBanned(tx)} - AND ${waitlistEventStillLive(tx)} - ORDER BY w.created_at, w.id - LIMIT 1 - FOR UPDATE SKIP LOCKED - ` - const candidate = candidates[0] - if (candidate === undefined) return null - - const reserved = await tx<{ id: string }[]>` - UPDATE cleanup_ticket_types - SET reserved_seats = reserved_seats + ${candidate.party_size}, updated_at = ${args.now} - WHERE id = ${args.ticketTypeId} - AND (capacity IS NULL OR reserved_seats + ${candidate.party_size} <= capacity) - RETURNING id - ` - if (reserved.length === 0) return null - - await tx` - UPDATE cleanup_waitlist - SET status = 'offered', offered_at = ${args.now}, claim_expires_at = ${expiresAt} - WHERE id = ${candidate.id} - ` - return { - waitlistId: candidate.id, - cleanupId: candidate.cleanup_id, - ticketTypeId: args.ticketTypeId, - userId: candidate.user_id, - guestId: candidate.guest_id, - partySize: candidate.party_size, - claimExpiresAt: expiresAt, - } - }) - }, - - async offerWaitlistEntry(args: { - cleanupId: string - waitlistId: string - now: Date - claimWindowMs: number - }): Promise { - const expiresAt = new Date(args.now.getTime() + args.claimWindowMs) - return sql.begin(async (tx) => { - const rows = await tx< - { - id: string - ticket_type_id: string - user_id: string | null - guest_id: string | null - party_size: number - }[] - >` - SELECT w.id, w.ticket_type_id, w.user_id, w.guest_id, w.party_size - FROM cleanup_waitlist w - WHERE w.id = ${args.waitlistId} - AND w.cleanup_id = ${args.cleanupId} - AND w.status = 'waiting' - AND ${waitlistEntryNotBanned(tx)} - AND ${waitlistEventStillLive(tx)} - LIMIT 1 - FOR UPDATE - ` - const candidate = rows[0] - if (candidate === undefined) return null - - const reserved = await tx<{ id: string }[]>` - UPDATE cleanup_ticket_types - SET reserved_seats = reserved_seats + ${candidate.party_size}, updated_at = ${args.now} - WHERE id = ${candidate.ticket_type_id} - AND cleanup_id = ${args.cleanupId} - AND (capacity IS NULL OR reserved_seats + ${candidate.party_size} <= capacity) - RETURNING id - ` - if (reserved.length === 0) return null - - await tx` - UPDATE cleanup_waitlist - SET status = 'offered', offered_at = ${args.now}, claim_expires_at = ${expiresAt} - WHERE id = ${candidate.id} - ` - return { - waitlistId: candidate.id, - cleanupId: args.cleanupId, - ticketTypeId: candidate.ticket_type_id, - userId: candidate.user_id, - guestId: candidate.guest_id, - partySize: candidate.party_size, - claimExpiresAt: expiresAt, - } - }) - }, - - async expireWaitlistOffers(args: { now: Date; limit: number }): Promise { - const rows = await sql<{ ticket_type_id: string }[]>` - WITH expired AS ( - UPDATE cleanup_waitlist - SET status = 'expired' - WHERE id IN ( - SELECT id FROM cleanup_waitlist - WHERE status = 'offered' AND claim_expires_at <= ${args.now} - ORDER BY claim_expires_at - LIMIT ${args.limit} - FOR UPDATE SKIP LOCKED - ) - RETURNING id, ticket_type_id, party_size - ), released AS ( - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), - updated_at = ${args.now} - FROM expired e - WHERE t.id = e.ticket_type_id - RETURNING t.id - ) - SELECT ticket_type_id FROM expired - ` - return [...new Set(rows.map((r) => r.ticket_type_id))] - }, - - async claimWaitlistOffer(args: { - cleanupId: string - waitlistId: string - subject: RegistrationSubject | null - seats: RegisterTxArgs["seats"] - now: Date - }): Promise { - const entry = await loadWaitlistEntry(sql, args.cleanupId, args.waitlistId) - if (entry === null) return { kind: "not_found" } - if (args.subject !== null) { - const matches = - args.subject.kind === "user" - ? entry.userId === args.subject.userId - : entry.guestId === args.subject.guestId - if (!matches) return { kind: "not_found" } - } - const subject: RegistrationSubject = - entry.userId !== null - ? { kind: "user", userId: entry.userId } - : { kind: "guest", guestId: entry.guestId as string } - const registerArgs: RegisterTxArgs = { - cleanupId: args.cleanupId, - subject, - ticketTypeId: entry.ticketTypeId, - seats: args.seats.slice(0, entry.partySize), - accessCodeHash: null, - answers: [], - consent: null, - slotId: null, - source: "waitlist", - idempotencyKey: `waitlist:${args.waitlistId}`, - waitlistId: args.waitlistId, - now: args.now, - } - - try { - return await sql.begin(async (tx) => { - const events = await tx< - { - status: EventRegistrationContext["status"] - scheduled_at: Date - ends_at: Date | null - now: Date - }[] - >` - SELECT status, scheduled_at, ends_at, now() AS now FROM cleanups - WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE - ` - const event = events[0] - const claimed = await tx< - { party_size: number; user_id: string | null; guest_id: string | null }[] - >` - UPDATE cleanup_waitlist - SET status = 'claimed' - WHERE id = ${args.waitlistId} - AND cleanup_id = ${args.cleanupId} - AND status = 'offered' - AND (claim_expires_at IS NULL OR claim_expires_at > ${args.now}) - AND party_size = ${registerArgs.seats.length} - RETURNING party_size, user_id, guest_id - ` - const row = claimed[0] - if (row === undefined) { - return classifyUnclaimable(tx, args.cleanupId, args.waitlistId, subject, args.now) - } - - const owns = - subject.kind === "user" - ? row.user_id === subject.userId - : row.guest_id === subject.guestId - if (!owns) throw new ClaimRefusal({ kind: "not_found" }) - - // registerIn only gates self sign-ups on the event end, so an offer outliving its event - // would otherwise still turn into a seat. - if (event !== undefined && hasEventEnded(eventWindowOfRow(event), event.now.getTime())) { - await releaseWaitlistHold(tx, args.cleanupId, args.waitlistId, args.now) - return { kind: "not_offered" as const } - } - - const outcome = await registerIn(tx, registerArgs) - if (outcome.kind === "registered") { - return { kind: "claimed" as const, registration: outcome.registration } - } - if (outcome.kind === "replayed" && outcome.registration !== null) { - return { kind: "claimed" as const, registration: outcome.registration } - } - if (outcome.kind === "registration_closed" || outcome.kind === "closed") { - await releaseWaitlistHold(tx, args.cleanupId, args.waitlistId, args.now) - return { kind: "not_offered" as const } - } - throw new ClaimRefusal({ kind: "not_offered" }) - }) - } catch (err) { - if (err instanceof ClaimRefusal) return err.outcome - const mapped = await registerErrorOutcome(err, registerArgs) - if (mapped === null) throw err - if (mapped.kind === "replayed" && mapped.registration !== null) { - return { kind: "claimed", registration: mapped.registration } - } - return { kind: "not_offered" } - } - }, - - async checkInByToken(args: { - cleanupId: string - tokenHash: string - actorId: string - method: CheckinMethod - now: Date - }): Promise { - const updated = await sql<(SeatRowSelect & { first_time: boolean })[]>` - WITH target AS ( - SELECT id, checked_in_at IS NULL AS first_time - FROM cleanup_registration_seats - WHERE ticket_token_hash = ${args.tokenHash} - AND cleanup_id = ${args.cleanupId} - AND status = 'active' - AND no_show_at IS NULL - FOR UPDATE - ) - UPDATE cleanup_registration_seats s - SET checked_in_at = COALESCE(s.checked_in_at, ${args.now}), - checked_in_by = COALESCE(s.checked_in_by, ${args.actorId}), - checkin_method = COALESCE(s.checkin_method, ${args.method}) - FROM target t - WHERE s.id = t.id - RETURNING s.id, s.registration_id, s.seat_index, s.attendee_name, s.status, - s.checked_in_at, s.checked_in_by, s.checkin_method, s.checkin_coarsened_at, - s.no_show_at, t.first_time - ` - const row = updated[0] - if (row !== undefined) { - return buildCheckinResult( - row.first_time ? "checked_in" : "already", - row.first_time, - toSeatRecord(row), - await loadRegistrationById(sql, args.cleanupId, row.registration_id), - ) - } - const probe = await sql< - { cleanup_id: string; status: SeatRecord["status"]; no_show_at: Date | null }[] - >` - SELECT cleanup_id, status, no_show_at FROM cleanup_registration_seats - WHERE ticket_token_hash = ${args.tokenHash} - LIMIT 1 - ` - const seat = probe[0] - if (seat === undefined) return emptyCheckinResult("unknown_token") - if (seat.cleanup_id !== args.cleanupId) return emptyCheckinResult("wrong_event") - if (seat.status === "cancelled") return emptyCheckinResult("cancelled") - if (seat.no_show_at !== null) return emptyCheckinResult("no_show") - return emptyCheckinResult("unknown_token") - }, - - async checkInSeat(args: { - cleanupId: string - seatId: string - actorId: string - method: CheckinMethod - now: Date - }): Promise { - const updated = await sql<(SeatRowSelect & { first_time: boolean })[]>` - WITH target AS ( - SELECT id, checked_in_at IS NULL AS first_time - FROM cleanup_registration_seats - WHERE id = ${args.seatId} - AND cleanup_id = ${args.cleanupId} - AND status = 'active' - FOR UPDATE - ) - UPDATE cleanup_registration_seats s - SET checked_in_at = COALESCE(s.checked_in_at, ${args.now}), - checked_in_by = COALESCE(s.checked_in_by, ${args.actorId}), - checkin_method = COALESCE(s.checkin_method, ${args.method}), - no_show_at = NULL - FROM target t - WHERE s.id = t.id - RETURNING s.id, s.registration_id, s.seat_index, s.attendee_name, s.status, - s.checked_in_at, s.checked_in_by, s.checkin_method, s.checkin_coarsened_at, - s.no_show_at, t.first_time - ` - const row = updated[0] - if (row === undefined) { - const probe = await sql<{ status: SeatRecord["status"] }[]>` - SELECT status FROM cleanup_registration_seats - WHERE id = ${args.seatId} AND cleanup_id = ${args.cleanupId} - LIMIT 1 - ` - const seat = probe[0] - if (seat === undefined) return emptyCheckinResult("unknown_token") - return emptyCheckinResult("cancelled") - } - return buildCheckinResult( - row.first_time ? "checked_in" : "already", - row.first_time, - toSeatRecord(row), - await loadRegistrationById(sql, args.cleanupId, row.registration_id), - ) - }, - - async undoCheckIn(args: { cleanupId: string; seatId: string }): Promise { - const rows = await sql` - UPDATE cleanup_registration_seats - SET checked_in_at = NULL, checked_in_by = NULL, checkin_method = NULL, - checkin_coarsened_at = NULL - WHERE id = ${args.seatId} AND cleanup_id = ${args.cleanupId} - RETURNING id, registration_id, seat_index, attendee_name, status, checked_in_at, - checked_in_by, checkin_method, checkin_coarsened_at, no_show_at - ` - const row = rows[0] - return row === undefined ? null : toSeatRecord(row) - }, - - async markNoShows(args: { - cleanupId: string - seatIds: readonly string[] | null - now: Date - }): Promise { - const idFilter = - args.seatIds === null ? sql`` : sql`AND id = ANY(${[...args.seatIds]}::uuid[])` - const rows = await sql<{ id: string }[]>` - UPDATE cleanup_registration_seats - SET no_show_at = ${args.now} - WHERE cleanup_id = ${args.cleanupId} - AND status = 'active' - AND checked_in_at IS NULL - AND no_show_at IS NULL - ${idFilter} - RETURNING id - ` - return rows.length - }, - - async sweepNoShows(args: { now: Date; limit: number }): Promise { - const rows = await sql<{ id: string }[]>` - UPDATE cleanup_registration_seats s - SET no_show_at = ${args.now} - WHERE s.id IN ( - SELECT s2.id - FROM cleanup_registration_seats s2 - JOIN cleanups c ON c.id = s2.cleanup_id - WHERE s2.status = 'active' - AND s2.checked_in_at IS NULL - AND s2.no_show_at IS NULL - AND c.status <> 'cancelled' - AND c.ends_at <= ${args.now} - interval '2 hours' - ORDER BY s2.created_at - LIMIT ${args.limit} - ) - RETURNING s.id - ` - return rows.length - }, - - async checkinCounters(cleanupId: string): Promise { - const totals = await sql< - { - registered: number - checked_in: number - no_show: number - waitlisted: number - capacity: number | null - }[] - >` - SELECT - COALESCE(( - SELECT sum(r.party_size)::int FROM cleanup_registrations r - WHERE r.cleanup_id = ${cleanupId} AND r.status = 'registered' - ), 0) AS registered, - COALESCE(( - SELECT count(*)::int FROM cleanup_registration_seats s - WHERE s.cleanup_id = ${cleanupId} AND s.status = 'active' AND s.checked_in_at IS NOT NULL - ), 0) AS checked_in, - COALESCE(( - SELECT count(*)::int FROM cleanup_registration_seats s - WHERE s.cleanup_id = ${cleanupId} AND s.status = 'active' AND s.no_show_at IS NOT NULL - ), 0) AS no_show, - COALESCE(( - SELECT sum(w.party_size)::int FROM cleanup_waitlist w - WHERE w.cleanup_id = ${cleanupId} AND w.status IN ('waiting', 'offered') - AND ${waitlistEntryNotBanned(sql)} - ), 0) AS waitlisted, - ( - SELECT CASE - WHEN count(*) = 0 THEN (SELECT c.capacity FROM cleanups c WHERE c.id = ${cleanupId}) - WHEN bool_or(t.capacity IS NULL) THEN NULL - ELSE sum(t.capacity)::int - END - FROM cleanup_ticket_types t WHERE t.cleanup_id = ${cleanupId} - ) AS capacity - ` - const byType = await sql< - { - ticket_type_id: string - name: string - registered: number - checked_in: number - waitlisted: number - capacity: number | null - }[] - >` - SELECT t.id AS ticket_type_id, - t.name, - COALESCE(( - SELECT sum(r.party_size)::int FROM cleanup_registrations r - WHERE r.ticket_type_id = t.id AND r.status = 'registered' - ), 0) AS registered, - COALESCE(( - SELECT count(*)::int FROM cleanup_registration_seats s - JOIN cleanup_registrations r2 ON r2.id = s.registration_id - WHERE r2.ticket_type_id = t.id AND s.status = 'active' AND s.checked_in_at IS NOT NULL - ), 0) AS checked_in, - COALESCE(( - SELECT sum(w.party_size)::int FROM cleanup_waitlist w - WHERE w.ticket_type_id = t.id AND w.status IN ('waiting', 'offered') - AND ${waitlistEntryNotBanned(sql)} - ), 0) AS waitlisted, - t.capacity - FROM cleanup_ticket_types t - WHERE t.cleanup_id = ${cleanupId} - ORDER BY t.sort_order, t.id - LIMIT ${MAX_TICKET_TYPES} - ` - const arrivals = await sql<{ at: Date; n: number }[]>` - SELECT to_timestamp( - floor(extract(epoch FROM s.checked_in_at) / ${ARRIVAL_BUCKET_MINUTES * 60}) - * ${ARRIVAL_BUCKET_MINUTES * 60} - ) AS at, - count(*)::int AS n - FROM cleanup_registration_seats s - WHERE s.cleanup_id = ${cleanupId} AND s.checked_in_at IS NOT NULL - GROUP BY 1 - ORDER BY 1 - LIMIT 200 - ` - const row = totals[0] - return { - registered: row?.registered ?? 0, - checkedIn: row?.checked_in ?? 0, - waitlisted: row?.waitlisted ?? 0, - noShow: row?.no_show ?? 0, - capacity: row?.capacity ?? null, - byTicketType: byType.map((t) => ({ - ticketTypeId: t.ticket_type_id, - name: t.name, - registered: t.registered, - checkedIn: t.checked_in, - waitlisted: t.waitlisted, - capacity: t.capacity, - })), - arrivals: arrivals.map((a) => ({ at: a.at, count: a.n })), - } - }, - - async getPage(cleanupId: string): Promise { - const rows = await sql` - SELECT c.id AS cleanup_id, - c.page_slug AS slug, - COALESCE(p.status, 'draft') AS status, - COALESCE(p.theme_accent, 'bloom') AS theme_accent, - p.blocks, - p.seo, - c.cover_media_id, - ${publicServedKeyExpr(sql, "m")} AS cover_key, - c.visibility, - p.published_at, - p.updated_at, - p.flagged_at, - p.flag_reason, - COALESCE(p.view_count, 0) AS view_count - FROM cleanups c - LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id - LEFT JOIN media_assets m ON m.id = c.cover_media_id AND m.status = 'ready' - WHERE c.id = ${cleanupId} - LIMIT 1 - ` - const row = rows[0] - return row === undefined ? null : toPageRecord(row) - }, - - async mediaKeysFor( - cleanupId: string, - mediaIds: readonly string[], - ): Promise> { - const out = new Map() - if (mediaIds.length === 0) return out - const rows = await sql<{ id: string; served_key: string }[]>` - SELECT media_assets.id, ${publicServedKeyExpr(sql, "media_assets")} AS served_key - FROM media_assets - WHERE media_assets.id = ANY(${[...new Set(mediaIds)]}::uuid[]) - AND media_assets.status = 'ready' - AND media_assets.served_key IS NOT NULL - AND (${mediaBoundToCleanup(sql, cleanupId)}) - ` - for (const row of rows) out.set(row.id, row.served_key) - return out - }, - - async savePage(args: SavePageArgs): Promise { - try { - return await sql.begin(async (tx) => { - const locked = await tx<{ id: string }[]>` - SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE - ` - if (locked[0] === undefined) return { kind: "not_found" as const } - - const blockIds = [...new Set(args.blockMediaIds)] - if (blockIds.length > 0) { - const claimed = await claimPageMediaInTx( - tx, - args.cleanupId, - blockIds, - null, - args.actorUserId, - ) - if (claimed.length !== blockIds.length) { - return { kind: "block_media_not_found" as const } - } - } - - if (args.coverMediaId !== undefined) { - if (args.coverMediaId !== null) { - const claimed = await claimPageMediaInTx( - tx, - args.cleanupId, - [args.coverMediaId], - "event_cover", - args.actorUserId, - ) - if (claimed.length !== 1) return { kind: "cover_not_found" as const } - } - await tx` - UPDATE cleanups SET cover_media_id = ${args.coverMediaId} - WHERE id = ${args.cleanupId} - ` - } - - await tx` - DELETE FROM cleanup_page_media - WHERE cleanup_id = ${args.cleanupId} - AND NOT (media_id = ANY(${blockIds}::uuid[])) - ` - if (blockIds.length > 0) { - await tx` - INSERT INTO cleanup_page_media (cleanup_id, media_id) - SELECT ${args.cleanupId}::uuid, t.id FROM unnest(${blockIds}::uuid[]) AS t(id) - ON CONFLICT (cleanup_id, media_id) DO NOTHING - ` - } - - if (args.slug !== undefined) { - await tx` - UPDATE cleanups SET page_slug = ${args.slug} WHERE id = ${args.cleanupId} - ` - } - - const blocks = tx.json(args.blocks as unknown as Parameters[0]) - const seo = - args.seo === undefined - ? null - : tx.json(args.seo as unknown as Parameters[0]) - await tx` - INSERT INTO cleanup_pages ( - cleanup_id, status, theme_accent, blocks, seo, created_at, updated_at - ) VALUES ( - ${args.cleanupId}, 'draft', ${args.themeAccent ?? "bloom"}, ${blocks}, - COALESCE(${seo}, '{}'::jsonb), ${args.now}, ${args.now} - ) - ON CONFLICT (cleanup_id) DO UPDATE SET - theme_accent = COALESCE(${args.themeAccent ?? null}, cleanup_pages.theme_accent), - blocks = EXCLUDED.blocks, - seo = COALESCE(${seo}, cleanup_pages.seo), - updated_at = ${args.now} - ` - - const record = await loadPage(tx, args.cleanupId) - if (record === null) return { kind: "not_found" as const } - return { kind: "saved" as const, record } - }) - } catch (err) { - if (isUniqueViolationOn(err, "cleanups_page_slug_uidx")) return { kind: "slug_taken" } - throw err - } - }, - - async publishPage(args: { - cleanupId: string - published: boolean - actorId: string - now: Date - }): Promise { - // The flag is re-checked in the write: an operator may flag the page after the service read it. - const rows = await sql<{ cleanup_id: string }[]>` - UPDATE cleanup_pages - SET status = ${args.published ? "published" : "unpublished"}, - published_at = ${args.published ? args.now : sql`published_at`}, - published_by = ${args.published ? args.actorId : sql`published_by`}, - updated_at = ${args.now} - WHERE cleanup_id = ${args.cleanupId} - AND (NOT ${args.published} OR flagged_at IS NULL) - RETURNING cleanup_id - ` - if (rows.length === 0) { - if (!args.published) return { kind: "not_found" } - const flagged = await sql<{ flagged_at: Date | null }[]>` - SELECT flagged_at FROM cleanup_pages WHERE cleanup_id = ${args.cleanupId} LIMIT 1 - ` - const row = flagged[0] - return row !== undefined && row.flagged_at !== null - ? { kind: "flagged" } - : { kind: "not_found" } - } - const record = await loadPage(sql, args.cleanupId) - return record === null ? { kind: "not_found" } : { kind: "published", record } - }, - - async slugTaken(cleanupId: string, slug: string): Promise { - const rows = await sql<{ id: string }[]>` - SELECT id FROM cleanups - WHERE page_slug = ${slug} AND id <> ${cleanupId} - LIMIT 1 - ` - return rows.length > 0 - }, - - async getPublicPage(slug: string): Promise { - const rows = await sql< - (PageRowSelect & { donation_url: string | null; logo_key: string | null })[] - >` - SELECT c.id AS cleanup_id, - c.page_slug AS slug, - COALESCE(p.status, 'draft') AS status, - COALESCE(p.theme_accent, 'bloom') AS theme_accent, - p.blocks, - p.seo, - c.cover_media_id, - ${publicServedKeyExpr(sql, "m")} AS cover_key, - c.visibility, - p.published_at, - p.updated_at, - p.flagged_at, - p.flag_reason, - COALESCE(p.view_count, 0) AS view_count, - c.donation_url, - ${publicServedKeyExpr(sql, "lm")} AS logo_key - FROM cleanups c - LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id - LEFT JOIN media_assets m ON m.id = c.cover_media_id AND m.status = 'ready' - LEFT JOIN organizations o ON o.id = c.organization_id AND o.deleted_at IS NULL - LEFT JOIN media_assets lm ON lm.id = o.logo_media_id AND lm.status = 'ready' - WHERE c.page_slug = ${slug} - LIMIT 1 - ` - const row = rows[0] - if (row === undefined) return null - const event = await eventContextIn(sql, row.cleanup_id) - if (event === null) return null - return { - page: toPageRecord(row), - event, - ticketTypes: await loadTicketTypes(sql, [row.cleanup_id]), - questions: ( - await sql` - SELECT ${questionColumns(sql)} - FROM cleanup_questions - WHERE cleanup_id = ${row.cleanup_id} AND archived_at IS NULL - ORDER BY sort_order, id - ` - ).map(toQuestionRecord), - organizationId: event.organizationId, - donationUrl: row.donation_url, - logoKey: row.logo_key, - } - }, - - async hostedEventCounts( - cleanupIds: readonly string[], - ): Promise> { - const out = new Map() - if (cleanupIds.length === 0) return out - const rows = await sql< - { cleanup_id: string; registered: number; waitlisted: number; checked_in: number }[] - >` - SELECT c.id AS cleanup_id, - COALESCE(reg.n, 0) AS registered, - COALESCE(wl.n, 0) AS waitlisted, - COALESCE(ci.n, 0) AS checked_in - FROM unnest(${[...cleanupIds]}::uuid[]) AS c(id) - LEFT JOIN LATERAL ( - SELECT sum(r.party_size)::int AS n FROM cleanup_registrations r - WHERE r.cleanup_id = c.id AND r.status = 'registered' - ) reg ON true - LEFT JOIN LATERAL ( - SELECT sum(w.party_size)::int AS n FROM cleanup_waitlist w - WHERE w.cleanup_id = c.id AND w.status IN ('waiting', 'offered') - AND ${waitlistEntryNotBanned(sql)} - ) wl ON true - LEFT JOIN LATERAL ( - SELECT count(*)::int AS n FROM cleanup_registration_seats s - WHERE s.cleanup_id = c.id AND s.status = 'active' AND s.checked_in_at IS NOT NULL - ) ci ON true - ` - for (const row of rows) { - out.set(row.cleanup_id, { - registeredCount: row.registered, - waitlistCount: row.waitlisted, - checkedInCount: row.checked_in, - }) - } - return out - }, + ...makeTicketTypeMethods(sql), + ...makeQuestionMethods(sql), + ...makeRegisterMethods(sql), + ...makeRosterMethods(sql), + ...makeWaitlistMethods(sql), + ...makeCheckinMethods(sql), + ...makePageMethods(sql), } - - return repo } diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index 1cc38494..72c35c1c 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -10,12 +10,17 @@ import { import { LIVE_TAIL_MS } from "@civfix/shared/host" import { ARRIVAL_BUCKET_MINUTES, - type AppliedBan, buildCheckinResult, emptyCheckinResult, +} from "./registration-checkin.drizzle.js" +import { guestSelfRegistrationOnPrivateEvent, - waitlistEntryAsRegistration, -} from "./registration-repository.drizzle.js" + subjectOwner, + withinSalesWindow, +} from "./registration-register.drizzle.js" +import type { AppliedBan } from "./registration-roster.drizzle.js" +import { MAX_TICKET_TYPES } from "./registration-ticket-types.drizzle.js" +import { waitlistEntryAsRegistration } from "./registration-waitlist.drizzle.js" import type { AnswerRecord, CancelRegistrationOutcome, @@ -63,8 +68,6 @@ interface MemoryGuest { name: string } -const MAX_TICKET_TYPES = 20 - const ARRIVAL_BUCKET_MS = ARRIVAL_BUCKET_MINUTES * 60_000 const SEEDED_EVENT_LEAD_MS = 7 * 24 * 60 * 60 * 1000 @@ -92,12 +95,6 @@ function subjectMatches( : record.guestId === subject.guestId } -function withinWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { - if (opensAt !== null && now < opensAt) return false - if (closesAt !== null && now >= closesAt) return false - return true -} - export class InMemoryHostRegistrationRepository implements HostRegistrationRepository { readonly events = new Map() readonly ticketTypes = new Map() @@ -519,90 +516,91 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos } } - async registerTx(args: RegisterTxArgs): Promise { - const event = this.events.get(args.cleanupId) - if (event === undefined) return { kind: "not_found" } - if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) return { kind: "not_found" } - - const owner = - args.idempotencyOwner ?? - (args.subject.kind === "user" - ? `user:${args.subject.userId}` - : `guest:${args.subject.guestId}`) - const idempotencyKey = `${owner}:${args.idempotencyKey}` - const replayed = this.idempotency.get(idempotencyKey) - if (replayed !== undefined) { - const existing = this.registrations.get(replayed) - return { - kind: "replayed", - registration: existing === undefined ? null : this.toRecord(existing), - } + private replayOf(registrationId: string): RegisterTxOutcome { + const existing = this.registrations.get(registrationId) + return { + kind: "replayed", + registration: existing === undefined ? null : this.toRecord(existing), } + } + private eventRefusal( + args: RegisterTxArgs, + event: EventRegistrationContext, + ): RegisterTxOutcome | null { if (event.status === "cancelled") return { kind: "closed" } - if (!withinWindow(args.now, event.registrationOpensAt, event.registrationClosesAt)) { + if (!withinSalesWindow(args.now, event.registrationOpensAt, event.registrationClosesAt)) { return { kind: "registration_closed" } } if (args.subject.kind === "user" && this.isBanned(args.cleanupId, args.subject.userId)) { return { kind: "banned" } } - const active = [...this.registrations.values()].find( (r) => r.cleanupId === args.cleanupId && r.status === "registered" && subjectMatches(r, args.subject), ) - if (active !== undefined) return { kind: "already_registered" } + return active === undefined ? null : { kind: "already_registered" } + } + private pickTicketType( + args: RegisterTxArgs, + ): { refusal: RegisterTxOutcome } | { type: TicketTypeRecord | null } { const types = this.typesOf(args.cleanupId) let type: TicketTypeRecord | null = null if (args.ticketTypeId !== null) { type = types.find((t) => t.id === args.ticketTypeId) ?? null - if (type === null) return { kind: "ticket_type_not_found" } + if (type === null) return { refusal: { kind: "ticket_type_not_found" } } } else if (types.length === 1) { type = types[0] ?? null } else if (types.length > 1) { - return { kind: "ticket_type_not_found" } + return { refusal: { kind: "ticket_type_not_found" } } } if (type !== null && type.visibility === "hidden" && args.source === "self") { - return { kind: "ticket_type_not_found" } + return { refusal: { kind: "ticket_type_not_found" } } } + return { type } + } - const partySize = args.seats.length - if (type !== null) { - if (type.visibility === "access_code" && args.waitlistId === null) { - const expected = this.accessCodeHashes.get(type.id) ?? null - if (args.accessCodeHash === null) return { kind: "access_code_required" } - if (args.accessCodeHash !== expected) return { kind: "access_code_invalid" } - } - if (partySize > type.maxPartySize) return { kind: "party_too_large" } - if ( - args.waitlistId === null && - !withinWindow(args.now, type.salesOpensAt, type.salesClosesAt) - ) { - return { kind: "sales_closed" } - } - if ( - args.waitlistId === null && - type.capacity !== null && - type.reservedSeats + partySize > type.capacity - ) { - return { kind: "full" } - } + private ticketTypeRefusal( + args: RegisterTxArgs, + type: TicketTypeRecord, + partySize: number, + ): RegisterTxOutcome | null { + if (type.visibility === "access_code" && args.waitlistId === null) { + const expected = this.accessCodeHashes.get(type.id) ?? null + if (args.accessCodeHash === null) return { kind: "access_code_required" } + if (args.accessCodeHash !== expected) return { kind: "access_code_invalid" } } - - if (type === null && event.capacity != null && args.waitlistId === null) { - const held = [...this.registrations.values()] - .filter((r) => r.cleanupId === args.cleanupId && r.status === "registered") - .reduce((sum, r) => sum + r.partySize, 0) - if (held + partySize > event.capacity) return { kind: "full" } + if (partySize > type.maxPartySize) return { kind: "party_too_large" } + if (args.waitlistId !== null) return null + if (!withinSalesWindow(args.now, type.salesOpensAt, type.salesClosesAt)) { + return { kind: "sales_closed" } + } + if (type.capacity !== null && type.reservedSeats + partySize > type.capacity) { + return { kind: "full" } } + return null + } - if (args.subject.kind === "user") this.members.add(`${args.cleanupId}:${args.subject.userId}`) - if (type !== null && args.waitlistId === null) type.reservedSeats += partySize + private eventCapacityRefusal( + args: RegisterTxArgs, + event: EventRegistrationContext, + partySize: number, + ): RegisterTxOutcome | null { + if (event.capacity == null || args.waitlistId !== null) return null + const held = [...this.registrations.values()] + .filter((r) => r.cleanupId === args.cleanupId && r.status === "registered") + .reduce((sum, r) => sum + r.partySize, 0) + return held + partySize > event.capacity ? { kind: "full" } : null + } - const id = this.newId() + private newRegistration( + args: RegisterTxArgs, + id: string, + type: TicketTypeRecord | null, + ): MemoryRegistration { const seats: SeatRecord[] = args.seats.map((seat, index) => ({ id: seat.id, registrationId: id, @@ -616,7 +614,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos noShowAt: null, })) const guest = args.subject.kind === "guest" ? this.guests.get(args.subject.guestId) : undefined - const registration: MemoryRegistration = { + return { id, cleanupId: args.cleanupId, ticketTypeId: type?.id ?? null, @@ -635,7 +633,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos deletedAt: null, } : null, - partySize, + partySize: args.seats.length, status: "registered", source: args.source, hostNote: null, @@ -658,8 +656,37 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos scrubbedAt: null, })), } - this.registrations.set(id, registration) - this.idempotency.set(idempotencyKey, id) + } + + async registerTx(args: RegisterTxArgs): Promise { + const event = this.events.get(args.cleanupId) + if (event === undefined) return { kind: "not_found" } + if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) return { kind: "not_found" } + + const owner = args.idempotencyOwner ?? subjectOwner(args.subject) + const idempotencyKey = `${owner}:${args.idempotencyKey}` + const replayed = this.idempotency.get(idempotencyKey) + if (replayed !== undefined) return this.replayOf(replayed) + + const eventRefusal = this.eventRefusal(args, event) + if (eventRefusal !== null) return eventRefusal + + const picked = this.pickTicketType(args) + if ("refusal" in picked) return picked.refusal + const { type } = picked + const partySize = args.seats.length + const refusal = + type === null + ? this.eventCapacityRefusal(args, event, partySize) + : this.ticketTypeRefusal(args, type, partySize) + if (refusal !== null) return refusal + + if (args.subject.kind === "user") this.members.add(`${args.cleanupId}:${args.subject.userId}`) + if (type !== null && args.waitlistId === null) type.reservedSeats += partySize + + const registration = this.newRegistration(args, this.newId(), type) + this.registrations.set(registration.id, registration) + this.idempotency.set(idempotencyKey, registration.id) if (type !== null) this.recomputeSold(type.id) return { kind: "registered", registration: this.toRecord(registration) } diff --git a/services/api/src/services/host/registration-retention.ts b/services/api/src/services/host/registration-retention.ts index 8960ec2c..3eee4992 100644 --- a/services/api/src/services/host/registration-retention.ts +++ b/services/api/src/services/host/registration-retention.ts @@ -8,7 +8,7 @@ export const ANSWER_RETENTION_DAYS = 30 export const CHECKIN_COARSEN_DAYS = 30 -export const ATTENDEE_NAME_RETENTION_DAYS = 30 +const ATTENDEE_NAME_RETENTION_DAYS = 30 export const HOST_NOTE_RETENTION_DAYS = 90 diff --git a/services/api/src/services/host/registration-roster.drizzle.ts b/services/api/src/services/host/registration-roster.drizzle.ts new file mode 100644 index 00000000..03bf9387 --- /dev/null +++ b/services/api/src/services/host/registration-roster.drizzle.ts @@ -0,0 +1,534 @@ +import type { RegistrationRosterFilter, RegistrationRosterSort } from "@civfix/shared" +import type { Queryable, Sql } from "../../db/client.js" +import { + encodeKeysetCursor, + encodeNameCursor, + keysetInstant, + keysetPredicate, + pageWith, + parseKeysetCursor, + parseNameCursor, +} from "../../db/cursor-helpers.js" +import { likeContains } from "../admin/like.js" +import { + registrationColumns, + registrationJoins, + type RegistrationRowSelect, +} from "./registration-sql.js" +import { hydrate, loadRegistrationById } from "./registration-load.drizzle.js" +import { + cancelWaitlistEntriesIn, + listWaitlistIn, + waitlistEntryAsRegistration, +} from "./registration-waitlist.drizzle.js" +import type { + CancelRegistrationOutcome, + HostRegistrationRepository, + RegistrationRecord, + RegistrationSubject, + RemoveRegistrationOutcome, + RosterPage, + RosterQuery, + TransferRegistrationOutcome, +} from "./registration-repository.types.js" + +export type RosterMethods = Pick< + HostRegistrationRepository, + | "findRegistration" + | "findMyRegistration" + | "findRegistrationsFor" + | "listRoster" + | "setHostNote" + | "cancelRegistration" + | "removeRegistration" + | "transferRegistration" +> + +type CancelRegistrationArgs = Parameters[0] + +type RemoveRegistrationArgs = Parameters[0] + +type TransferRegistrationArgs = Parameters[0] + +type RosterRowSelect = RegistrationRowSelect & { cursor_at: string; checked_in_cursor_at: string } + +const rosterCheckedInKey = (tag: Queryable) => tag`COALESCE(ci.first_at, 'epoch'::timestamptz)` + +const ROSTER_SORTS = Object.freeze({ + registered_at_desc: (tag: Queryable) => tag`r.registered_at DESC, r.id DESC`, + registered_at_asc: (tag: Queryable) => tag`r.registered_at ASC, r.id ASC`, + name_asc: (tag: Queryable) => + tag`lower(COALESCE(u.display_name, g.name, '')) ASC, r.registered_at DESC, r.id DESC`, + checked_in_at_desc: (tag: Queryable) => + tag`${rosterCheckedInKey(tag)} DESC, r.registered_at DESC, r.id DESC`, +}) satisfies Readonly unknown>> + +/** + * Both instants are the microsecond text Postgres rendered (or a legacy cursor spelled), bound back as + * text: a Date anchor drops the microseconds and skips every row sharing the anchor's millisecond. + */ +interface CheckedInRosterCursor { + checkedInAtText: string + registeredAtText: string | null + id: string +} + +// Carries every ORDER BY key: everyone not yet checked in shares the epoch sort key, so a cursor +// without registered_at cannot say where inside that tie the previous page stopped. +function encodeCheckedInRosterCursor(row: RosterRowSelect): string { + return `${row.checked_in_cursor_at}|${encodeKeysetCursor(row.cursor_at, row.id)}` +} + +function parseCheckedInRosterCursor(cursor: string): CheckedInRosterCursor | null { + const parts = cursor.split("|") + if (parts.length <= 2) { + const legacy = parseKeysetCursor(cursor, { direction: "desc" }) + return legacy === null + ? null + : { checkedInAtText: legacy.atText, registeredAtText: null, id: legacy.id } + } + if (parts.length !== 3) return null + const [checkedInText, registeredText, id] = parts + const checkedIn = parseKeysetCursor(`${checkedInText}|${id}`) + const registered = parseKeysetCursor(`${registeredText}|${id}`) + if (checkedIn === null || registered === null) return null + return { + checkedInAtText: checkedIn.atText, + registeredAtText: registered.atText, + id: registered.id, + } +} + +function rosterStatusFilter(tag: Queryable, filter: RegistrationRosterFilter) { + switch (filter) { + case "registered": + return tag`AND r.status = 'registered'` + case "cancelled": + return tag`AND r.status = 'cancelled'` + case "guests": + return tag`AND r.status = 'registered' AND r.guest_id IS NOT NULL` + case "members": + return tag`AND r.status = 'registered' AND r.user_id IS NOT NULL` + case "checked_in": + return tag`AND r.status = 'registered' AND ci.first_at IS NOT NULL` + case "not_checked_in": + return tag`AND r.status = 'registered' AND ci.first_at IS NULL` + case "no_show": + return tag`AND r.status = 'registered' AND EXISTS ( + SELECT 1 FROM cleanup_registration_seats s + WHERE s.registration_id = r.id AND s.no_show_at IS NOT NULL + )` + default: + return tag`` + } +} + +function rosterSearchFilter(tag: Queryable, q: string | null) { + if (q === null || q.length === 0) return tag`` + const pattern = likeContains(q) + return tag`AND ( + u.display_name ILIKE ${pattern} ESCAPE '\\' + OR u.handle ILIKE ${pattern} ESCAPE '\\' + OR g.name ILIKE ${pattern} ESCAPE '\\' + OR EXISTS ( + SELECT 1 FROM cleanup_registration_seats s + WHERE s.registration_id = r.id AND s.attendee_name ILIKE ${pattern} ESCAPE '\\' + ) + )` +} + +function checkedInCursorFilter(tag: Queryable, raw: string) { + const cursor = parseCheckedInRosterCursor(raw) + if (cursor === null) return tag`` + if (cursor.registeredAtText === null) { + // A cursor minted before it carried registered_at cannot place itself inside a tie, so it + // resumes at the start of that tie: a row may repeat once, none is skipped. + return tag`AND ${rosterCheckedInKey(tag)} <= ${cursor.checkedInAtText}::timestamptz` + } + return tag`AND (${rosterCheckedInKey(tag)}, r.registered_at, r.id) < (${cursor.checkedInAtText}::timestamptz, ${cursor.registeredAtText}::timestamptz, ${cursor.id}::uuid)` +} + +function rosterCursorFilter(tag: Queryable, sort: RegistrationRosterSort, raw: string | null) { + if (raw === null) return tag`` + if (sort === "name_asc") { + const cursor = parseNameCursor(raw) + if (cursor === null) return tag`` + return tag`AND (lower(COALESCE(u.display_name, g.name, '')), r.id) > (${cursor.name}, ${cursor.id}::uuid)` + } + if (sort === "checked_in_at_desc") return checkedInCursorFilter(tag, raw) + const direction = sort === "registered_at_asc" ? "asc" : "desc" + const cursor = parseKeysetCursor(raw, { direction }) + if (cursor === null) return tag`` + return tag`AND ${keysetPredicate(tag, tag`r.registered_at`, tag`r.id`, cursor, { direction })}` +} + +function encodeRosterCursor(sort: RegistrationRosterSort, last: RosterRowSelect): string { + if (sort === "name_asc") { + return encodeNameCursor({ + name: (last.person_display_name ?? last.guest_name ?? "").toLowerCase(), + id: last.id, + }) + } + if (sort === "checked_in_at_desc") return encodeCheckedInRosterCursor(last) + return encodeKeysetCursor(last.cursor_at, last.id) +} + +async function listWaitlistedRoster(sql: Sql, query: RosterQuery): Promise { + const page = await listWaitlistIn(sql, { + cleanupId: query.cleanupId, + ticketTypeId: query.ticketTypeId, + status: null, + cursor: query.cursor, + limit: query.limit, + }) + return { + rows: page.rows + .filter((entry) => entry.status === "waiting" || entry.status === "offered") + .map(waitlistEntryAsRegistration), + nextCursor: page.nextCursor, + } +} + +async function countRegistered(sql: Sql, cleanupId: string): Promise { + const counted = await sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_registrations + WHERE cleanup_id = ${cleanupId} AND status = 'registered' + ` + return counted[0]?.n ?? 0 +} + +async function listRosterIn(sql: Sql, query: RosterQuery): Promise { + const typeFilter = + query.ticketTypeId === null ? sql`` : sql`AND r.ticket_type_id = ${query.ticketTypeId}` + const slotFilter = query.slotId === null ? sql`` : sql`AND sc.slot_id = ${query.slotId}` + const rows = await sql` + SELECT ${registrationColumns(sql)}, + ${keysetInstant(sql, sql`r.registered_at`)} AS cursor_at, + ${keysetInstant(sql, rosterCheckedInKey(sql))} AS checked_in_cursor_at + FROM cleanup_registrations r + ${registrationJoins(sql)} + WHERE r.cleanup_id = ${query.cleanupId} + ${rosterStatusFilter(sql, query.filter)} + ${typeFilter} + ${slotFilter} + ${rosterSearchFilter(sql, query.q)} + ${rosterCursorFilter(sql, query.sort, query.cursor)} + ORDER BY ${ROSTER_SORTS[query.sort](sql)} + LIMIT ${query.limit + 1} + ` + const { items, nextCursor } = pageWith(rows, query.limit, (last) => + encodeRosterCursor(query.sort, last), + ) + const page: RosterPage = { rows: await hydrate(sql, items), nextCursor } + if (query.withTotal) page.total = await countRegistered(sql, query.cleanupId) + return page +} + +export interface AppliedBan { + cancelledRegistrations: { id: string; ticketTypeId: string | null }[] + /** Ticket types that got seats back, from cancelled registrations or released waitlist offers. */ + releasedTicketTypeIds: string[] +} + +/** + * The one ban path behind removing an attendee from the event and removing them from the roster. + * The event row lock comes first: every registration, join and waitlist path reads the ban under + * FOR SHARE on the same row, so one racing the ban either commits before it (and is undone here) or + * reads the ban. Waitlist rows are cancelled before registrations, the cleanup_waitlist -> + * cleanup_ticket_types order the expiry sweep and leaveWaitlist take, so the two cannot deadlock. + */ +export async function applyBanIn( + tx: Queryable, + args: { cleanupId: string; userId: string; actorId: string; now: Date }, +): Promise { + await tx` + SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR NO KEY UPDATE + ` + await tx` + INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) + VALUES (${args.cleanupId}, ${args.userId}, ${args.actorId}) + ON CONFLICT (cleanup_id, user_id) DO NOTHING + ` + await tx` + DELETE FROM cleanup_members + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} AND role = 'member' + ` + const waitlist = await cancelWaitlistEntriesIn(tx, { + cleanupId: args.cleanupId, + ticketTypeId: null, + subject: { kind: "user", userId: args.userId }, + now: args.now, + }) + const cancelled = await tx<{ id: string; ticket_type_id: string | null }[]>` + WITH cancelled AS ( + UPDATE cleanup_registrations + SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} + WHERE cleanup_id = ${args.cleanupId} + AND user_id = ${args.userId} + AND status = 'registered' + RETURNING id, ticket_type_id, party_size + ), seats AS ( + UPDATE cleanup_registration_seats s + SET status = 'cancelled' + FROM cancelled c + WHERE s.registration_id = c.id AND s.status = 'active' + RETURNING s.id + ), held AS ( + SELECT ticket_type_id, sum(party_size)::int AS seats + FROM cancelled + WHERE ticket_type_id IS NOT NULL + GROUP BY ticket_type_id + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - h.seats, 0), + updated_at = ${args.now} + FROM held h + WHERE t.id = h.ticket_type_id + RETURNING t.id + ) + SELECT id, ticket_type_id FROM cancelled + ` + await tx` + DELETE FROM cleanup_slot_claims + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} + ` + const registrationTypes = cancelled + .map((row) => row.ticket_type_id) + .filter((id): id is string => id !== null) + return { + cancelledRegistrations: cancelled.map((row) => ({ + id: row.id, + ticketTypeId: row.ticket_type_id, + })), + releasedTicketTypeIds: [...new Set([...waitlist.releasedTicketTypeIds, ...registrationTypes])], + } +} + +async function cancelRegistrationIn( + tag: Queryable, + args: CancelRegistrationArgs, +): Promise { + const cancelled = await tag<{ id: string; ticket_type_id: string | null }[]>` + WITH cancelled AS ( + UPDATE cleanup_registrations + SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} + WHERE id = ${args.registrationId} + AND cleanup_id = ${args.cleanupId} + AND status = 'registered' + RETURNING id, ticket_type_id, party_size + ), seats AS ( + UPDATE cleanup_registration_seats s + SET status = 'cancelled' + FROM cancelled c + WHERE s.registration_id = c.id AND s.status = 'active' + RETURNING s.id + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - c.party_size, 0), + updated_at = ${args.now} + FROM cancelled c + WHERE t.id = c.ticket_type_id + RETURNING t.id + ) + SELECT id, ticket_type_id FROM cancelled + ` + const row = cancelled[0] + if (row === undefined) { + const existing = await loadRegistrationById(tag, args.cleanupId, args.registrationId) + return existing === null ? { kind: "not_found" } : { kind: "already_cancelled" } + } + const registration = await loadRegistrationById(tag, args.cleanupId, row.id) + if (registration === null) return { kind: "not_found" } + return { kind: "cancelled", registration, ticketTypeId: row.ticket_type_id } +} + +async function removeRegistrationIn( + tx: Queryable, + args: RemoveRegistrationArgs, +): Promise { + const target = await tx<{ user_id: string | null }[]>` + SELECT user_id FROM cleanup_registrations + WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 + ` + if (target.length === 0) return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } + const bannedUserId = args.ban ? (target[0]?.user_id ?? null) : null + if (bannedUserId === null) { + const outcome = await cancelRegistrationIn(tx, args) + return { ...outcome, releasedWaitlistTicketTypeIds: [] } + } + const ban = await applyBanIn(tx, { + cleanupId: args.cleanupId, + userId: bannedUserId, + actorId: args.actorId, + now: args.now, + }) + const releasedWaitlistTicketTypeIds = ban.releasedTicketTypeIds + const removed = ban.cancelledRegistrations.find((r) => r.id === args.registrationId) + if (removed === undefined) { + const outcome = await cancelRegistrationIn(tx, args) + return { ...outcome, releasedWaitlistTicketTypeIds } + } + const registration = await loadRegistrationById(tx, args.cleanupId, removed.id) + if (registration === null) return { kind: "not_found", releasedWaitlistTicketTypeIds } + return { + kind: "cancelled", + registration, + ticketTypeId: removed.ticketTypeId, + releasedWaitlistTicketTypeIds, + } +} + +async function transferRegistrationIn( + tx: Queryable, + args: TransferRegistrationArgs, +): Promise { + // The event row first, as applyBanIn takes it: a ban holds it while it moves ticket-type seats and + // then cancels registrations, so a transfer that locked its registration before the ticket types + // could wait on the ban from the opposite end. registerIn's FOR SHARE on the same row keeps the + // same order. + const event = await tx<{ id: string }[]>` + SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR NO KEY UPDATE + ` + if (event[0] === undefined) return { kind: "not_found" } + const locked = await tx< + { id: string; ticket_type_id: string | null; party_size: number; status: string }[] + >` + SELECT id, ticket_type_id, party_size, status + FROM cleanup_registrations + WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 FOR UPDATE + ` + const current = locked[0] + if (current === undefined || current.status !== "registered") return { kind: "not_found" } + if (current.ticket_type_id === args.ticketTypeId) return { kind: "same_type" } + + const wanted = [ + ...new Set( + [args.ticketTypeId, current.ticket_type_id].filter((id): id is string => id !== null), + ), + ].sort() + const types = await tx< + { id: string; max_party_size: number; capacity: number | null; reserved_seats: number }[] + >` + SELECT id, max_party_size, capacity, reserved_seats + FROM cleanup_ticket_types + WHERE cleanup_id = ${args.cleanupId} AND id = ANY(${wanted}::uuid[]) + ORDER BY id + FOR UPDATE + ` + const targetType = types.find((t) => t.id === args.ticketTypeId) + if (targetType === undefined) return { kind: "ticket_type_not_found" } + if (current.party_size > targetType.max_party_size) return { kind: "party_too_large" } + + const reserved = await tx<{ id: string }[]>` + UPDATE cleanup_ticket_types + SET reserved_seats = reserved_seats + ${current.party_size}, updated_at = ${args.now} + WHERE id = ${args.ticketTypeId} + AND cleanup_id = ${args.cleanupId} + AND (capacity IS NULL OR reserved_seats + ${current.party_size} <= capacity) + RETURNING id + ` + if (reserved.length === 0) return { kind: "full" } + + if (current.ticket_type_id !== null) { + await tx` + UPDATE cleanup_ticket_types + SET reserved_seats = GREATEST(reserved_seats - ${current.party_size}, 0), + updated_at = ${args.now} + WHERE id = ${current.ticket_type_id} AND cleanup_id = ${args.cleanupId} + ` + } + + await tx` + UPDATE cleanup_registrations + SET ticket_type_id = ${args.ticketTypeId}, source = 'transfer' + WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} + ` + + const registration = await loadRegistrationById(tx, args.cleanupId, args.registrationId) + if (registration === null) return { kind: "not_found" } + return { kind: "transferred", registration, previousTicketTypeId: current.ticket_type_id } +} + +export function makeRosterMethods(sql: Sql): RosterMethods { + return { + async findRegistration( + cleanupId: string, + registrationId: string, + ): Promise { + return loadRegistrationById(sql, cleanupId, registrationId) + }, + + async findMyRegistration( + cleanupId: string, + subject: RegistrationSubject, + ): Promise { + const rows = await sql` + SELECT ${registrationColumns(sql)} + FROM cleanup_registrations r + ${registrationJoins(sql)} + WHERE r.cleanup_id = ${cleanupId} + AND ${ + subject.kind === "user" + ? sql`r.user_id = ${subject.userId}` + : sql`r.guest_id = ${subject.guestId}` + } + ORDER BY (r.status = 'registered') DESC, r.registered_at DESC + LIMIT 1 + ` + const hydrated = await hydrate(sql, rows) + return hydrated[0] ?? null + }, + + async findRegistrationsFor( + cleanupIds: readonly string[], + userId: string, + ): Promise> { + const out = new Map() + if (cleanupIds.length === 0) return out + const rows = await sql` + SELECT ${registrationColumns(sql)} + FROM cleanup_registrations r + ${registrationJoins(sql)} + WHERE r.cleanup_id = ANY(${[...cleanupIds]}::uuid[]) + AND r.user_id = ${userId} + AND r.status = 'registered' + ` + for (const record of await hydrate(sql, rows)) out.set(record.cleanupId, record) + return out + }, + + async listRoster(query: RosterQuery): Promise { + if (query.filter === "waitlisted") return listWaitlistedRoster(sql, query) + return listRosterIn(sql, query) + }, + + async setHostNote( + cleanupId: string, + registrationId: string, + note: string | null, + ): Promise { + const rows = await sql<{ id: string }[]>` + UPDATE cleanup_registrations SET host_note = ${note} + WHERE id = ${registrationId} AND cleanup_id = ${cleanupId} + RETURNING id + ` + return rows.length > 0 + }, + + async cancelRegistration(args: CancelRegistrationArgs): Promise { + return cancelRegistrationIn(sql, args) + }, + + async removeRegistration(args: RemoveRegistrationArgs): Promise { + return sql.begin((tx) => removeRegistrationIn(tx, args)) + }, + + async transferRegistration( + args: TransferRegistrationArgs, + ): Promise { + return sql.begin((tx) => transferRegistrationIn(tx, args)) + }, + } +} diff --git a/services/api/src/services/host/registration-service.ts b/services/api/src/services/host/registration-service.ts index e7aea04e..5d1b2784 100644 --- a/services/api/src/services/host/registration-service.ts +++ b/services/api/src/services/host/registration-service.ts @@ -12,7 +12,6 @@ import type { NotificationType, RegisterForEventRequest, RegisterForEventResponse, - RegisterOutcome, RemoveEventRegistrationRequest, SetEventRegistrationNoteRequest, TransferEventRegistrationRequest, @@ -25,6 +24,7 @@ import { eventEndedError, eventWindowOf, hasEventEnded } from "../cleanup-rules. import { sha256Hex } from "../../auth/crypto.js" import type { CounterStore } from "../../abuse/counter-store.js" import type { AdminAuditAction } from "../admin/audit.js" +import { reserveCounterBudget } from "./counter-budget.js" import { validateAnswers } from "./question-validation.js" import { REGISTRATION_ROSTER_DEFAULT_LIMIT, @@ -43,19 +43,21 @@ import type { SeatDraft, } from "./registration-repository.types.js" -export const REGISTER_FLIP_COUNTER_KEY = "event:register" +const REGISTER_FLIP_COUNTER_KEY = "event:register" -export const REGISTER_FLIPS_PER_HOUR = 20 +const REGISTER_FLIPS_PER_HOUR = 20 -export const REGISTER_FLIP_WINDOW_SECONDS = 60 * 60 +const REGISTER_FLIP_WINDOW_SECONDS = 60 * 60 -export const HOST_ROSTER_READ_COUNTER_KEY = "host:rosterReads" +const HOST_ROSTER_READ_COUNTER_KEY = "host:rosterReads" -export const HOST_ROSTER_READS_PER_HOUR = 200 +const HOST_ROSTER_READS_PER_HOUR = 200 + +const HOST_ROSTER_READ_WINDOW_SECONDS = 60 * 60 export const HOST_TEAM_SIGNAL_CAP = 50 -export const WALKUP_IDEMPOTENCY_BUCKET_MS = 60_000 +const WALKUP_IDEMPOTENCY_BUCKET_MS = 60_000 export function walkupIdempotencyKey( actorId: string, @@ -151,25 +153,6 @@ export interface RegistrationService { }): Promise } -const REFUSAL_OUTCOMES: ReadonlySet = new Set([ - "already_registered", - "waitlisted", - "full", - "party_too_large", - "sales_closed", - "registration_closed", - "ticket_type_not_found", - "access_code_required", - "access_code_invalid", - "answers_invalid", - "banned", - "closed", -]) - -export function isRegisterRefusal(outcome: RegisterOutcome): boolean { - return REFUSAL_OUTCOMES.has(outcome) -} - export function makeRegistrationService(deps: RegistrationServiceDeps): RegistrationService { const now = deps.now ?? (() => new Date()) const newId = deps.newId ?? (() => crypto.randomUUID()) @@ -225,44 +208,34 @@ export function makeRegistrationService(deps: RegistrationServiceDeps): Registra } async function reserveFlipBudget(subject: RegistrationSubject): Promise { - if (deps.counters === undefined) return const owner = subject.kind === "user" ? subject.userId : subject.guestId - let used: number - try { - used = await deps.counters.incr( - `${REGISTER_FLIP_COUNTER_KEY}:${owner}`, - REGISTER_FLIP_WINDOW_SECONDS, - ) - } catch (err) { - deps.logger?.warn( - { err }, - "registration: abuse counter unavailable; refusing the write (fail closed)", - ) - throw AppError.rateLimited("Registration is temporarily unavailable. Try again shortly.") - } - if (used > REGISTER_FLIPS_PER_HOUR) { - throw AppError.rateLimited("Too many registration changes. Try again later.") - } + await reserveCounterBudget( + deps.counters, + { + key: `${REGISTER_FLIP_COUNTER_KEY}:${owner}`, + windowSeconds: REGISTER_FLIP_WINDOW_SECONDS, + cap: REGISTER_FLIPS_PER_HOUR, + unavailableLog: "registration: abuse counter unavailable; refusing the write (fail closed)", + unavailableMessage: "Registration is temporarily unavailable. Try again shortly.", + exceededMessage: "Too many registration changes. Try again later.", + }, + deps.logger, + ) } async function reserveRosterBudget(actorId: string): Promise { - if (deps.counters === undefined) return - let used: number - try { - used = await deps.counters.incr( - `${HOST_ROSTER_READ_COUNTER_KEY}:${actorId}`, - REGISTER_FLIP_WINDOW_SECONDS, - ) - } catch (err) { - deps.logger?.warn( - { err }, - "roster: harvest counter unavailable; refusing the read (fail closed)", - ) - throw AppError.rateLimited("The roster is temporarily unavailable.") - } - if (used > HOST_ROSTER_READS_PER_HOUR) { - throw AppError.rateLimited("Too many roster reads. Try again later.") - } + await reserveCounterBudget( + deps.counters, + { + key: `${HOST_ROSTER_READ_COUNTER_KEY}:${actorId}`, + windowSeconds: HOST_ROSTER_READ_WINDOW_SECONDS, + cap: HOST_ROSTER_READS_PER_HOUR, + unavailableLog: "roster: harvest counter unavailable; refusing the read (fail closed)", + unavailableMessage: "The roster is temporarily unavailable.", + exceededMessage: "Too many roster reads. Try again later.", + }, + deps.logger, + ) } async function notifyRegistered( diff --git a/services/api/src/services/host/registration-sql.ts b/services/api/src/services/host/registration-sql.ts index 87c5f768..34370666 100644 --- a/services/api/src/services/host/registration-sql.ts +++ b/services/api/src/services/host/registration-sql.ts @@ -14,6 +14,7 @@ import type { WaitlistStatus, } from "@civfix/shared" import type { Queryable } from "../../db/client.js" +import { publicServedKeyExpr } from "../media-served-key.js" import type { AnswerRecord, PageRecord, @@ -27,18 +28,18 @@ import type { export const PG_UNIQUE_VIOLATION = "23505" -export const PG_CHECK_VIOLATION = "23514" +const PG_CHECK_VIOLATION = "23514" -export const RESERVED_SEATS_BACKSTOP_CONSTRAINT = "cleanup_ticket_types_reserved_bounds" +const RESERVED_SEATS_BACKSTOP_CONSTRAINT = "cleanup_ticket_types_reserved_bounds" -export const ANSWER_PREVIEW_MAX = 120 +const ANSWER_PREVIEW_MAX = 120 -export interface PgErrorShape { +interface PgErrorShape { code?: unknown constraint_name?: unknown } -export function pgErrorConstraint(err: unknown): { code: string; constraint: string } | null { +function pgErrorConstraint(err: unknown): { code: string; constraint: string } | null { if (typeof err !== "object" || err === null) return null const e = err as PgErrorShape if (typeof e.code !== "string") return null @@ -244,7 +245,17 @@ export interface RegistrationRowSelect { person_deleted_at: Date | null } -function toIdentity(r: RegistrationRowSelect): RegistrantIdentity | null { +type RegistrantColumns = Pick< + RegistrationRowSelect, + | "user_id" + | "person_display_name" + | "person_handle" + | "person_bio" + | "person_avatar_url" + | "person_deleted_at" +> + +function toIdentity(r: RegistrantColumns): RegistrantIdentity | null { if (r.user_id === null) return null return { userId: r.user_id, @@ -379,17 +390,7 @@ export function toWaitlistRecord(r: WaitlistRowSelect): WaitlistRecord { userId: r.user_id, guestId: r.guest_id, guestName: r.guest_name, - identity: - r.user_id === null - ? null - : { - userId: r.user_id, - displayName: r.person_display_name, - handle: r.person_handle, - bio: r.person_bio, - avatarUrl: r.person_avatar_url, - deletedAt: r.person_deleted_at, - }, + identity: toIdentity(r), partySize: r.party_size, status: r.status, position: r.position, @@ -463,7 +464,32 @@ export interface PageRowSelect { view_count: string | number } -export const DEFAULT_PAGE_SEO: EventPageSeo = { noindex: false } +export function pageColumns(tag: Queryable) { + return tag` + c.id AS cleanup_id, + c.page_slug AS slug, + COALESCE(p.status, 'draft') AS status, + COALESCE(p.theme_accent, 'bloom') AS theme_accent, + p.blocks, + p.seo, + c.cover_media_id, + ${publicServedKeyExpr(tag, "m")} AS cover_key, + c.visibility, + p.published_at, + p.updated_at, + p.flagged_at, + p.flag_reason, + COALESCE(p.view_count, 0) AS view_count` +} + +export function pageJoins(tag: Queryable) { + return tag` + LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id + LEFT JOIN media_assets m ON m.id = c.cover_media_id AND m.status = 'ready' + ` +} + +const DEFAULT_PAGE_SEO: EventPageSeo = { noindex: false } export function toPageRecord(r: PageRowSelect): PageRecord { return { diff --git a/services/api/src/services/host/registration-ticket-types.drizzle.ts b/services/api/src/services/host/registration-ticket-types.drizzle.ts new file mode 100644 index 00000000..98e73eca --- /dev/null +++ b/services/api/src/services/host/registration-ticket-types.drizzle.ts @@ -0,0 +1,334 @@ +import type { Queryable, Sql, TransactionSql } from "../../db/client.js" +import { constantTimeStringEqual } from "../../auth/crypto.js" +import { + isCheckViolationOn, + isUniqueViolationOn, + SALES_WINDOW_CONSTRAINT, +} from "./registration-sql.js" +import { loadTicketType, loadTicketTypes } from "./registration-load.drizzle.js" +import type { + CreateTicketTypeOutcome, + DeleteTicketTypeOutcome, + HostRegistrationRepository, + ReorderTicketTypesOutcome, + TicketTypeCapacityFit, + TicketTypeRecord, + TicketTypeWriteArgs, + UpdateTicketTypeOutcome, +} from "./registration-repository.types.js" + +export const MAX_TICKET_TYPES = 20 + +const TICKET_TYPE_NAME_CONSTRAINT = "cleanup_ticket_types_cleanup_name_uidx" + +export type TicketTypeMethods = Pick< + HostRegistrationRepository, + | "listTicketTypes" + | "listTicketTypesFor" + | "getTicketType" + | "ticketTypeIdsMatchingAccessCode" + | "createTicketType" + | "updateTicketType" + | "deleteTicketType" + | "reorderTicketTypes" +> + +async function applyQuestionOwnership( + tag: Queryable, + cleanupId: string, + ticketTypeId: string, + questionIds: readonly string[], +): Promise { + await tag` + UPDATE cleanup_questions + SET ticket_type_id = NULL, updated_at = now() + WHERE cleanup_id = ${cleanupId} + AND ticket_type_id = ${ticketTypeId} + AND NOT (id = ANY(${[...questionIds]}::uuid[])) + ` + if (questionIds.length === 0) return + await tag` + UPDATE cleanup_questions + SET ticket_type_id = ${ticketTypeId}, updated_at = now() + WHERE cleanup_id = ${cleanupId} + AND id = ANY(${[...questionIds]}::uuid[]) + ` +} + +async function ticketTypeCapacityFit( + tx: TransactionSql, + cleanupId: string, + eventCapacity: number | null, + nextCapacity: number | null, + excludeTicketTypeId: string | null, +): Promise { + if (eventCapacity === null) return { ok: true } + const exclude = excludeTicketTypeId === null ? tx`` : tx`AND id <> ${excludeTicketTypeId}` + const rows = await tx<{ used: number | null; unlimited: boolean | null }[]>` + SELECT sum(capacity)::int AS used, bool_or(capacity IS NULL) AS unlimited + FROM cleanup_ticket_types + WHERE cleanup_id = ${cleanupId} + ${exclude} + ` + const used = rows[0]?.used ?? 0 + if (rows[0]?.unlimited === true) return { ok: false, eventCapacity, used } + if (nextCapacity === null) return { ok: false, eventCapacity, used } + if (used + nextCapacity > eventCapacity) return { ok: false, eventCapacity, used } + return { ok: true } +} + +function ticketTypeWriteRefusal(err: unknown): { kind: "name_taken" | "sales_window" } | null { + if (isUniqueViolationOn(err, TICKET_TYPE_NAME_CONSTRAINT)) return { kind: "name_taken" } + if (isCheckViolationOn(err, SALES_WINDOW_CONSTRAINT)) return { kind: "sales_window" } + return null +} + +async function insertTicketTypeIn( + tx: TransactionSql, + args: TicketTypeWriteArgs, +): Promise { + const locked = await tx<{ id: string; capacity: number | null }[]>` + SELECT id, capacity FROM cleanups + WHERE id = ${args.cleanupId} + LIMIT 1 FOR NO KEY UPDATE + ` + const event = locked[0] + if (event === undefined) return { kind: "not_found" } + + const counted = await tx<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_ticket_types WHERE cleanup_id = ${args.cleanupId} + ` + if ((counted[0]?.n ?? 0) >= MAX_TICKET_TYPES) return { kind: "too_many" } + + const fit = await ticketTypeCapacityFit(tx, args.cleanupId, event.capacity, args.capacity, null) + if (!fit.ok) { + return { kind: "capacity_exceeded", eventCapacity: fit.eventCapacity, used: fit.used } + } + + const sortOrder = + args.sortOrder ?? + ( + await tx<{ next: number }[]>` + SELECT COALESCE(max(sort_order), -1) + 1 AS next + FROM cleanup_ticket_types WHERE cleanup_id = ${args.cleanupId} + ` + )[0]?.next ?? + 0 + + const inserted = await tx<{ id: string }[]>` + INSERT INTO cleanup_ticket_types ( + cleanup_id, name, description, capacity, sales_opens_at, sales_closes_at, + visibility, access_code_hash, max_party_size, sort_order, waitlist_enabled, + created_at, updated_at + ) VALUES ( + ${args.cleanupId}, ${args.name}, ${args.description}, ${args.capacity}, + ${args.salesOpensAt}, ${args.salesClosesAt}, ${args.visibility}, + ${args.accessCodeHash}, ${args.maxPartySize}, ${sortOrder}, ${args.waitlistEnabled}, + ${args.now}, ${args.now} + ) + RETURNING id + ` + const id = inserted[0]?.id + if (id === undefined) throw new Error("ticket type insert returned no row") + + if (args.questionIds !== null) { + await applyQuestionOwnership(tx, args.cleanupId, id, args.questionIds) + } + + const record = await loadTicketType(tx, args.cleanupId, id) + if (record === null) throw new Error("ticket type reload returned no row") + return { kind: "created", record } +} + +async function updateTicketTypeIn( + sql: Sql, + tx: TransactionSql, + args: TicketTypeWriteArgs & { ticketTypeId: string; patch: readonly string[] }, +): Promise { + const patch = new Set(args.patch) + const event = ( + await tx<{ id: string; capacity: number | null }[]>` + SELECT id, capacity FROM cleanups + WHERE id = ${args.cleanupId} + LIMIT 1 FOR NO KEY UPDATE + ` + )[0] + if (event === undefined) return { kind: "not_found" } + + const locked = await tx<{ reserved_seats: number }[]>` + SELECT reserved_seats FROM cleanup_ticket_types + WHERE id = ${args.ticketTypeId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 FOR UPDATE + ` + const current = locked[0] + if (current === undefined) return { kind: "not_found" } + + if (patch.has("capacity") && args.capacity !== null && args.capacity < current.reserved_seats) { + return { kind: "capacity_below_reserved", reservedSeats: current.reserved_seats } + } + + if (patch.has("capacity")) { + const fit = await ticketTypeCapacityFit( + tx, + args.cleanupId, + event.capacity, + args.capacity, + args.ticketTypeId, + ) + if (!fit.ok) { + return { kind: "capacity_exceeded", eventCapacity: fit.eventCapacity, used: fit.used } + } + } + + await tx` + UPDATE cleanup_ticket_types SET + name = ${patch.has("name") ? args.name : sql`name`}, + description = ${patch.has("description") ? args.description : sql`description`}, + capacity = ${patch.has("capacity") ? args.capacity : sql`capacity`}, + sales_opens_at = ${patch.has("salesOpensAt") ? args.salesOpensAt : sql`sales_opens_at`}, + sales_closes_at = ${patch.has("salesClosesAt") ? args.salesClosesAt : sql`sales_closes_at`}, + visibility = ${patch.has("visibility") ? args.visibility : sql`visibility`}, + access_code_hash = ${ + args.clearAccessCode + ? null + : args.accessCodeHash !== null + ? args.accessCodeHash + : sql`access_code_hash` + }, + max_party_size = ${patch.has("maxPartySize") ? args.maxPartySize : sql`max_party_size`}, + sort_order = ${patch.has("sortOrder") && args.sortOrder !== null ? args.sortOrder : sql`sort_order`}, + waitlist_enabled = ${patch.has("waitlistEnabled") ? args.waitlistEnabled : sql`waitlist_enabled`}, + updated_at = ${args.now} + WHERE id = ${args.ticketTypeId} AND cleanup_id = ${args.cleanupId} + ` + + if (args.questionIds !== null) { + await applyQuestionOwnership(tx, args.cleanupId, args.ticketTypeId, args.questionIds) + } + + const record = await loadTicketType(tx, args.cleanupId, args.ticketTypeId) + if (record === null) return { kind: "not_found" } + return { kind: "updated", record } +} + +export function makeTicketTypeMethods(sql: Sql): TicketTypeMethods { + return { + async listTicketTypes(cleanupId: string): Promise { + return loadTicketTypes(sql, [cleanupId]) + }, + + async listTicketTypesFor( + cleanupIds: readonly string[], + ): Promise> { + const out = new Map() + for (const record of await loadTicketTypes(sql, cleanupIds)) { + const bucket = out.get(record.cleanupId) + if (bucket === undefined) out.set(record.cleanupId, [record]) + else bucket.push(record) + } + return out + }, + + async getTicketType(cleanupId: string, ticketTypeId: string): Promise { + return loadTicketType(sql, cleanupId, ticketTypeId) + }, + + async ticketTypeIdsMatchingAccessCode( + cleanupId: string, + accessCodeHash: string, + ): Promise { + const rows = await sql<{ id: string; access_code_hash: string | null }[]>` + SELECT id, access_code_hash FROM cleanup_ticket_types + WHERE cleanup_id = ${cleanupId} AND visibility = 'access_code' + ORDER BY sort_order, id + LIMIT ${MAX_TICKET_TYPES} + ` + return rows + .filter( + (row) => + row.access_code_hash !== null && + constantTimeStringEqual(accessCodeHash, row.access_code_hash), + ) + .map((row) => row.id) + }, + + async createTicketType(args: TicketTypeWriteArgs): Promise { + try { + return await sql.begin((tx) => insertTicketTypeIn(tx, args)) + } catch (err) { + const refusal = ticketTypeWriteRefusal(err) + if (refusal !== null) return refusal + throw err + } + }, + + async updateTicketType( + args: TicketTypeWriteArgs & { ticketTypeId: string; patch: readonly string[] }, + ): Promise { + try { + return await sql.begin((tx) => updateTicketTypeIn(sql, tx, args)) + } catch (err) { + const refusal = ticketTypeWriteRefusal(err) + if (refusal !== null) return refusal + throw err + } + }, + + async deleteTicketType( + cleanupId: string, + ticketTypeId: string, + ): Promise { + return sql.begin(async (tx) => { + const locked = await tx<{ id: string }[]>` + SELECT id FROM cleanup_ticket_types + WHERE id = ${ticketTypeId} AND cleanup_id = ${cleanupId} + LIMIT 1 FOR UPDATE + ` + if (locked[0] === undefined) return { kind: "not_found" as const } + + const referenced = await tx<{ one: number }[]>` + SELECT 1 AS one WHERE EXISTS ( + SELECT 1 FROM cleanup_registrations WHERE ticket_type_id = ${ticketTypeId} + ) OR EXISTS ( + SELECT 1 FROM cleanup_waitlist WHERE ticket_type_id = ${ticketTypeId} + ) + ` + if (referenced.length > 0) return { kind: "in_use" as const } + + await tx` + UPDATE cleanup_questions SET ticket_type_id = NULL, updated_at = now() + WHERE cleanup_id = ${cleanupId} AND ticket_type_id = ${ticketTypeId} + ` + await tx` + DELETE FROM cleanup_ticket_types + WHERE id = ${ticketTypeId} AND cleanup_id = ${cleanupId} + ` + return { kind: "deleted" as const } + }) + }, + + async reorderTicketTypes( + cleanupId: string, + ticketTypeIds: readonly string[], + now: Date, + ): Promise { + return sql.begin(async (tx) => { + const existing = await tx<{ id: string }[]>` + SELECT id FROM cleanup_ticket_types WHERE cleanup_id = ${cleanupId} ORDER BY id + ` + const have = new Set(existing.map((r) => r.id)) + const want = new Set(ticketTypeIds) + if (have.size !== want.size || [...want].some((id) => !have.has(id))) { + return { kind: "mismatch" as const } + } + for (const [index, id] of ticketTypeIds.entries()) { + await tx` + UPDATE cleanup_ticket_types SET sort_order = ${index}, updated_at = ${now} + WHERE id = ${id} AND cleanup_id = ${cleanupId} + ` + } + return { kind: "reordered" as const, items: await loadTicketTypes(tx, [cleanupId]) } + }) + }, + } +} diff --git a/services/api/src/services/host/registration-waitlist.drizzle.ts b/services/api/src/services/host/registration-waitlist.drizzle.ts new file mode 100644 index 00000000..f25f1945 --- /dev/null +++ b/services/api/src/services/host/registration-waitlist.drizzle.ts @@ -0,0 +1,663 @@ +import type postgres from "postgres" +import type { Queryable, Sql, TransactionSql } from "../../db/client.js" +import { constantTimeStringEqual } from "../../auth/crypto.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../../db/cursor-helpers.js" +import { DEFAULT_EVENT_DURATION_MS, eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" +import { + isUniqueViolationOn, + toWaitlistRecord, + waitlistColumns, + waitlistEntryNotBanned, + waitlistJoins, + type WaitlistRowSelect, +} from "./registration-sql.js" +import { + isBannedIn, + loadRegistrationById, + loadWaitlistEntry, + subjectIs, +} from "./registration-load.drizzle.js" +import { registerErrorOutcome, registerIn } from "./registration-register.drizzle.js" +import type { + ClaimWaitlistOutcome, + EventRegistrationContext, + HostRegistrationRepository, + JoinWaitlistOutcome, + RegisterTxArgs, + RegistrationRecord, + RegistrationSubject, + TicketTypeRecord, + WaitlistOffer, + WaitlistRecord, +} from "./registration-repository.types.js" + +const DEFAULT_EVENT_DURATION_SEC = DEFAULT_EVENT_DURATION_MS / 1000 + +const WAITLIST_ACTIVE_CONSTRAINTS = [ + "cleanup_waitlist_active_user_uidx", + "cleanup_waitlist_active_guest_uidx", +] + +const WAITLIST_CLAIM_IDEMPOTENCY_PREFIX = "waitlist:" + +export type WaitlistMethods = Pick< + HostRegistrationRepository, + | "joinWaitlist" + | "leaveWaitlist" + | "listWaitlist" + | "findWaitlistEntry" + | "ticketTypeIdsWithWaiting" + | "offerNextWaitlistEntry" + | "offerWaitlistEntry" + | "expireWaitlistOffers" + | "claimWaitlistOffer" +> + +type JoinWaitlistArgs = Parameters[0] + +type ClaimWaitlistArgs = Parameters[0] + +export type ListWaitlistArgs = Parameters[0] + +interface LockedEventWindow { + status: EventRegistrationContext["status"] + scheduled_at: Date + ends_at: Date | null + now: Date +} + +interface WaitlistTicketTypeRow { + id: string + waitlist_enabled: boolean + visibility: TicketTypeRecord["visibility"] + access_code_hash: string | null +} + +type OfferCandidate = Omit + +class ClaimRefusal extends Error { + readonly outcome: ClaimWaitlistOutcome + + constructor(outcome: ClaimWaitlistOutcome) { + super("waitlist claim refused after the transaction began writing") + this.name = "ClaimRefusal" + this.outcome = outcome + } +} + +/** + * An EXISTS probe, not a join: a join under FOR UPDATE would also lock the cleanups row and invert the + * cleanups -> cleanup_ticket_types -> cleanup_waitlist lock order every writer takes. The end instant + * mirrors hasEventEnded (ends_at, else scheduled_at plus the default duration) on the DB clock. + */ +function waitlistEventStillLive(tag: Queryable) { + return tag`EXISTS ( + SELECT 1 FROM cleanups c WHERE c.id = w.cleanup_id + AND c.status <> 'cancelled' + AND COALESCE(c.ends_at, c.scheduled_at + make_interval(secs => ${DEFAULT_EVENT_DURATION_SEC})) + > now() + )` +} + +export async function cancelWaitlistEntriesIn( + tag: Queryable, + args: { + cleanupId: string + ticketTypeId: string | null + subject: RegistrationSubject + now: Date + }, +): Promise<{ left: number; releasedTicketTypeIds: string[] }> { + const typeFilter = + args.ticketTypeId === null ? tag`` : tag`AND ticket_type_id = ${args.ticketTypeId}` + const rows = await tag<{ id: string; released_ticket_type_id: string | null }[]>` + WITH left_entries AS ( + UPDATE cleanup_waitlist + SET status = 'cancelled' + WHERE cleanup_id = ${args.cleanupId} + AND status IN ('waiting', 'offered') + ${typeFilter} + AND ${subjectIs(tag, args.subject)} + RETURNING id, ticket_type_id, party_size, offered_at + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), + updated_at = ${args.now} + FROM left_entries e + WHERE t.id = e.ticket_type_id AND e.offered_at IS NOT NULL + RETURNING t.id + ) + SELECT id, + CASE WHEN offered_at IS NULL THEN NULL ELSE ticket_type_id END + AS released_ticket_type_id + FROM left_entries + ` + return { + left: rows.length, + releasedTicketTypeIds: [ + ...new Set( + rows.map((row) => row.released_ticket_type_id).filter((id): id is string => id !== null), + ), + ], + } +} + +export function waitlistEntryAsRegistration(entry: WaitlistRecord): RegistrationRecord { + return { + id: entry.id, + cleanupId: entry.cleanupId, + ticketTypeId: entry.ticketTypeId, + ticketTypeName: entry.ticketTypeName, + userId: entry.userId, + guestId: entry.guestId, + guestName: entry.guestName, + identity: entry.identity, + partySize: entry.partySize, + status: "registered", + source: "waitlist", + hostNote: null, + registeredAt: entry.createdAt, + cancelledAt: null, + checkedInAt: null, + slotId: null, + slotTitle: null, + seats: [], + answersPreview: null, + } +} + +export async function listWaitlistIn( + tag: Queryable, + args: ListWaitlistArgs, +): Promise<{ rows: WaitlistRecord[]; nextCursor: string | null }> { + const typeFilter = + args.ticketTypeId === null ? tag`` : tag`AND w.ticket_type_id = ${args.ticketTypeId}` + const statusFilter = + args.status === null + ? tag`AND w.status IN ('waiting', 'offered')` + : tag`AND w.status = ${args.status}` + // A banned user can never be seated, so their open rows are not part of the host's queue; closed + // rows stay listed as history. + const bannedFilter = + args.status === null || args.status === "waiting" || args.status === "offered" + ? tag`AND ${waitlistEntryNotBanned(tag)}` + : tag`` + const cursor = parseKeysetCursor(args.cursor, { direction: "asc" }) + const cursorFilter = + cursor === null + ? tag`` + : tag`AND ${keysetPredicate(tag, tag`w.created_at`, tag`w.id`, cursor, { direction: "asc" })}` + const rows = await tag<(WaitlistRowSelect & { cursor_at: string })[]>` + SELECT ${waitlistColumns(tag)}, ${keysetInstant(tag, tag`w.created_at`)} AS cursor_at + FROM cleanup_waitlist w + ${waitlistJoins(tag)} + WHERE w.cleanup_id = ${args.cleanupId} + ${typeFilter} + ${statusFilter} + ${cursorFilter} + ${bannedFilter} + ORDER BY w.created_at ASC, w.id ASC + LIMIT ${args.limit + 1} + ` + const { items, nextCursor } = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.id, + })) + return { rows: items.map(toWaitlistRecord), nextCursor } +} + +async function releaseWaitlistHold( + tx: TransactionSql, + cleanupId: string, + waitlistId: string, + now: Date, +): Promise { + await tx` + WITH expired AS ( + UPDATE cleanup_waitlist SET status = 'expired' + WHERE id = ${waitlistId} AND cleanup_id = ${cleanupId} + RETURNING ticket_type_id, party_size, offered_at + ) + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), + updated_at = ${now} + FROM expired e + WHERE t.id = e.ticket_type_id AND e.offered_at IS NOT NULL + ` +} + +async function classifyUnclaimable( + tx: TransactionSql, + cleanupId: string, + waitlistId: string, + subject: RegistrationSubject, + now: Date, +): Promise { + const rows = await tx< + { + status: WaitlistRecord["status"] + claim_expires_at: Date | null + user_id: string | null + guest_id: string | null + promoted_registration_id: string | null + }[] + >` + SELECT status, claim_expires_at, user_id, guest_id, promoted_registration_id + FROM cleanup_waitlist + WHERE id = ${waitlistId} AND cleanup_id = ${cleanupId} + LIMIT 1 FOR UPDATE + ` + const row = rows[0] + if (row === undefined) return { kind: "not_found" } + const owns = + subject.kind === "user" ? row.user_id === subject.userId : row.guest_id === subject.guestId + if (row.status === "claimed" && owns && row.promoted_registration_id !== null) { + const registration = await loadRegistrationById(tx, cleanupId, row.promoted_registration_id) + if (registration !== null) return { kind: "claimed", registration } + } + if (row.status === "expired") return { kind: "expired" } + if (row.status !== "offered") return { kind: "not_offered" } + if (row.claim_expires_at !== null && row.claim_expires_at <= now) { + await releaseWaitlistHold(tx, cleanupId, waitlistId, now) + return { kind: "expired" } + } + return { kind: "not_offered" } +} + +async function lockEventWindow( + tx: TransactionSql, + cleanupId: string, +): Promise { + const locked = await tx` + SELECT status, scheduled_at, ends_at, now() AS now FROM cleanups + WHERE id = ${cleanupId} LIMIT 1 FOR SHARE + ` + return locked[0] +} + +function eventWindowEnded(event: LockedEventWindow): boolean { + return hasEventEnded(eventWindowOfRow(event), event.now.getTime()) +} + +async function findLiveWaitlistEntryId( + tag: Queryable, + ticketTypeId: string, + subject: RegistrationSubject, +): Promise { + const existing = await tag<{ id: string }[]>` + SELECT id FROM cleanup_waitlist + WHERE ticket_type_id = ${ticketTypeId} + AND status IN ('waiting', 'offered') + AND ${subjectIs(tag, subject)} + LIMIT 1 + ` + return existing[0]?.id +} + +function waitlistTicketTypeRefusal( + type: WaitlistTicketTypeRow | undefined, + accessCodeHash: string | null, +): JoinWaitlistOutcome | null { + if (type === undefined || type.visibility === "hidden") return { kind: "ticket_type_not_found" } + if (!type.waitlist_enabled) return { kind: "waitlist_disabled" } + if (type.visibility !== "access_code") return null + if (accessCodeHash === null) return { kind: "access_code_required" } + if ( + type.access_code_hash === null || + !constantTimeStringEqual(accessCodeHash, type.access_code_hash) + ) { + return { kind: "access_code_invalid" } + } + return null +} + +async function joinWaitlistGateRefusal( + tx: TransactionSql, + args: JoinWaitlistArgs, +): Promise { + const event = await lockEventWindow(tx, args.cleanupId) + if (event === undefined) return { kind: "not_found" } + if (event.status === "cancelled") return { kind: "closed" } + if (eventWindowEnded(event)) return { kind: "ended" } + if (args.subject.kind === "user" && (await isBannedIn(tx, args.cleanupId, args.subject.userId))) { + return { kind: "banned" } + } + + const typeRows = await tx` + SELECT id, waitlist_enabled, visibility, access_code_hash + FROM cleanup_ticket_types + WHERE id = ${args.ticketTypeId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 + ` + const typeRefusal = waitlistTicketTypeRefusal(typeRows[0], args.accessCodeHash) + if (typeRefusal !== null) return typeRefusal + + const registered = await tx<{ id: string }[]>` + SELECT id FROM cleanup_registrations + WHERE cleanup_id = ${args.cleanupId} + AND status = 'registered' + AND ${subjectIs(tx, args.subject)} + LIMIT 1 + ` + return registered.length > 0 ? { kind: "already_registered" } : null +} + +async function joinWaitlistIn( + tx: TransactionSql, + args: JoinWaitlistArgs, +): Promise { + const refusal = await joinWaitlistGateRefusal(tx, args) + if (refusal !== null) return refusal + + const existingId = await findLiveWaitlistEntryId(tx, args.ticketTypeId, args.subject) + if (existingId !== undefined) { + const entry = await loadWaitlistEntry(tx, args.cleanupId, existingId) + if (entry === null) return { kind: "not_found" } + return { kind: "already_waiting", entry } + } + + const inserted = await tx<{ id: string }[]>` + INSERT INTO cleanup_waitlist ( + cleanup_id, ticket_type_id, user_id, guest_id, party_size, status, created_at + ) VALUES ( + ${args.cleanupId}, ${args.ticketTypeId}, + ${args.subject.kind === "user" ? args.subject.userId : null}, + ${args.subject.kind === "guest" ? args.subject.guestId : null}, + ${args.partySize}, 'waiting', ${args.now} + ) + RETURNING id + ` + const id = inserted[0]?.id + if (id === undefined) throw new Error("waitlist insert returned no row") + const entry = await loadWaitlistEntry(tx, args.cleanupId, id) + if (entry === null) throw new Error("waitlist reload returned no row") + return { kind: "joined", entry } +} + +async function reserveAndOffer( + tx: TransactionSql, + candidate: OfferCandidate, + cleanupScope: postgres.Fragment, + now: Date, + claimExpiresAt: Date, +): Promise { + const reserved = await tx<{ id: string }[]>` + UPDATE cleanup_ticket_types + SET reserved_seats = reserved_seats + ${candidate.partySize}, updated_at = ${now} + WHERE id = ${candidate.ticketTypeId} + ${cleanupScope} + AND (capacity IS NULL OR reserved_seats + ${candidate.partySize} <= capacity) + RETURNING id + ` + if (reserved.length === 0) return null + + await tx` + UPDATE cleanup_waitlist + SET status = 'offered', offered_at = ${now}, claim_expires_at = ${claimExpiresAt} + WHERE id = ${candidate.waitlistId} + ` + return { ...candidate, claimExpiresAt } +} + +function claimRegisterArgs( + args: ClaimWaitlistArgs, + entry: WaitlistRecord, + subject: RegistrationSubject, +): RegisterTxArgs { + return { + cleanupId: args.cleanupId, + subject, + ticketTypeId: entry.ticketTypeId, + seats: args.seats.slice(0, entry.partySize), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "waitlist", + idempotencyKey: `${WAITLIST_CLAIM_IDEMPOTENCY_PREFIX}${args.waitlistId}`, + waitlistId: args.waitlistId, + now: args.now, + } +} + +async function claimOfferIn( + tx: TransactionSql, + args: ClaimWaitlistArgs, + subject: RegistrationSubject, + registerArgs: RegisterTxArgs, +): Promise { + const event = await lockEventWindow(tx, args.cleanupId) + const claimed = await tx< + { party_size: number; user_id: string | null; guest_id: string | null }[] + >` + UPDATE cleanup_waitlist + SET status = 'claimed' + WHERE id = ${args.waitlistId} + AND cleanup_id = ${args.cleanupId} + AND status = 'offered' + AND (claim_expires_at IS NULL OR claim_expires_at > ${args.now}) + AND party_size = ${registerArgs.seats.length} + RETURNING party_size, user_id, guest_id + ` + const row = claimed[0] + if (row === undefined) { + return classifyUnclaimable(tx, args.cleanupId, args.waitlistId, subject, args.now) + } + + const owns = + subject.kind === "user" ? row.user_id === subject.userId : row.guest_id === subject.guestId + if (!owns) throw new ClaimRefusal({ kind: "not_found" }) + + // registerIn only gates self sign-ups on the event end, so an offer outliving its event + // would otherwise still turn into a seat. + if (event !== undefined && eventWindowEnded(event)) { + await releaseWaitlistHold(tx, args.cleanupId, args.waitlistId, args.now) + return { kind: "not_offered" } + } + + const outcome = await registerIn(tx, registerArgs) + if (outcome.kind === "registered") return { kind: "claimed", registration: outcome.registration } + if (outcome.kind === "replayed" && outcome.registration !== null) { + return { kind: "claimed", registration: outcome.registration } + } + if (outcome.kind === "registration_closed" || outcome.kind === "closed") { + await releaseWaitlistHold(tx, args.cleanupId, args.waitlistId, args.now) + return { kind: "not_offered" } + } + throw new ClaimRefusal({ kind: "not_offered" }) +} + +export function makeWaitlistMethods(sql: Sql): WaitlistMethods { + return { + async joinWaitlist(args: JoinWaitlistArgs): Promise { + try { + return await sql.begin((tx) => joinWaitlistIn(tx, args)) + } catch (err) { + if (!isUniqueViolationOn(err, ...WAITLIST_ACTIVE_CONSTRAINTS)) throw err + const existingId = await findLiveWaitlistEntryId(sql, args.ticketTypeId, args.subject) + const entry = + existingId === undefined ? null : await loadWaitlistEntry(sql, args.cleanupId, existingId) + if (entry === null) return { kind: "not_found" } + return { kind: "already_waiting", entry } + } + }, + + async leaveWaitlist(args: { + cleanupId: string + ticketTypeId: string | null + subject: RegistrationSubject + now: Date + }): Promise<{ left: number; releasedTicketTypeIds: string[] }> { + return cancelWaitlistEntriesIn(sql, args) + }, + + async listWaitlist( + args: ListWaitlistArgs, + ): Promise<{ rows: WaitlistRecord[]; nextCursor: string | null }> { + return listWaitlistIn(sql, args) + }, + + async findWaitlistEntry(cleanupId: string, waitlistId: string): Promise { + return loadWaitlistEntry(sql, cleanupId, waitlistId) + }, + + async ticketTypeIdsWithWaiting(cleanupId: string): Promise { + const rows = await sql<{ ticket_type_id: string }[]>` + SELECT DISTINCT ticket_type_id FROM cleanup_waitlist + WHERE cleanup_id = ${cleanupId} AND status = 'waiting' + LIMIT 20 + ` + return rows.map((r) => r.ticket_type_id) + }, + + async offerNextWaitlistEntry(args: { + ticketTypeId: string + now: Date + claimWindowMs: number + }): Promise { + const expiresAt = new Date(args.now.getTime() + args.claimWindowMs) + return sql.begin(async (tx) => { + const candidates = await tx< + { + id: string + cleanup_id: string + user_id: string | null + guest_id: string | null + party_size: number + }[] + >` + SELECT w.id, w.cleanup_id, w.user_id, w.guest_id, w.party_size + FROM cleanup_waitlist w + WHERE w.ticket_type_id = ${args.ticketTypeId} AND w.status = 'waiting' + AND ${waitlistEntryNotBanned(tx)} + AND ${waitlistEventStillLive(tx)} + ORDER BY w.created_at, w.id + LIMIT 1 + FOR UPDATE SKIP LOCKED + ` + const candidate = candidates[0] + if (candidate === undefined) return null + return reserveAndOffer( + tx, + { + waitlistId: candidate.id, + cleanupId: candidate.cleanup_id, + ticketTypeId: args.ticketTypeId, + userId: candidate.user_id, + guestId: candidate.guest_id, + partySize: candidate.party_size, + }, + tx``, + args.now, + expiresAt, + ) + }) + }, + + async offerWaitlistEntry(args: { + cleanupId: string + waitlistId: string + now: Date + claimWindowMs: number + }): Promise { + const expiresAt = new Date(args.now.getTime() + args.claimWindowMs) + return sql.begin(async (tx) => { + const rows = await tx< + { + id: string + ticket_type_id: string + user_id: string | null + guest_id: string | null + party_size: number + }[] + >` + SELECT w.id, w.ticket_type_id, w.user_id, w.guest_id, w.party_size + FROM cleanup_waitlist w + WHERE w.id = ${args.waitlistId} + AND w.cleanup_id = ${args.cleanupId} + AND w.status = 'waiting' + AND ${waitlistEntryNotBanned(tx)} + AND ${waitlistEventStillLive(tx)} + LIMIT 1 + FOR UPDATE + ` + const candidate = rows[0] + if (candidate === undefined) return null + return reserveAndOffer( + tx, + { + waitlistId: candidate.id, + cleanupId: args.cleanupId, + ticketTypeId: candidate.ticket_type_id, + userId: candidate.user_id, + guestId: candidate.guest_id, + partySize: candidate.party_size, + }, + tx`AND cleanup_id = ${args.cleanupId}`, + args.now, + expiresAt, + ) + }) + }, + + async expireWaitlistOffers(args: { now: Date; limit: number }): Promise { + const rows = await sql<{ ticket_type_id: string }[]>` + WITH expired AS ( + UPDATE cleanup_waitlist + SET status = 'expired' + WHERE id IN ( + SELECT id FROM cleanup_waitlist + WHERE status = 'offered' AND claim_expires_at <= ${args.now} + ORDER BY claim_expires_at + LIMIT ${args.limit} + FOR UPDATE SKIP LOCKED + ) + RETURNING id, ticket_type_id, party_size + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), + updated_at = ${args.now} + FROM expired e + WHERE t.id = e.ticket_type_id + RETURNING t.id + ) + SELECT ticket_type_id FROM expired + ` + return [...new Set(rows.map((r) => r.ticket_type_id))] + }, + + async claimWaitlistOffer(args: ClaimWaitlistArgs): Promise { + const entry = await loadWaitlistEntry(sql, args.cleanupId, args.waitlistId) + if (entry === null) return { kind: "not_found" } + if (args.subject !== null) { + const matches = + args.subject.kind === "user" + ? entry.userId === args.subject.userId + : entry.guestId === args.subject.guestId + if (!matches) return { kind: "not_found" } + } + const subject: RegistrationSubject = + entry.userId !== null + ? { kind: "user", userId: entry.userId } + : { kind: "guest", guestId: entry.guestId as string } + const registerArgs = claimRegisterArgs(args, entry, subject) + + try { + return await sql.begin((tx) => claimOfferIn(tx, args, subject, registerArgs)) + } catch (err) { + if (err instanceof ClaimRefusal) return err.outcome + const mapped = await registerErrorOutcome(sql, err, registerArgs) + if (mapped === null) throw err + if (mapped.kind === "replayed" && mapped.registration !== null) { + return { kind: "claimed", registration: mapped.registration } + } + return { kind: "not_offered" } + } + }, + } +} diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index 36f79184..f86e29e6 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -2,7 +2,7 @@ import type { CounterStore } from "../../abuse/counter-store.js" import type { Container } from "../../di.js" import type { Sql } from "../../db/client.js" import { makeMediaPresigner } from "../media-presign.js" -import { makeGuestPromotionNotifier } from "../guest-notify.js" +import { makeGuestPromotionNotifier, type GuestPromotionNotifier } from "../guest-notify.js" import { webBaseUrlOf } from "../../lib/base-url.js" import { makeDrizzleGuestRsvpRepository } from "../guest-rsvp-repository.drizzle.js" import { writeAudit } from "../admin/audit.js" @@ -38,6 +38,8 @@ export interface HostServiceLogger { error(obj: unknown, msg?: string): void } +const TRAILING_SLASHES = /\/+$/ + export interface HostRegistrationOverrides { guards?: HostGuards counters?: CounterStore @@ -72,10 +74,10 @@ export interface HostRegistrationServices { checkin: CheckinService } -export function platformMediaUrlPrefixes(container: Container): string[] { +function platformMediaUrlPrefixes(container: Container): string[] { const bases = [container.env.R2_PUBLIC_BASE ?? "", container.env.PUBLIC_API_URL] return bases - .map((base) => base.trim().replace(/\/+$/, "")) + .map((base) => base.trim().replace(TRAILING_SLASHES, "")) .filter((base) => base.length > 0) .map((base) => `${base}/`) } @@ -104,11 +106,41 @@ function auditWriter(sql: Sql, logger?: HostServiceLogger): RegistrationAudit { } } -export function makeContainerRegistrationServices( +type GuestTicketLookup = NonNullable + +interface ResolvedRegistrationDeps { + sql: Sql | undefined + repo: HostRegistrationRepository + tokens: TicketTokenSigner + audit: RegistrationAudit | undefined + teamUserIds: ((cleanupId: string) => Promise) | undefined + guestByManageToken: GuestTicketLookup | undefined + notifier: RegistrationNotifier + guests: GuestPromotionNotifier | undefined + counters: CounterStore + insightsInvalidator: InsightsInvalidator +} + +function guestByManageTokenIn(sql: Sql): GuestTicketLookup { + return async (hash: string) => { + const rows = await sql<{ id: string; cleanup_id: string; cancelled_at: Date | null }[]>` + SELECT id, cleanup_id, cancelled_at FROM cleanup_guests + WHERE manage_token_hash = ${hash} + LIMIT 1 + ` + const row = rows[0] + return row === undefined + ? null + : { id: row.id, cleanupId: row.cleanup_id, cancelledAt: row.cancelled_at } + } +} + +// A repo override means an offline test harness: nothing that would open the database is built then. +function resolveRegistrationDeps( container: Container, overrides: HostRegistrationOverrides | undefined, - logger?: HostServiceLogger, -): HostRegistrationServices { + logger: HostServiceLogger | undefined, +): ResolvedRegistrationDeps { const sql = overrides?.repo === undefined ? container.getDb().sql : undefined const repo = overrides?.repo ?? makeDrizzleHostRegistrationRepository(sql as Sql) const tokens = overrides?.tokens ?? container.getTicketTokenSigner() @@ -119,21 +151,7 @@ export function makeContainerRegistrationServices( ? undefined : (cleanupId: string) => hostTeamUserIds(sql, cleanupId, HOST_TEAM_SIGNAL_CAP)) const guestByManageToken = - overrides?.guestByManageToken ?? - (sql === undefined - ? undefined - : async (hash: string) => { - const rows = await sql<{ id: string; cleanup_id: string; cancelled_at: Date | null }[]>` - SELECT id, cleanup_id, cancelled_at FROM cleanup_guests - WHERE manage_token_hash = ${hash} - LIMIT 1 - ` - const row = rows[0] - return row === undefined - ? null - : { id: row.id, cleanupId: row.cleanup_id, cancelledAt: row.cancelled_at } - }) - + overrides?.guestByManageToken ?? (sql === undefined ? undefined : guestByManageTokenIn(sql)) const notifier = lazyNotifier(container, logger) const guests = sql === undefined @@ -152,6 +170,30 @@ export function makeContainerRegistrationServices( cache: container.getCache(), ...(logger !== undefined ? { logger } : {}), })) + return { + sql, + repo, + tokens, + audit, + teamUserIds, + guestByManageToken, + notifier, + guests, + counters, + insightsInvalidator, + } +} + +export function makeContainerRegistrationServices( + container: Container, + overrides: HostRegistrationOverrides | undefined, + logger?: HostServiceLogger, +): HostRegistrationServices { + const deps = resolveRegistrationDeps(container, overrides, logger) + const { repo, tokens, notifier, counters, insightsInvalidator } = deps + const clock = overrides?.now !== undefined ? { now: overrides.now } : {} + const log = logger !== undefined ? { logger } : {} + const audit = deps.audit !== undefined ? { audit: deps.audit } : {} const registrations = makeRegistrationService({ repo, @@ -160,13 +202,13 @@ export function makeContainerRegistrationServices( notifier, userChannel: container.userChannel, counters, - ...(audit !== undefined ? { audit } : {}), + ...audit, insightsInvalidator, - ...(teamUserIds !== undefined ? { teamUserIds } : {}), - ...(sql === undefined ? {} : { affiliations: container.getAffiliationLoader() }), - ...(overrides?.now !== undefined ? { now: overrides.now } : {}), + ...(deps.teamUserIds !== undefined ? { teamUserIds: deps.teamUserIds } : {}), + ...(deps.sql === undefined ? {} : { affiliations: container.getAffiliationLoader() }), + ...clock, ...(overrides?.newId !== undefined ? { newId: overrides.newId } : {}), - ...(logger !== undefined ? { logger } : {}), + ...log, }) return { @@ -178,22 +220,19 @@ export function makeContainerRegistrationServices( jobs: container.jobs, counters, insightsInvalidator, - ...(overrides?.now !== undefined ? { now: overrides.now } : {}), - ...(logger !== undefined ? { logger } : {}), - }), - questions: makeQuestionService({ - repo, - ...(overrides?.now !== undefined ? { now: overrides.now } : {}), + ...clock, + ...log, }), + questions: makeQuestionService({ repo, ...clock }), waitlist: makeWaitlistService({ repo, registrations, jobs: container.jobs, notifier, - ...(guests !== undefined ? { guests } : {}), - ...(audit !== undefined ? { audit } : {}), - ...(overrides?.now !== undefined ? { now: overrides.now } : {}), - ...(logger !== undefined ? { logger } : {}), + ...(deps.guests !== undefined ? { guests: deps.guests } : {}), + ...audit, + ...clock, + ...log, }), checkin: makeCheckinService({ repo, @@ -202,10 +241,12 @@ export function makeContainerRegistrationServices( ...(container.env.PUBLIC_API_URL.length > 0 ? { publicApiUrl: container.env.PUBLIC_API_URL } : {}), - ...(guestByManageToken !== undefined ? { guestByManageToken } : {}), - ...(audit !== undefined ? { audit } : {}), - ...(overrides?.now !== undefined ? { now: overrides.now } : {}), - ...(logger !== undefined ? { logger } : {}), + ...(deps.guestByManageToken !== undefined + ? { guestByManageToken: deps.guestByManageToken } + : {}), + ...audit, + ...clock, + ...log, }), } } diff --git a/services/api/src/services/host/ticket-token.ts b/services/api/src/services/host/ticket-token.ts index d341a0b8..ad376772 100644 --- a/services/api/src/services/host/ticket-token.ts +++ b/services/api/src/services/host/ticket-token.ts @@ -3,10 +3,18 @@ import { constantTimeStringEqual } from "../../auth/crypto.js" const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" -export const TICKET_TOKEN_BYTES = 16 +const BITS_PER_BYTE = 8 + +const BASE32_BITS = 5 + +const BASE32_MASK = BASE32_ALPHABET.length - 1 + +const TICKET_TOKEN_BYTES = 16 export const TICKET_TOKEN_LENGTH = 26 +const TOKEN_SEPARATORS = /[\s-]+/gu + export interface TicketTokenSigner { tokenFor(seatId: string): string hashOf(token: string): string @@ -19,19 +27,19 @@ function base32(bytes: Buffer): string { let buffer = 0 let bits = 0 for (const byte of bytes) { - buffer = (buffer << 8) | byte - bits += 8 - while (bits >= 5) { - bits -= 5 - out += BASE32_ALPHABET[(buffer >> bits) & 31] + buffer = (buffer << BITS_PER_BYTE) | byte + bits += BITS_PER_BYTE + while (bits >= BASE32_BITS) { + bits -= BASE32_BITS + out += BASE32_ALPHABET[(buffer >> bits) & BASE32_MASK] } } - if (bits > 0) out += BASE32_ALPHABET[(buffer << (5 - bits)) & 31] + if (bits > 0) out += BASE32_ALPHABET[(buffer << (BASE32_BITS - bits)) & BASE32_MASK] return out } export function normalizeTicketToken(raw: string): string { - return raw.replace(/[\s-]+/gu, "").toUpperCase() + return raw.replace(TOKEN_SEPARATORS, "").toUpperCase() } export function makeTicketTokenSigner(secret: string): TicketTokenSigner { diff --git a/services/api/src/services/host/ticket-type-service.ts b/services/api/src/services/host/ticket-type-service.ts index bdfa6a33..d5759b26 100644 --- a/services/api/src/services/host/ticket-type-service.ts +++ b/services/api/src/services/host/ticket-type-service.ts @@ -17,12 +17,13 @@ import { toTicketTypeDTO } from "./registration-dto.js" import type { InsightsInvalidator } from "./host-analytics-cache.js" import type { HostRegistrationRepository } from "./registration-repository.types.js" import { enqueueWaitlistPromotion } from "./waitlist-promotion.js" +import { reserveCounterBudget } from "./counter-budget.js" -export const HOST_TICKET_TYPE_COUNTER_KEY = "host:ticketTypes" +const HOST_TICKET_TYPE_COUNTER_KEY = "host:ticketTypes" -export const HOST_TICKET_TYPE_MAX_PER_HOUR = 60 +const HOST_TICKET_TYPE_MAX_PER_HOUR = 60 -export const HOST_TICKET_TYPE_WINDOW_SECONDS = 60 * 60 +const HOST_TICKET_TYPE_WINDOW_SECONDS = 60 * 60 export interface TicketTypeServiceDeps { repo: HostRegistrationRepository @@ -69,23 +70,18 @@ export function makeTicketTypeService(deps: TicketTypeServiceDeps): TicketTypeSe const now = deps.now ?? (() => new Date()) async function reserveWriteBudget(actorId: string): Promise { - if (deps.counters === undefined) return - let used: number - try { - used = await deps.counters.incr( - `${HOST_TICKET_TYPE_COUNTER_KEY}:${actorId}`, - HOST_TICKET_TYPE_WINDOW_SECONDS, - ) - } catch (err) { - deps.logger?.warn( - { err }, - "ticket types: abuse counter unavailable; refusing the write (fail closed)", - ) - throw AppError.rateLimited("Ticket type changes are temporarily unavailable.") - } - if (used > HOST_TICKET_TYPE_MAX_PER_HOUR) { - throw AppError.rateLimited("Too many ticket type changes. Try again later.") - } + await reserveCounterBudget( + deps.counters, + { + key: `${HOST_TICKET_TYPE_COUNTER_KEY}:${actorId}`, + windowSeconds: HOST_TICKET_TYPE_WINDOW_SECONDS, + cap: HOST_TICKET_TYPE_MAX_PER_HOUR, + unavailableLog: "ticket types: abuse counter unavailable; refusing the write (fail closed)", + unavailableMessage: "Ticket type changes are temporarily unavailable.", + exceededMessage: "Too many ticket type changes. Try again later.", + }, + deps.logger, + ) } async function eventChanged(cleanupId: string): Promise { diff --git a/services/api/src/services/host/waitlist-service.ts b/services/api/src/services/host/waitlist-service.ts index 1b82b853..dad59758 100644 --- a/services/api/src/services/host/waitlist-service.ts +++ b/services/api/src/services/host/waitlist-service.ts @@ -29,11 +29,11 @@ import type { export const WAITLIST_CLAIM_WINDOW_MS = 24 * 60 * 60 * 1000 -export const WAITLIST_EXPIRE_BATCH = 500 +const WAITLIST_EXPIRE_BATCH = 500 -export const WAITLIST_PROMOTE_MAX_PER_RUN = 50 +const WAITLIST_PROMOTE_MAX_PER_RUN = 50 -export const WAITLIST_DEFAULT_LIMIT = 25 +const WAITLIST_DEFAULT_LIMIT = 25 export interface WaitlistServiceDeps { repo: HostRegistrationRepository From 0523ff42c6a2ebf032d19cd52eae3b40a18b15bb Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 13:24:59 +0000 Subject: [PATCH 3/3] in-file cleanup: host orgs, team, pages, split the organization repository --- services/api/src/routes/host/_host-routes.ts | 41 +- services/api/src/routes/host/orgs.routes.ts | 13 +- .../api/src/routes/host/page-views.routes.ts | 4 +- .../api/src/routes/host/portfolio.routes.ts | 4 +- services/api/src/routes/host/team.routes.ts | 14 +- services/api/src/services/host/authz.ts | 12 +- .../services/host/host-portfolio-service.ts | 4 +- .../host/host-team-repository.drizzle.ts | 129 +- .../host/host-team-repository.memory.ts | 94 +- .../src/services/host/host-team-service.ts | 47 +- .../host/organization-admin.drizzle.ts | 189 ++ .../host/organization-invites.drizzle.ts | 586 +++++ .../host/organization-members.drizzle.ts | 525 +++++ .../host/organization-profile.drizzle.ts | 290 +++ .../host/organization-repository.drizzle.ts | 1986 +---------------- .../host/organization-repository.memory.ts | 225 +- .../host/organization-rows.drizzle.ts | 188 ++ .../src/services/host/organization-service.ts | 263 ++- .../host/organization-verification.drizzle.ts | 408 ++++ .../api/src/services/host/page-service.ts | 28 +- services/api/src/services/host/slugs.ts | 10 +- 21 files changed, 2681 insertions(+), 2379 deletions(-) create mode 100644 services/api/src/services/host/organization-admin.drizzle.ts create mode 100644 services/api/src/services/host/organization-invites.drizzle.ts create mode 100644 services/api/src/services/host/organization-members.drizzle.ts create mode 100644 services/api/src/services/host/organization-profile.drizzle.ts create mode 100644 services/api/src/services/host/organization-rows.drizzle.ts create mode 100644 services/api/src/services/host/organization-verification.drizzle.ts diff --git a/services/api/src/routes/host/_host-routes.ts b/services/api/src/routes/host/_host-routes.ts index bcb979d3..9cdc8c09 100644 --- a/services/api/src/routes/host/_host-routes.ts +++ b/services/api/src/routes/host/_host-routes.ts @@ -14,6 +14,11 @@ import { } from "../../services/host/registration-wiring.js" import type { PageService } from "../../services/host/page-service.js" +const ONE_MINUTE = "1 minute" + +/** A cheap bound on the raw param; the request schema validates the slug itself. */ +const PAGE_SLUG_PARAM_MAX = 120 + export const CleanupIdParamsSchema = z.object({ id: IdSchema }).strict() export const TicketTypeParamsSchema = z.object({ id: IdSchema, ticketTypeId: IdSchema }).strict() @@ -26,24 +31,26 @@ export const WaitlistParamsSchema = z.object({ id: IdSchema, waitlistId: IdSchem export const SeatParamsSchema = z.object({ id: IdSchema, seatId: IdSchema }).strict() -export const PageSlugParamsSchema = z.object({ slug: z.string().min(1).max(120) }).strict() +export const PageSlugParamsSchema = z + .object({ slug: z.string().min(1).max(PAGE_SLUG_PARAM_MAX) }) + .strict() -export const TICKET_TYPE_READ_RATE_LIMIT = perHost({ max: 120, timeWindow: "1 minute" }) -export const TICKET_TYPE_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) -export const QUESTION_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) -export const REGISTER_RATE_LIMIT = perIdentity({ max: 10, timeWindow: "1 minute" }) -export const ROSTER_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -export const WALKUP_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -export const REGISTRATION_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) -export const WAITLIST_WRITE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 minute" }) -export const WAITLIST_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -export const PAGE_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -export const PAGE_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) -export const PUBLIC_PAGE_RATE_LIMIT = perHost({ max: 120, timeWindow: "1 minute" }) -export const SCAN_RATE_LIMIT = perIdentity({ max: 300, timeWindow: "1 minute" }) -export const CHECKIN_RATE_LIMIT = perIdentity({ max: 120, timeWindow: "1 minute" }) -export const TICKET_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -export const GUEST_TICKET_RATE_LIMIT = perHost({ max: 20, timeWindow: "1 minute" }) +export const TICKET_TYPE_READ_RATE_LIMIT = perHost({ max: 120, timeWindow: ONE_MINUTE }) +export const TICKET_TYPE_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: ONE_MINUTE }) +export const QUESTION_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: ONE_MINUTE }) +export const REGISTER_RATE_LIMIT = perIdentity({ max: 10, timeWindow: ONE_MINUTE }) +export const ROSTER_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) +export const WALKUP_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) +export const REGISTRATION_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: ONE_MINUTE }) +export const WAITLIST_WRITE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: ONE_MINUTE }) +export const WAITLIST_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) +export const PAGE_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) +export const PAGE_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: ONE_MINUTE }) +export const PUBLIC_PAGE_RATE_LIMIT = perHost({ max: 120, timeWindow: ONE_MINUTE }) +export const SCAN_RATE_LIMIT = perIdentity({ max: 300, timeWindow: ONE_MINUTE }) +export const CHECKIN_RATE_LIMIT = perIdentity({ max: 120, timeWindow: ONE_MINUTE }) +export const TICKET_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) +export const GUEST_TICKET_RATE_LIMIT = perHost({ max: 20, timeWindow: ONE_MINUTE }) declare module "fastify" { interface FastifyInstance { diff --git a/services/api/src/routes/host/orgs.routes.ts b/services/api/src/routes/host/orgs.routes.ts index 669b7b6d..fccc7e65 100644 --- a/services/api/src/routes/host/orgs.routes.ts +++ b/services/api/src/routes/host/orgs.routes.ts @@ -52,6 +52,9 @@ import { makeDrizzleOrganizationRepository } from "../../services/host/organizat import type { OrganizationRepository } from "../../services/host/organization-repository.types.js" import { webBaseUrlOf } from "../../lib/base-url.js" +const ONE_MINUTE = "1 minute" +const ONE_HOUR = "1 hour" + export interface OrganizationOverrides { repo: OrganizationRepository counters?: OrganizationServiceDeps["counters"] @@ -91,15 +94,15 @@ const OrgEventsQuerySchema = z }) .strict() -export const CREATE_ORG_RATE_LIMIT = perIdentity({ max: 5, timeWindow: "1 hour" }) +export const CREATE_ORG_RATE_LIMIT = perIdentity({ max: 5, timeWindow: ONE_HOUR }) -export const ORG_MUTATION_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) +export const ORG_MUTATION_RATE_LIMIT = perIdentity({ max: 30, timeWindow: ONE_MINUTE }) -export const ORG_INVITE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 hour" }) +export const ORG_INVITE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: ONE_HOUR }) -export const ORG_VERIFICATION_RATE_LIMIT = perIdentity({ max: 3, timeWindow: "1 hour" }) +export const ORG_VERIFICATION_RATE_LIMIT = perIdentity({ max: 3, timeWindow: ONE_HOUR }) -export const PUBLIC_ORG_READ_RATE_LIMIT = perHost({ max: 120, timeWindow: "1 minute" }) +export const PUBLIC_ORG_READ_RATE_LIMIT = perHost({ max: 120, timeWindow: ONE_MINUTE }) const CreateOrganizationBodySchema = trimTextFields( CreateOrganizationRequestSchema, diff --git a/services/api/src/routes/host/page-views.routes.ts b/services/api/src/routes/host/page-views.routes.ts index 0b4585bd..4c7b52c6 100644 --- a/services/api/src/routes/host/page-views.routes.ts +++ b/services/api/src/routes/host/page-views.routes.ts @@ -7,7 +7,9 @@ import { parse } from "../_validate.js" import { makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" -export const PAGE_VIEW_RATE_LIMIT = perHost({ max: 120, timeWindow: "1 minute" }) +const ONE_MINUTE = "1 minute" + +export const PAGE_VIEW_RATE_LIMIT = perHost({ max: 120, timeWindow: ONE_MINUTE }) function headerOf(request: FastifyRequest, name: string): string | undefined { const value = request.headers[name] diff --git a/services/api/src/routes/host/portfolio.routes.ts b/services/api/src/routes/host/portfolio.routes.ts index 083ba15e..22455d88 100644 --- a/services/api/src/routes/host/portfolio.routes.ts +++ b/services/api/src/routes/host/portfolio.routes.ts @@ -19,6 +19,8 @@ import { } from "../../services/host/host-portfolio-service.js" import { hostedEventCounts } from "../../services/host/portfolio-counts.js" +const ONE_MINUTE = "1 minute" + export interface HostPortfolioOverrides { repo: HostPortfolioServiceDeps["repo"] counts?: HostPortfolioServiceDeps["counts"] @@ -42,7 +44,7 @@ const HostedEventsQuerySchema = z }) .strict() -export const HOSTED_EVENTS_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) +export const HOSTED_EVENTS_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) export async function registerHostPortfolioRoutes( app: FastifyInstance, diff --git a/services/api/src/routes/host/team.routes.ts b/services/api/src/routes/host/team.routes.ts index 1f072dca..ab1d399b 100644 --- a/services/api/src/routes/host/team.routes.ts +++ b/services/api/src/routes/host/team.routes.ts @@ -22,9 +22,9 @@ import { requireAuth } from "../../auth/context.js" import { parse } from "../_validate.js" import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" -import { requireCapability } from "../../services/host/authz.js" import { makeHostTeamService, + makeSqlTeamStanding, MY_EVENT_INVITES_DEFAULT_LIMIT, type HostTeamService, type HostTeamServiceDeps, @@ -36,6 +36,9 @@ import { makeRouteNotificationService } from "../../services/route-notifier.js" import { makeRouteCleanupReader } from "../../services/route-cleanup-reader.js" import { webBaseUrlOf } from "../../lib/base-url.js" +const ONE_MINUTE = "1 minute" +const ONE_HOUR = "1 hour" + export interface HostTeamOverrides { repo: HostTeamRepository standing: HostTeamServiceDeps["standing"] @@ -66,11 +69,11 @@ const MyInvitesQuerySchema = z .object({ cursor: z.string().optional(), limit: z.string().optional() }) .strict() -export const TEAM_INVITE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 hour" }) +export const TEAM_INVITE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: ONE_HOUR }) -export const TEAM_MUTATION_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) +export const TEAM_MUTATION_RATE_LIMIT = perIdentity({ max: 30, timeWindow: ONE_MINUTE }) -export const MY_EVENT_INVITES_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) +export const MY_EVENT_INVITES_READ_RATE_LIMIT = perIdentity({ max: 60, timeWindow: ONE_MINUTE }) export async function registerHostTeamRoutes( app: FastifyInstance, @@ -102,8 +105,7 @@ export async function registerHostTeamRoutes( const sql = container.getDb().sql return makeHostTeamService({ repo: makeDrizzleHostTeamRepository(sql), - standing: (cleanupId, userId, capability) => - requireCapability(sql, cleanupId, userId, capability), + standing: makeSqlTeamStanding(sql), loadEvent: makeRouteCleanupReader(container, app.log), counters: container.getCounterStore(), mailer: container.mailer, diff --git a/services/api/src/services/host/authz.ts b/services/api/src/services/host/authz.ts index 49f08dbe..5a72dc69 100644 --- a/services/api/src/services/host/authz.ts +++ b/services/api/src/services/host/authz.ts @@ -50,14 +50,6 @@ export function hasHostStanding(standing: HostStanding): boolean { return standing.eventRole !== null || standing.orgRole !== null } -function isPubliclyVisible(resolution: HostStandingResolution): boolean { - return isEventPubliclyVisible(resolution.visibility) -} - -function isMember(standing: HostStanding): boolean { - return hasHostStanding(standing) -} - export function notFoundCleanup(): AppError { return AppError.notFound("Cleanup not found") } @@ -69,7 +61,9 @@ export async function resolveVisibleStanding( ): Promise { const resolution = await hostStandingOf(sql, cleanupId, userId) if (resolution === null) throw notFoundCleanup() - if (!isMember(resolution.standing) && !isPubliclyVisible(resolution)) throw notFoundCleanup() + if (!hasHostStanding(resolution.standing) && !isEventPubliclyVisible(resolution.visibility)) { + throw notFoundCleanup() + } return resolution } diff --git a/services/api/src/services/host/host-portfolio-service.ts b/services/api/src/services/host/host-portfolio-service.ts index 7d91e3f9..75333544 100644 --- a/services/api/src/services/host/host-portfolio-service.ts +++ b/services/api/src/services/host/host-portfolio-service.ts @@ -13,6 +13,8 @@ import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" export const HOSTED_EVENTS_DEFAULT_LIMIT = 20 +const DEFAULT_HOSTED_EVENTS_WHEN = "upcoming" + export interface HostPortfolioCountsLoader { (cleanupIds: readonly string[]): Promise> } @@ -63,7 +65,7 @@ export function makeHostPortfolioService(deps: HostPortfolioServiceDeps): HostPo const organizationId = query.orgId ?? null const { items, nextCursor } = await deps.repo.listHostedEvents({ userId, - when: query.when ?? "upcoming", + when: query.when ?? DEFAULT_HOSTED_EVENTS_WHEN, organizationId, cursor: query.cursor ?? null, limit, diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 20a1b77e..d2b45dd9 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -33,6 +33,14 @@ import type { RevokeTeamInviteOutcome, } from "./host-team-repository.types.js" import { TEAM_INVITE_CAP_MESSAGE } from "./host-team-repository.types.js" +import type { CleanupPersonView } from "../cleanup-repository.types.js" + +const UNKNOWN_PERSON_NAME = "Unknown" + +const TEAM_INVITE_PENDING_CONSTRAINTS = [ + "cleanup_team_invites_pending_user_uidx", + "cleanup_team_invites_pending_email_uidx", +] interface InviteRowSelect { id: string @@ -74,33 +82,35 @@ function inviteColumns(sql: Queryable) { ` } +function personViewOf( + id: string | null, + name: string | null, + handle: string | null, + avatarUrl: string | null, +): CleanupPersonView | null { + if (id === null) return null + return { id, displayName: name ?? UNKNOWN_PERSON_NAME, handle, bio: null, avatarUrl } +} + function toInviteRecord(row: InviteRowSelect): EventTeamInviteRecord { return { id: row.id, cleanupId: row.cleanup_id, role: row.role, status: row.status, - invitee: - row.invitee_id === null - ? null - : { - id: row.invitee_id, - displayName: row.invitee_name ?? "Unknown", - handle: row.invitee_handle, - bio: null, - avatarUrl: row.invitee_avatar_url, - }, + invitee: personViewOf( + row.invitee_id, + row.invitee_name, + row.invitee_handle, + row.invitee_avatar_url, + ), invitedEmail: row.invited_email, - invitedBy: - row.inviter_id === null - ? null - : { - id: row.inviter_id, - displayName: row.inviter_name ?? "Unknown", - handle: row.inviter_handle, - bio: null, - avatarUrl: row.inviter_avatar_url, - }, + invitedBy: personViewOf( + row.inviter_id, + row.inviter_name, + row.inviter_handle, + row.inviter_avatar_url, + ), createdAt: row.created_at, expiresAt: row.expires_at, acceptedAt: row.accepted_at, @@ -140,26 +150,17 @@ function toPendingInviteForUser(row: PendingInviteForUserRowSelect): PendingInvi coverKey: row.cover_key, address: row.address, }, - invitedBy: - row.inviter_id === null - ? null - : { - id: row.inviter_id, - displayName: row.inviter_name ?? "Unknown", - handle: row.inviter_handle, - bio: null, - avatarUrl: row.inviter_avatar_url, - }, + invitedBy: personViewOf( + row.inviter_id, + row.inviter_name, + row.inviter_handle, + row.inviter_avatar_url, + ), createdAt: row.created_at, expiresAt: row.expires_at, } } -const TEAM_INVITE_PENDING_CONSTRAINTS = [ - "cleanup_team_invites_pending_user_uidx", - "cleanup_team_invites_pending_email_uidx", -] - async function readOpenInvite( tag: Queryable, args: OpenTeamInviteQuery, @@ -271,6 +272,14 @@ async function seatTeamMemberInTx( return (await heldRoleInTx(tx, args.cleanupId, args.userId)) ?? args.role } +async function expireTeamInviteInTx(tx: Queryable, inviteId: string): Promise { + await tx` + UPDATE cleanup_team_invites + SET status = 'expired', invited_email = NULL, email_scrubbed_at = now() + WHERE id = ${inviteId} + ` +} + async function closeAcceptedInviteInTx( tx: Queryable, args: { @@ -304,6 +313,18 @@ async function closeAcceptedInviteInTx( }) } +/** The shared tail of both accept paths: a banned account is refused, anyone else is seated. */ +async function seatFromInviteInTx( + tx: Queryable, + args: { inviteId: string; cleanupId: string; userId: string; role: EventTeamRole; now: Date }, +): Promise { + if (await isBannedInTx(tx, args.cleanupId, args.userId)) return "banned" + const held = await heldRoleInTx(tx, args.cleanupId, args.userId) + const role = await seatTeamMemberInTx(tx, args) + await closeAcceptedInviteInTx(tx, { ...args, from: held }) + return role +} + export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { return { async listTeam(cleanupId: string, limit: number): Promise { @@ -509,11 +530,7 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { if (invite === undefined || invite.status !== "pending") return { kind: "invalid" } if (invite.invited_by === args.userId) return { kind: "wrong_recipient" } if (invite.expires_at.getTime() <= args.now.getTime()) { - await tx` - UPDATE cleanup_team_invites - SET status = 'expired', invited_email = NULL, email_scrubbed_at = now() - WHERE id = ${invite.id} - ` + await expireTeamInviteInTx(tx, invite.id) return { kind: "expired" } } if (invite.invited_user_id !== null && invite.invited_user_id !== args.userId) { @@ -530,23 +547,14 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { ` if (match.length === 0) return { kind: "wrong_recipient" } } - if (await isBannedInTx(tx, args.cleanupId, args.userId)) return { kind: "banned" } - const held = await heldRoleInTx(tx, args.cleanupId, args.userId) - const role = await seatTeamMemberInTx(tx, { - cleanupId: args.cleanupId, - userId: args.userId, - role: invite.role, - now: args.now, - }) - await closeAcceptedInviteInTx(tx, { + const role = await seatFromInviteInTx(tx, { inviteId: invite.id, cleanupId: args.cleanupId, userId: args.userId, role: invite.role, now: args.now, - from: held, }) - return { kind: "accepted", role } + return role === "banned" ? { kind: "banned" } : { kind: "accepted", role } }) }, @@ -629,30 +637,17 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { if (invite.status === "accepted") return alreadySeatedOutcome(tx, cleanupId, args.userId) if (invite.status !== "pending") return { kind: "not_open" } if (invite.expires_at.getTime() <= args.now.getTime()) { - await tx` - UPDATE cleanup_team_invites - SET status = 'expired', invited_email = NULL, email_scrubbed_at = now() - WHERE id = ${invite.id} - ` + await expireTeamInviteInTx(tx, invite.id) return { kind: "expired" } } - if (await isBannedInTx(tx, cleanupId, args.userId)) return { kind: "banned" } - const held = await heldRoleInTx(tx, cleanupId, args.userId) - const role = await seatTeamMemberInTx(tx, { - cleanupId, - userId: args.userId, - role: invite.role, - now: args.now, - }) - await closeAcceptedInviteInTx(tx, { + const role = await seatFromInviteInTx(tx, { inviteId: invite.id, cleanupId, userId: args.userId, role: invite.role, now: args.now, - from: held, }) - return { kind: "accepted", cleanupId, role } + return role === "banned" ? { kind: "banned" } : { kind: "accepted", cleanupId, role } }) }, diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index 5119fcd9..66a9fba6 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -71,6 +71,16 @@ function teamRoleRank(role: string): number { return TEAM_ROLE_RANK[role] ?? 0 } +const DAY_MS = 86_400_000 + +const SEED_EVENT_LEAD_MS = 30 * DAY_MS + +const SEED_JOINED_AT = "2026-01-01T00:00:00.000Z" + +const SEED_DISPLAY_NAME = "Member" + +const SEED_EVENT_TITLE = "Beach cleanup" + function compareIds(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } @@ -97,7 +107,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { seedUser(over: Partial = {}): StoredPerson { const person: StoredPerson = { id: over.id ?? randomUUID(), - displayName: over.displayName ?? "Member", + displayName: over.displayName ?? SEED_DISPLAY_NAME, handle: over.handle ?? null, email: over.email ?? null, emailVerified: over.emailVerified ?? true, @@ -111,7 +121,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { cleanupId: string, userId: string, role: CleanupMemberRole, - joinedAt: Date = new Date("2026-01-01T00:00:00.000Z"), + joinedAt: Date = new Date(SEED_JOINED_AT), ): void { if (!this.users.has(userId)) this.seedUser({ id: userId }) const existing = this.members.find((m) => m.cleanupId === cleanupId && m.userId === userId) @@ -122,8 +132,8 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { seedEvent(cleanupId: string, over: Partial = {}): InviteEventView { const event: InviteEventView = { id: cleanupId, - title: over.title ?? "Beach cleanup", - startsAt: over.startsAt ?? new Date(Date.now() + 30 * 86_400_000), + title: over.title ?? SEED_EVENT_TITLE, + startsAt: over.startsAt ?? new Date(Date.now() + SEED_EVENT_LEAD_MS), endsAt: over.endsAt ?? null, status: over.status ?? ("upcoming" as CleanupStatus), visibility: over.visibility ?? ("public" as EventVisibility), @@ -150,6 +160,29 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { this.bans.push({ cleanupId, userId }) } + private isBanned(cleanupId: string, userId: string): boolean { + return this.bans.some((b) => b.cleanupId === cleanupId && b.userId === userId) + } + + /** Every closed invite drops the address it was sent to. */ + private closeInvite(invite: StoredInvite, status: EventTeamInviteStatus, at: Date): void { + invite.status = status + invite.invitedEmail = null + invite.emailScrubbedAt = at + } + + private acceptInvite(invite: StoredInvite, userId: string, now: Date): CleanupMemberRole { + const role = this.seatMember(invite.cleanupId, userId, invite.role, now) + this.closeInvite(invite, "accepted", now) + invite.acceptedAt = now + this.audits.push({ + actorId: userId, + action: "event.team_role_changed", + target: `cleanup:${invite.cleanupId}`, + }) + return role + } + private personOf(userId: string): CleanupPersonView | null { const stored = this.users.get(userId) if (stored === undefined) return null @@ -261,10 +294,9 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { if (member !== undefined && member.role !== "member") { return Promise.resolve({ kind: "already_member", role: member.role }) } - const banned = this.bans.some( - (b) => b.cleanupId === args.cleanupId && b.userId === args.invitedUserId, - ) - if (banned) return Promise.resolve({ kind: "banned" }) + if (this.isBanned(args.cleanupId, args.invitedUserId)) { + return Promise.resolve({ kind: "banned" }) + } } const open = this.openInviteFor(args) if (open !== undefined) { @@ -319,9 +351,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { (i) => i.id === args.inviteId && i.cleanupId === args.cleanupId && i.status === "pending", ) if (invite === undefined) return Promise.resolve("not_found") - invite.status = "revoked" - invite.invitedEmail = null - invite.emailScrubbedAt = new Date() + this.closeInvite(invite, "revoked", new Date()) this.audits.push({ actorId: args.actorId, action: "event.team_invite_revoked", @@ -345,9 +375,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { } if (invite.invitedBy === args.userId) return Promise.resolve({ kind: "wrong_recipient" }) if (invite.expiresAt.getTime() <= args.now.getTime()) { - invite.status = "expired" - invite.invitedEmail = null - invite.emailScrubbedAt = args.now + this.closeInvite(invite, "expired", args.now) return Promise.resolve({ kind: "expired" }) } if (invite.invitedUserId !== null && invite.invitedUserId !== args.userId) { @@ -358,19 +386,8 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { return Promise.resolve({ kind: "wrong_recipient" }) } } - if (this.bans.some((b) => b.cleanupId === args.cleanupId && b.userId === args.userId)) { - return Promise.resolve({ kind: "banned" }) - } - const seated = this.seatMember(args.cleanupId, args.userId, invite.role, args.now) - invite.status = "accepted" - invite.acceptedAt = args.now - invite.invitedEmail = null - invite.emailScrubbedAt = args.now - this.audits.push({ - actorId: args.userId, - action: "event.team_role_changed", - target: `cleanup:${args.cleanupId}`, - }) + if (this.isBanned(args.cleanupId, args.userId)) return Promise.resolve({ kind: "banned" }) + const seated = this.acceptInvite(invite, args.userId, args.now) return Promise.resolve({ kind: "accepted", role: seated }) } @@ -430,24 +447,11 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { if (invite.status !== "pending") return Promise.resolve({ kind: "not_open" }) if (this.isClosed(cleanupId)) return Promise.resolve({ kind: "closed" }) if (invite.expiresAt.getTime() <= args.now.getTime()) { - invite.status = "expired" - invite.invitedEmail = null - invite.emailScrubbedAt = args.now + this.closeInvite(invite, "expired", args.now) return Promise.resolve({ kind: "expired" }) } - if (this.bans.some((b) => b.cleanupId === cleanupId && b.userId === args.userId)) { - return Promise.resolve({ kind: "banned" }) - } - const role = this.seatMember(cleanupId, args.userId, invite.role, args.now) - invite.status = "accepted" - invite.acceptedAt = args.now - invite.invitedEmail = null - invite.emailScrubbedAt = args.now - this.audits.push({ - actorId: args.userId, - action: "event.team_role_changed", - target: `cleanup:${cleanupId}`, - }) + if (this.isBanned(cleanupId, args.userId)) return Promise.resolve({ kind: "banned" }) + const role = this.acceptInvite(invite, args.userId, args.now) return Promise.resolve({ kind: "accepted", cleanupId, role }) } @@ -461,9 +465,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { ) if (invite === undefined) return Promise.resolve("not_found") if (invite.status !== "pending") return Promise.resolve("not_pending") - invite.status = "declined" - invite.invitedEmail = null - invite.emailScrubbedAt = args.now + this.closeInvite(invite, "declined", args.now) this.audits.push({ actorId: args.userId, action: "event.team_invite_declined", diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index f8746a90..4e923366 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -37,23 +37,39 @@ import type { PendingInviteForUserRecord, } from "./host-team-repository.types.js" +const DAY_SEC = 24 * 60 * 60 +const DAY_MS = DAY_SEC * 1000 + export const TEAM_INVITES_PER_EVENT_PER_DAY = 30 -const TEAM_INVITE_WINDOW_SEC = 24 * 60 * 60 +const TEAM_INVITE_WINDOW_SEC = DAY_SEC +const TEAM_INVITE_COUNTER_KEY = "host:teamInvites" -export const TEAM_INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000 +const TEAM_INVITE_TTL_DAYS = 14 +export const TEAM_INVITE_TTL_MS = TEAM_INVITE_TTL_DAYS * DAY_MS -export const TEAM_INVITE_EMAIL_SCRUB_DELAY_MS = 7 * 24 * 60 * 60 * 1000 +export const TEAM_INVITE_EMAIL_SCRUB_DELAY_MS = 7 * DAY_MS -export const TEAM_MEMBER_CAP = 200 +const TEAM_MEMBER_CAP = 200 -export const TEAM_INVITE_LIST_CAP = 100 +const TEAM_INVITE_LIST_CAP = 100 export const MY_EVENT_INVITES_DEFAULT_LIMIT = 20 -export const TEAM_INVITE_TOKEN_BYTES = 32 +const TEAM_INVITE_TOKEN_BYTES = 32 export const TEAM_INVITE_INBOX_LINK = "/" +const ACTION_EMAIL_TEMPLATE = "action" + +const FALLBACK_EVENT_TITLE = "a civfix event" + +const EMAIL_MASK = "•••" + +/** The token rides the URL fragment, which browsers never send to the server. */ +function teamInviteAcceptPath(cleanupId: string, token: string): string { + return `/cleanups/${cleanupId}#teamInvite=${encodeURIComponent(token)}` +} + const TEAM_ROLE_LABEL_KEYS: Record = { cohost: "role.cohost", coordinator: "role.coordinator", @@ -118,14 +134,14 @@ export interface HostTeamService { export function maskEmail(email: string): string { const at = email.indexOf("@") - if (at <= 0) return "•••" + if (at <= 0) return EMAIL_MASK const local = email.slice(0, at) const domain = email.slice(at + 1) const head = local.slice(0, 1) const dot = domain.lastIndexOf(".") const tld = dot >= 0 ? domain.slice(dot) : "" - const domainHead = dot > 0 ? domain.slice(0, 1) : domain.slice(0, 1) - return `${head}•••@${domainHead}•••${tld}` + const domainHead = domain.slice(0, 1) + return `${head}${EMAIL_MASK}@${domainHead}${EMAIL_MASK}${tld}` } function toInviteDTO( @@ -208,7 +224,7 @@ export function teamInviteEmailVars(args: { ], ctaUrl: args.link, ctaLabel: "View the invitation", - note: "The invitation expires in 14 days. If you weren't expecting it, you can ignore this email.", + note: `The invitation expires in ${TEAM_INVITE_TTL_DAYS} days. If you weren't expecting it, you can ignore this email.`, } } @@ -226,11 +242,11 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService token: string, ): Promise { if (deps.mailer === undefined) return - const link = `${deps.webOrigin}/cleanups/${cleanupId}#teamInvite=${encodeURIComponent(token)}` + const link = `${deps.webOrigin}${teamInviteAcceptPath(cleanupId, token)}` try { await deps.mailer.sendTransactional( email, - "action", + ACTION_EMAIL_TEMPLATE, teamInviteEmailVars({ title, role, link }), ) } catch (err) { @@ -309,7 +325,10 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService invitedEmail: typedEmail, }) if (alreadyOpen === null) { - const sent = await counters.incr(`host:teamInvites:${cleanupId}`, TEAM_INVITE_WINDOW_SEC) + const sent = await counters.incr( + `${TEAM_INVITE_COUNTER_KEY}:${cleanupId}`, + TEAM_INVITE_WINDOW_SEC, + ) if (sent > TEAM_INVITES_PER_EVENT_PER_DAY) { throw AppError.rateLimited( "This event has sent too many team invitations today. Please try again tomorrow.", @@ -340,7 +359,7 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService if (outcome.kind === "already_invited" || outcome.kind === "updated") { return { ok: true, invite: toInviteDTO(outcome.invite) } } - const eventTitle = (await deps.eventTitleOf?.(cleanupId)) ?? "a civfix event" + const eventTitle = (await deps.eventTitleOf?.(cleanupId)) ?? FALLBACK_EVENT_TITLE if (notifyAt !== null) { await notifyInvitee(notifyAt, cleanupId, eventTitle, input.role, token) } diff --git a/services/api/src/services/host/organization-admin.drizzle.ts b/services/api/src/services/host/organization-admin.drizzle.ts new file mode 100644 index 00000000..cfb37243 --- /dev/null +++ b/services/api/src/services/host/organization-admin.drizzle.ts @@ -0,0 +1,189 @@ +import type postgres from "postgres" +import type { Queryable, Sql } from "../../db/client.js" +import { + isUuid, + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../../db/cursor-helpers.js" +import { likeContains } from "../admin/like.js" +import { writeHostAudit } from "./host-audit.js" +import { + adminActorOf, + organizationColumns, + toOrganizationBaseRecord, + type OrganizationRowSelect, +} from "./organization-rows.drizzle.js" +import type { + AdminOrganizationCounts, + AdminOrganizationListQuery, + AdminOrganizationRecord, + OrganizationRepository, + SetOrganizationSuspendedArgs, + SetOrganizationSuspendedOutcome, +} from "./organization-repository.types.js" + +interface AdminOrganizationRowSelect extends OrganizationRowSelect { + owner_id: string | null + owner_name: string | null + owner_handle: string | null + owner_joined: Date | null +} + +interface AdminOrganizationCountsRow { + all: number + verified: number + pending: number + suspended: number +} + +function toAdminOrganizationRecord(row: AdminOrganizationRowSelect): AdminOrganizationRecord { + return { + ...toOrganizationBaseRecord(row), + owner: adminActorOf(row.owner_id, row.owner_name, row.owner_handle, row.owner_joined), + } +} + +/** Owner columns; assumes the admin joins (own/ou) below are in the FROM. */ +function adminOrganizationColumns(sql: Queryable) { + return sql` + ${organizationColumns(sql, null)}, + ou.id AS owner_id, + ou.display_name AS owner_name, + ou.handle AS owner_handle, + ou.created_at AS owner_joined + ` +} + +function adminOrganizationJoins(sql: Queryable) { + return sql` + LEFT JOIN media_assets am ON am.id = o.logo_media_id + LEFT JOIN organization_members own ON own.organization_id = o.id AND own.role = 'owner' + LEFT JOIN users ou ON ou.id = own.user_id + ` +} + +function adminOrganizationFilters(sql: Sql, query: AdminOrganizationListQuery) { + const q = query.q?.trim() ?? "" + const search = + q.length > 0 + ? sql`AND (o.name ILIKE ${likeContains(q)} ESCAPE '\\' + OR o.slug ILIKE ${likeContains(q)} ESCAPE '\\' + ${isUuid(q) ? sql`OR o.id = ${q}::uuid` : sql``})` + : sql`` + const verified = + query.verified !== undefined ? sql`AND o.verified_status = ${query.verified}` : sql`` + const kind = query.kind !== undefined ? sql`AND o.verified_kind = ${query.kind}` : sql`` + const suspended = + query.suspended === undefined + ? sql`` + : query.suspended + ? sql`AND o.suspended_at IS NOT NULL` + : sql`AND o.suspended_at IS NULL` + return { search, verified, kind, suspended } +} + +export function makeOrganizationAdminMethods( + sql: Sql, +): Pick< + OrganizationRepository, + "adminFindOrganization" | "adminListOrganizations" | "setSuspendedTx" +> { + /** + * Facet counts span the SEARCHED set but ignore the facets, and only on page one (the shared + * admin-list policy: the console reads the chip numbers off the first page). + */ + async function facetCounts(search: postgres.Fragment): Promise { + const totals = await sql` + SELECT + count(*)::int AS all, + count(*) FILTER (WHERE o.verified_status = 'verified')::int AS verified, + count(*) FILTER (WHERE o.verified_status = 'pending')::int AS pending, + count(*) FILTER (WHERE o.suspended_at IS NOT NULL)::int AS suspended + FROM organizations o + WHERE o.deleted_at IS NULL + ${search} + ` + const t = totals[0] + return { + all: Number(t?.all ?? 0), + verified: Number(t?.verified ?? 0), + pending: Number(t?.pending ?? 0), + suspended: Number(t?.suspended ?? 0), + } + } + + return { + async adminFindOrganization(id: string): Promise { + const rows = await sql` + SELECT ${adminOrganizationColumns(sql)} + FROM organizations o + ${adminOrganizationJoins(sql)} + WHERE o.id = ${id} AND o.deleted_at IS NULL + LIMIT 1 + ` + return rows[0] ? toAdminOrganizationRecord(rows[0]) : null + }, + + async adminListOrganizations(query: AdminOrganizationListQuery): Promise<{ + items: AdminOrganizationRecord[] + nextCursor: string | null + counts: AdminOrganizationCounts | null + }> { + const cursor = parseKeysetCursor(query.cursor) + const filters = adminOrganizationFilters(sql, query) + const cursorFilter = + cursor !== null + ? sql`AND ${keysetPredicate(sql, sql`o.created_at`, sql`o.id`, cursor)}` + : sql`` + const rows = await sql<(AdminOrganizationRowSelect & { cursor_at: string })[]>` + SELECT ${adminOrganizationColumns(sql)}, + ${keysetInstant(sql, sql`o.created_at`)} AS cursor_at + FROM organizations o + ${adminOrganizationJoins(sql)} + WHERE o.deleted_at IS NULL + ${filters.search} + ${filters.verified} + ${filters.kind} + ${filters.suspended} + ${cursorFilter} + ORDER BY o.created_at DESC, o.id DESC + LIMIT ${query.limit + 1} + ` + const keyed = paginateKeyset(rows, query.limit, (last) => ({ + atText: last.cursor_at, + id: last.id, + })) + return { + items: keyed.items.map(toAdminOrganizationRecord), + nextCursor: keyed.nextCursor, + counts: cursor === null ? await facetCounts(filters.search) : null, + } + }, + + async setSuspendedTx( + args: SetOrganizationSuspendedArgs, + ): Promise { + return sql.begin(async (tx) => { + const updated = await tx<{ id: string }[]>` + UPDATE organizations + SET suspended_at = ${args.suspended ? args.now : null}, + suspended_reason = ${args.suspended ? args.reason : null}, + suspended_by = ${args.suspended ? args.actorId : null}, + updated_at = ${args.now} + WHERE id = ${args.organizationId} AND deleted_at IS NULL + RETURNING id + ` + if (updated.length === 0) return "not_found" + await writeHostAudit(tx, { + actorId: args.actorId, + action: args.suspended ? "org.suspended" : "org.unsuspended", + target: `organization:${args.organizationId}`, + meta: { reason: args.reason }, + }) + return "updated" + }) + }, + } +} diff --git a/services/api/src/services/host/organization-invites.drizzle.ts b/services/api/src/services/host/organization-invites.drizzle.ts new file mode 100644 index 00000000..9f0b0718 --- /dev/null +++ b/services/api/src/services/host/organization-invites.drizzle.ts @@ -0,0 +1,586 @@ +import { + AppError, + MAX_ORG_INVITES_PER_ORG, + type OrganizationInviteRole, + type OrganizationInviteStatus, + type OrganizationMemberRole, + type OrgVerificationKind, + type OrgVerificationStatus, +} from "@civfix/shared" +import type { Queryable, Sql } from "../../db/client.js" +import { publicServedKeyExpr } from "../media-served-key.js" +import { writeHostAudit } from "./host-audit.js" +import { lockOrgForActorIn } from "./organization-members.drizzle.js" +import { personViewOf, UNKNOWN_PERSON_NAME } from "./organization-rows.drizzle.js" +import type { + AcceptOrganizationInviteOutcome, + CreateOrganizationInviteArgs, + CreateOrganizationInviteOutcome, + DeclineOrganizationInviteOutcome, + InviterStanding, + OrganizationInviteRecord, + OrganizationRepository, + PendingOrganizationInviteRecord, + RevokeOrganizationInviteOutcome, +} from "./organization-repository.types.js" +import { inviterRevocationReason, ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" + +type InviteLookup = { tokenHash: string } | { inviteId: string } + +interface InviteRowSelect { + id: string + organization_id: string + email: string | null + role: OrganizationInviteRole + status: OrganizationInviteStatus + created_at: Date + expires_at: Date + user_id: string | null + user_name: string | null + user_handle: string | null + user_bio: string | null + user_avatar_url: string | null + invited_by_id: string | null + invited_by_name: string | null + invited_by_handle: string | null + invited_by_bio: string | null + invited_by_avatar_url: string | null +} + +interface PendingInviteForUserRowSelect { + id: string + role: OrganizationInviteRole + created_at: Date + expires_at: Date + invited_by_id: string | null + invited_by_name: string | null + invited_by_handle: string | null + invited_by_bio: string | null + invited_by_avatar_url: string | null + organization_id: string + organization_slug: string + organization_name: string + organization_logo_key: string | null + organization_verified_status: OrgVerificationStatus + organization_verified_kind: OrgVerificationKind | null +} + +interface LockedInviteRow { + id: string + email: string | null + user_id: string | null + role: OrganizationInviteRole + status: OrganizationInviteStatus + expires_at: Date + invited_by: string | null +} + +function inviteColumns(sql: Queryable) { + return sql` + i.id, + i.organization_id, + i.email, + i.role, + i.status, + i.created_at, + i.expires_at, + au.id AS user_id, + au.display_name AS user_name, + au.handle AS user_handle, + au.bio AS user_bio, + au.avatar_url AS user_avatar_url, + iu.id AS invited_by_id, + iu.display_name AS invited_by_name, + iu.handle AS invited_by_handle, + iu.bio AS invited_by_bio, + iu.avatar_url AS invited_by_avatar_url + ` +} + +function inviterViewOf(row: InviteRowSelect | PendingInviteForUserRowSelect, fallbackName: string) { + return personViewOf( + { + id: row.invited_by_id, + name: row.invited_by_name, + handle: row.invited_by_handle, + bio: row.invited_by_bio, + avatarUrl: row.invited_by_avatar_url, + }, + fallbackName, + ) +} + +function toInviteRecord(row: InviteRowSelect): OrganizationInviteRecord { + return { + id: row.id, + organizationId: row.organization_id, + email: row.email, + user: personViewOf( + { + id: row.user_id, + name: row.user_name, + handle: row.user_handle, + bio: row.user_bio, + avatarUrl: row.user_avatar_url, + }, + UNKNOWN_PERSON_NAME, + ), + role: row.role, + status: row.status, + invitedBy: inviterViewOf(row, UNKNOWN_PERSON_NAME), + createdAt: row.created_at, + expiresAt: row.expires_at, + } +} + +function toPendingInviteForUser( + row: PendingInviteForUserRowSelect, +): PendingOrganizationInviteRecord { + return { + id: row.id, + role: row.role, + createdAt: row.created_at, + expiresAt: row.expires_at, + invitedBy: inviterViewOf(row, ""), + organization: { + id: row.organization_id, + slug: row.organization_slug, + name: row.organization_name, + logoKey: row.organization_logo_key, + donationUrl: null, + verifiedStatus: row.organization_verified_status, + verifiedKind: row.organization_verified_kind, + suspended: false, + }, + } +} + +async function readInvite( + tag: Queryable, + inviteId: string, +): Promise { + const rows = await tag` + SELECT ${inviteColumns(tag)} + FROM organization_invites i + LEFT JOIN users iu ON iu.id = i.invited_by + LEFT JOIN users au ON au.id = i.user_id + WHERE i.id = ${inviteId} + LIMIT 1 + ` + return rows[0] ? toInviteRecord(rows[0]) : null +} + +async function requireInvite(tag: Queryable, inviteId: string): Promise { + const invite = await readInvite(tag, inviteId) + if (invite === null) throw AppError.internal() + return invite +} + +async function countOpenInvites( + tag: Queryable, + organizationId: string, + now: Date, +): Promise { + const rows = await tag<{ count: number }[]>` + SELECT count(*)::int AS count FROM organization_invites + WHERE organization_id = ${organizationId} + AND status = 'pending' + AND expires_at > ${now} + ` + return Number(rows[0]?.count ?? 0) +} + +async function expireInvitesInTx(tag: Queryable, organizationId: string, now: Date): Promise { + await tag` + UPDATE organization_invites SET status = 'expired' + WHERE organization_id = ${organizationId} AND status = 'pending' AND expires_at <= ${now} + ` +} + +async function expireInviteInTx(tx: Queryable, inviteId: string): Promise { + await tx`UPDATE organization_invites SET status = 'expired' WHERE id = ${inviteId}` +} + +async function inviterStandingIn( + tx: Queryable, + organizationId: string, + inviterId: string | null, +): Promise { + if (inviterId === null) return null + const rows = await tx<{ role: OrganizationMemberRole | null; deleted: boolean }[]>` + SELECT m.role, (u.deleted_at IS NOT NULL) AS deleted + FROM users u + LEFT JOIN organization_members m ON m.organization_id = ${organizationId} AND m.user_id = u.id + WHERE u.id = ${inviterId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : { role: row.role, deleted: row.deleted } +} + +/** + * The invite is a claim on the account that signs in with the invited address (verified), the same + * rule cleanup_team_invites applies - never on whoever holds the link: the row must name this + * account, or carry an address this account has verified. + */ +async function inviteAddressesUserInTx( + tx: Queryable, + invite: { user_id: string | null; email: string | null }, + userId: string, +): Promise { + if (invite.user_id !== null && invite.user_id === userId) return true + if (invite.email === null) return false + const rows = await tx<{ one: number }[]>` + SELECT 1 AS one FROM users + WHERE id = ${userId} + AND email = ${invite.email} + AND email_verified = true + AND deleted_at IS NULL + LIMIT 1 + ` + return rows.length > 0 +} + +async function findInviteRefInTx( + tx: Queryable, + by: InviteLookup, +): Promise<{ id: string; organization_id: string } | undefined> { + const rows = + "tokenHash" in by + ? await tx<{ id: string; organization_id: string }[]>` + SELECT id, organization_id FROM organization_invites + WHERE token_hash = ${by.tokenHash} + LIMIT 1 + ` + : await tx<{ id: string; organization_id: string }[]>` + SELECT id, organization_id FROM organization_invites + WHERE id = ${by.inviteId} + LIMIT 1 + ` + return rows[0] +} + +async function lockInvitedOrgInTx( + tx: Queryable, + organizationId: string, +): Promise<{ id: string; suspended: boolean } | undefined> { + const rows = await tx<{ id: string; suspended: boolean }[]>` + SELECT id, (suspended_at IS NOT NULL) AS suspended FROM organizations + WHERE id = ${organizationId} AND deleted_at IS NULL + LIMIT 1 FOR UPDATE + ` + return rows[0] +} + +async function lockInviteInTx( + tx: Queryable, + inviteId: string, +): Promise { + const rows = await tx` + SELECT id, email, user_id, role, status, expires_at, invited_by FROM organization_invites + WHERE id = ${inviteId} + LIMIT 1 FOR UPDATE + ` + return rows[0] +} + +// An invite seats on its inviter's authority, so one that outlived it is closed as revoked instead. +async function revokeIfInviterLapsedInTx( + tx: Queryable, + args: { organizationId: string; invite: LockedInviteRow; userId: string; now: Date }, +): Promise { + const revocation = inviterRevocationReason( + await inviterStandingIn(tx, args.organizationId, args.invite.invited_by), + ) + if (revocation === null) return false + await tx` + UPDATE organization_invites + SET status = 'revoked', revoked_at = ${args.now} + WHERE id = ${args.invite.id} + ` + await writeHostAudit(tx, { + actorId: args.userId, + action: "org.invite_revoked", + target: `organization:${args.organizationId}`, + meta: { inviteId: args.invite.id, reason: revocation }, + }) + return true +} + +async function seatInviteeInTx( + tx: Queryable, + args: { organizationId: string; invite: LockedInviteRow; userId: string; now: Date }, +): Promise { + const inserted = await tx<{ user_id: string }[]>` + INSERT INTO organization_members (organization_id, user_id, role, joined_at) + VALUES (${args.organizationId}, ${args.userId}, ${args.invite.role}, ${args.now}) + ON CONFLICT (organization_id, user_id) DO NOTHING + RETURNING user_id + ` + const alreadyMember = inserted.length === 0 + // An existing member keeps the role they already hold: accepting an invite never upgrades + // (or downgrades) a seat, it only closes the invite. + let role: OrganizationMemberRole = args.invite.role + if (alreadyMember) { + const seated = await tx<{ role: OrganizationMemberRole }[]>` + SELECT role FROM organization_members + WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} + LIMIT 1 + ` + role = seated[0]?.role ?? args.invite.role + } + await tx` + UPDATE organization_invites + SET status = 'accepted', accepted_at = ${args.now}, user_id = ${args.userId} + WHERE id = ${args.invite.id} + ` + await writeHostAudit(tx, { + actorId: args.userId, + action: "org.invite_accepted", + target: `organization:${args.organizationId}`, + meta: { inviteId: args.invite.id, role, alreadyMember }, + }) + return { kind: "accepted", organizationId: args.organizationId, role, alreadyMember } +} + +export function makeOrganizationInviteMethods( + sql: Sql, +): Pick< + OrganizationRepository, + | "countPendingInvites" + | "createInviteTx" + | "listInvites" + | "revokeInviteTx" + | "acceptInviteTx" + | "listPendingInvitesForUser" + | "declineInviteTx" +> { + return { + countPendingInvites(organizationId: string, now: Date): Promise { + return countOpenInvites(sql, organizationId, now) + }, + + async createInviteTx( + args: CreateOrganizationInviteArgs, + ): Promise { + return sql.begin(async (tx): Promise => { + // The organization row lock also serializes concurrent inviters on one org (this is the only + // insert into organization_invites), so the cap below is counted, not raced. + if (!(await lockOrgForActorIn(tx, args.organizationId, args.invitedBy))) { + return { kind: "forbidden" } + } + await expireInvitesInTx(tx, args.organizationId, args.now) + // The cap is checked before the idempotent re-invite path on purpose: it must not depend on + // the address. + if ( + (await countOpenInvites(tx, args.organizationId, args.now)) >= MAX_ORG_INVITES_PER_ORG + ) { + throw AppError.conflict(ORG_INVITE_CAP_MESSAGE) + } + const inserted = await tx<{ id: string }[]>` + INSERT INTO organization_invites ( + id, organization_id, email, user_id, role, token_hash, status, invited_by, created_at, + expires_at + ) VALUES ( + ${args.inviteId}, ${args.organizationId}, ${args.email}, ${args.userId}, ${args.role}, + ${args.tokenHash}, 'pending', ${args.invitedBy}, ${args.now}, ${args.expiresAt} + ) + ON CONFLICT (organization_id, email) WHERE status = 'pending' AND email IS NOT NULL + DO NOTHING + RETURNING id + ` + if (inserted.length === 0) { + // A re-invite of an address with an open invite answers with THAT invite (idempotent), so the + // inviter cannot tell an address apart by whether a second call 409s. + const open = await tx<{ id: string }[]>` + SELECT id FROM organization_invites + WHERE organization_id = ${args.organizationId} + AND email = ${args.email} + AND status = 'pending' + LIMIT 1 + ` + const openId = open[0]?.id + if (openId === undefined) throw AppError.internal() + return { kind: "already_invited", invite: await requireInvite(tx, openId) } + } + await writeHostAudit(tx, { + actorId: args.invitedBy, + action: "org.invite_created", + target: `organization:${args.organizationId}`, + meta: { inviteId: args.inviteId, role: args.role }, + }) + return { kind: "created", invite: await requireInvite(tx, args.inviteId) } + }) + }, + + async listInvites( + organizationId: string, + now: Date, + limit: number, + ): Promise { + await expireInvitesInTx(sql, organizationId, now) + const rows = await sql` + SELECT ${inviteColumns(sql)} + FROM organization_invites i + LEFT JOIN users iu ON iu.id = i.invited_by + LEFT JOIN users au ON au.id = i.user_id + WHERE i.organization_id = ${organizationId} + ORDER BY (i.status = 'pending') DESC, i.created_at DESC, i.id DESC + LIMIT ${limit} + ` + return rows.map(toInviteRecord) + }, + + async revokeInviteTx(args: { + organizationId: string + inviteId: string + actorId: string + now: Date + }): Promise { + return sql.begin(async (tx): Promise => { + const revoked = await tx<{ id: string }[]>` + UPDATE organization_invites + SET status = 'revoked', revoked_at = ${args.now} + WHERE id = ${args.inviteId} + AND organization_id = ${args.organizationId} + AND status = 'pending' + RETURNING id + ` + if (revoked.length === 0) return "not_found" + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.invite_revoked", + target: `organization:${args.organizationId}`, + meta: { inviteId: args.inviteId }, + }) + return "revoked" + }) + }, + + async listPendingInvitesForUser(args: { + userId: string + now: Date + limit: number + }): Promise { + const viewer = await sql<{ email: string | null }[]>` + SELECT email FROM users + WHERE id = ${args.userId} AND email_verified = true AND deleted_at IS NULL + LIMIT 1 + ` + const verifiedEmail = viewer[0]?.email ?? null + const addressed = + verifiedEmail === null + ? sql`i.user_id = ${args.userId}` + : sql`(i.user_id = ${args.userId} OR i.email = ${verifiedEmail})` + const rows = await sql` + SELECT + i.id, + i.role, + i.created_at, + i.expires_at, + iu.id AS invited_by_id, + iu.display_name AS invited_by_name, + iu.handle AS invited_by_handle, + iu.bio AS invited_by_bio, + iu.avatar_url AS invited_by_avatar_url, + o.id AS organization_id, + o.slug AS organization_slug, + o.name AS organization_name, + ${publicServedKeyExpr(sql, "am")} AS organization_logo_key, + o.verified_status AS organization_verified_status, + o.verified_kind AS organization_verified_kind + FROM organization_invites i + JOIN organizations o ON o.id = i.organization_id + LEFT JOIN users iu ON iu.id = i.invited_by + LEFT JOIN media_assets am ON am.id = o.logo_media_id + WHERE i.status = 'pending' + AND i.expires_at > ${args.now} + AND o.deleted_at IS NULL + AND o.suspended_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM organization_members m + WHERE m.organization_id = i.organization_id AND m.user_id = ${args.userId} + ) + AND ${addressed} + ORDER BY i.created_at DESC, i.id DESC + LIMIT ${args.limit} + ` + return rows.map(toPendingInviteForUser) + }, + + async declineInviteTx(args: { + inviteId: string + userId: string + now: Date + }): Promise { + return sql.begin(async (tx): Promise => { + const rows = await tx< + { + id: string + organization_id: string + email: string | null + user_id: string | null + status: OrganizationInviteStatus + expires_at: Date + }[] + >` + SELECT id, organization_id, email, user_id, status, expires_at + FROM organization_invites + WHERE id = ${args.inviteId} + LIMIT 1 FOR UPDATE + ` + const invite = rows[0] + if (invite === undefined || invite.status !== "pending") return "invalid" + if (!(await inviteAddressesUserInTx(tx, invite, args.userId))) return "invalid" + if (invite.expires_at.getTime() <= args.now.getTime()) { + await expireInviteInTx(tx, invite.id) + return "expired" + } + await tx` + UPDATE organization_invites + SET status = 'declined', user_id = ${args.userId} + WHERE id = ${invite.id} + ` + await writeHostAudit(tx, { + actorId: args.userId, + action: "org.invite_declined", + target: `organization:${invite.organization_id}`, + meta: { inviteId: invite.id }, + }) + return "declined" + }) + }, + + async acceptInviteTx(args: { + by: InviteLookup + userId: string + now: Date + }): Promise { + const byToken = "tokenHash" in args.by + return sql.begin(async (tx): Promise => { + const hit = await findInviteRefInTx(tx, args.by) + if (hit === undefined) return { kind: "invalid" } + // Lock order: organizations -> organization_members -> organization_invites. + const org = await lockInvitedOrgInTx(tx, hit.organization_id) + if (org === undefined) return { kind: "invalid" } + const invite = await lockInviteInTx(tx, hit.id) + if (invite === undefined || invite.status !== "pending") return { kind: "invalid" } + // The id-addressed path has no token to prove anything, so the seat itself is the claim. + // Addressing is decided BEFORE expiry, so a caller holding someone else's invite id never + // learns that the invite exists. + const addressed = await inviteAddressesUserInTx(tx, invite, args.userId) + if (byToken ? invite.email !== null && !addressed : !addressed) { + return { kind: "wrong_recipient" } + } + if (invite.expires_at.getTime() <= args.now.getTime()) { + await expireInviteInTx(tx, invite.id) + return { kind: "expired" } + } + const seat = { organizationId: org.id, invite, userId: args.userId, now: args.now } + if (await revokeIfInviterLapsedInTx(tx, seat)) return { kind: "invalid" } + if (org.suspended) return { kind: "suspended" } + return seatInviteeInTx(tx, seat) + }) + }, + } +} diff --git a/services/api/src/services/host/organization-members.drizzle.ts b/services/api/src/services/host/organization-members.drizzle.ts new file mode 100644 index 00000000..78316b55 --- /dev/null +++ b/services/api/src/services/host/organization-members.drizzle.ts @@ -0,0 +1,525 @@ +import type { OrganizationMemberRole } from "@civfix/shared" +import type { Queryable, Sql } from "../../db/client.js" +import { + keysetInstant, + keysetPredicate, + paginateKeyset, + parseKeysetCursor, +} from "../../db/cursor-helpers.js" +import { writeHostAudit } from "./host-audit.js" +import type { + AddOrganizationMemberOutcome, + AdminActorView, + AdminAddMemberArgs, + AdminAddMemberOutcome, + AdminOrgMemberRecord, + AdminSetMemberRoleArgs, + AdminSetMemberRoleOutcome, + InviterRevocationReason, + OrganizationMemberRecord, + OrganizationOwnerRecord, + OrganizationRepository, + OrgMemberIdentifier, + RemoveOrganizationMemberOutcome, + SetOrganizationMemberRoleOutcome, +} from "./organization-repository.types.js" +import { canManageOrgMembers, roleChangeWithdrawsInvites } from "./organization-repository.types.js" + +interface MemberRowSelect { + user_id: string + display_name: string + handle: string | null + bio: string | null + avatar_url: string | null + role: OrganizationMemberRole + joined_at: Date +} + +function toMemberRecord(r: MemberRowSelect): OrganizationMemberRecord { + return { + person: { + id: r.user_id, + displayName: r.display_name, + handle: r.handle, + bio: r.bio, + avatarUrl: r.avatar_url, + }, + role: r.role, + joinedAt: r.joined_at, + } +} + +async function countAdminSeats(tx: Queryable, organizationId: string): Promise { + const rows = await tx<{ n: number }[]>` + SELECT count(*)::int AS n FROM organization_members + WHERE organization_id = ${organizationId} AND role IN ('owner','admin') + ` + return rows[0]?.n ?? 0 +} + +/** + * Every role change and removal takes the organizations row lock first, so re-reading the actor's + * role under it sees the latest committed seat. FOR SHARE also holds off an account erasure, which + * deletes seats without the organization lock. + */ +export async function lockOrgForActorIn( + tx: Queryable, + organizationId: string, + actorId: string, +): Promise { + await tx` + SELECT id FROM organizations WHERE id = ${organizationId} LIMIT 1 FOR UPDATE + ` + const rows = await tx<{ role: OrganizationMemberRole }[]>` + SELECT role FROM organization_members + WHERE organization_id = ${organizationId} AND user_id = ${actorId} + LIMIT 1 + FOR SHARE + ` + return canManageOrgMembers(rows[0]?.role ?? null) +} + +async function lockOrganizationInTx(tx: Queryable, organizationId: string): Promise { + await tx` + SELECT id FROM organizations WHERE id = ${organizationId} LIMIT 1 FOR UPDATE + ` +} + +async function lockMemberRoleInTx( + tx: Queryable, + organizationId: string, + userId: string, +): Promise { + const rows = await tx<{ role: OrganizationMemberRole }[]>` + SELECT role FROM organization_members + WHERE organization_id = ${organizationId} AND user_id = ${userId} + LIMIT 1 + FOR UPDATE + ` + return rows[0]?.role +} + +async function updateMemberRoleInTx( + tx: Queryable, + args: { organizationId: string; userId: string; role: OrganizationMemberRole }, +): Promise { + await tx` + UPDATE organization_members SET role = ${args.role} + WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} + ` +} + +/** The caller already holds the organizations row lock, so this keeps the documented lock order. */ +async function revokeInvitesByInviterInTx( + tx: Queryable, + args: { + organizationId: string + inviterId: string + actorId: string + reason: InviterRevocationReason + }, +): Promise { + await tx` + WITH revoked AS ( + UPDATE organization_invites + SET status = 'revoked', revoked_at = now() + WHERE organization_id = ${args.organizationId} + AND invited_by = ${args.inviterId} + AND status = 'pending' + RETURNING id + ) + INSERT INTO audit_log (actor_id, action, target, meta) + SELECT ${args.actorId}::uuid, 'org.invite_revoked', ${`organization:${args.organizationId}`}, + jsonb_build_object('inviteId', id, 'reason', ${args.reason}::text) + FROM revoked + ` +} + +/** + * Ownership transfer: exactly one owner per org (partial unique index), so the current owner steps + * down to admin in the same transaction before the new owner is seated. Returns the previous owner. + */ +async function demoteOwnerInTx(tx: Queryable, organizationId: string): Promise { + const demoted = await tx<{ user_id: string }[]>` + UPDATE organization_members SET role = 'admin' + WHERE organization_id = ${organizationId} AND role = 'owner' + RETURNING user_id + ` + return demoted[0]?.user_id ?? null +} + +async function auditOwnershipTransferInTx( + tx: Queryable, + args: { organizationId: string; actorId: string; to: string; reason: string }, + previousOwner: string | null, +): Promise { + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.ownership_transferred", + target: `organization:${args.organizationId}`, + meta: { from: previousOwner, to: args.to, reason: args.reason }, + }) +} + +export function makeOrganizationMemberMethods( + sql: Sql, +): Pick< + OrganizationRepository, + | "roleOf" + | "listMembers" + | "findMember" + | "findOwner" + | "findUser" + | "resolveUserByIdentifier" + | "addMemberTx" + | "setMemberRoleTx" + | "removeMemberTx" + | "adminListMembers" + | "adminAddMemberTx" + | "adminSetMemberRoleTx" +> { + function memberCursorFilter(raw: string | null) { + const cursor = parseKeysetCursor(raw, { direction: "asc" }) + return cursor === null + ? sql`` + : sql`AND ${keysetPredicate(sql, sql`m.joined_at`, sql`m.user_id`, cursor, { direction: "asc" })}` + } + + return { + async roleOf(organizationId: string, userId: string): Promise { + const rows = await sql<{ role: OrganizationMemberRole }[]>` + SELECT om.role + FROM organization_members om + JOIN organizations o ON o.id = om.organization_id AND o.deleted_at IS NULL + WHERE om.organization_id = ${organizationId} AND om.user_id = ${userId} + LIMIT 1 + ` + return rows[0]?.role ?? null + }, + + async listMembers(args: { + organizationId: string + cursor: string | null + limit: number + }): Promise<{ items: OrganizationMemberRecord[]; nextCursor: string | null }> { + const rows = await sql<(MemberRowSelect & { cursor_at: string })[]>` + SELECT m.user_id, u.display_name, u.handle, u.bio, u.avatar_url, m.role, m.joined_at, + ${keysetInstant(sql, sql`m.joined_at`)} AS cursor_at + FROM organization_members m + JOIN users u ON u.id = m.user_id + WHERE m.organization_id = ${args.organizationId} + ${memberCursorFilter(args.cursor)} + ORDER BY m.joined_at ASC, m.user_id ASC + LIMIT ${args.limit + 1} + ` + const page = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.user_id, + })) + return { items: page.items.map(toMemberRecord), nextCursor: page.nextCursor } + }, + + async findMember( + organizationId: string, + userId: string, + ): Promise { + const rows = await sql` + SELECT m.user_id, u.display_name, u.handle, u.bio, u.avatar_url, m.role, m.joined_at + FROM organization_members m + JOIN users u ON u.id = m.user_id + WHERE m.organization_id = ${organizationId} AND m.user_id = ${userId} + LIMIT 1 + ` + const r = rows[0] + return r === undefined ? null : toMemberRecord(r) + }, + + async findOwner(organizationId: string): Promise { + const rows = await sql< + { + user_id: string + display_name: string + handle: string + email: string | null + created_at: Date + }[] + >` + SELECT m.user_id, u.display_name, u.handle, u.email, u.created_at + FROM organization_members m + JOIN users u ON u.id = m.user_id AND u.deleted_at IS NULL + WHERE m.organization_id = ${organizationId} AND m.role = 'owner' + LIMIT 1 + ` + const r = rows[0] + return r === undefined + ? null + : { + userId: r.user_id, + displayName: r.display_name, + handle: r.handle, + email: r.email, + joined: r.created_at, + } + }, + + async findUser(userId: string): Promise { + const rows = await sql< + { id: string; display_name: string; handle: string; created_at: Date }[] + >` + SELECT id, display_name, handle, created_at FROM users + WHERE id = ${userId} AND deleted_at IS NULL + LIMIT 1 + ` + const r = rows[0] + return r === undefined + ? null + : { id: r.id, name: r.display_name, handle: r.handle, joined: r.created_at } + }, + + async resolveUserByIdentifier(identifier: OrgMemberIdentifier): Promise { + const rows = + identifier.identifierKind === "handle" + ? await sql<{ id: string }[]>` + SELECT id FROM users + WHERE handle = ${identifier.identifier} AND deleted_at IS NULL + LIMIT 1 + ` + : await sql<{ id: string }[]>` + SELECT id FROM users + WHERE email = ${identifier.identifier} + AND email_verified = true + AND deleted_at IS NULL + LIMIT 1 + ` + return rows[0]?.id ?? null + }, + + async addMemberTx(args: { + organizationId: string + userId: string + role: "admin" | "member" + actorId: string + now: Date + }): Promise { + return sql.begin(async (tx): Promise => { + if (!(await lockOrgForActorIn(tx, args.organizationId, args.actorId))) return "forbidden" + const inserted = await tx<{ user_id: string }[]>` + INSERT INTO organization_members (organization_id, user_id, role, joined_at) + VALUES (${args.organizationId}, ${args.userId}, ${args.role}, ${args.now}) + ON CONFLICT (organization_id, user_id) DO NOTHING + RETURNING user_id + ` + if (inserted.length === 0) return "already_member" + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.member_added", + target: `organization:${args.organizationId}`, + meta: { targetUserId: args.userId, role: args.role, via: "handle" }, + }) + return "added" + }) + }, + + async setMemberRoleTx(args: { + organizationId: string + userId: string + role: "admin" | "member" + actorId: string + }): Promise { + return sql.begin(async (tx) => { + await lockOrganizationInTx(tx, args.organizationId) + const existing = await lockMemberRoleInTx(tx, args.organizationId, args.userId) + if (existing === undefined) return "not_member" + if (existing === "owner") return "owner" + if (existing === args.role) return "updated" + if (existing === "admin" && args.role === "member") { + const seats = await countAdminSeats(tx, args.organizationId) + if (seats <= 1) return "last_admin" + } + await updateMemberRoleInTx(tx, args) + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.member_role_changed", + target: `organization:${args.organizationId}`, + meta: { targetUserId: args.userId, from: existing, to: args.role }, + }) + if (roleChangeWithdrawsInvites(existing, args.role)) { + await revokeInvitesByInviterInTx(tx, { + organizationId: args.organizationId, + inviterId: args.userId, + actorId: args.actorId, + reason: "inviter_demoted", + }) + } + return "updated" + }) + }, + + async removeMemberTx(args: { + organizationId: string + userId: string + actorId: string + reason?: string + }): Promise { + return sql.begin(async (tx) => { + await lockOrganizationInTx(tx, args.organizationId) + await tx` + SELECT id FROM users WHERE id = ${args.userId} LIMIT 1 FOR UPDATE + ` + const targetRole = await lockMemberRoleInTx(tx, args.organizationId, args.userId) + if (targetRole === "admin") { + const seats = await countAdminSeats(tx, args.organizationId) + if (seats <= 1) return "last_admin" + } + const removed = await tx<{ role: OrganizationMemberRole }[]>` + DELETE FROM organization_members + WHERE organization_id = ${args.organizationId} + AND user_id = ${args.userId} + AND role <> 'owner' + RETURNING role + ` + if (removed.length === 0) { + const still = await tx<{ role: OrganizationMemberRole }[]>` + SELECT role FROM organization_members + WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} + LIMIT 1 + ` + return still.length > 0 ? "owner" : "not_member" + } + await tx` + UPDATE users SET primary_organization_id = NULL + WHERE id = ${args.userId} AND primary_organization_id = ${args.organizationId} + ` + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.member_removed", + target: `organization:${args.organizationId}`, + meta: { + targetUserId: args.userId, + role: removed[0]?.role ?? null, + ...(args.reason !== undefined ? { reason: args.reason } : {}), + }, + }) + await revokeInvitesByInviterInTx(tx, { + organizationId: args.organizationId, + inviterId: args.userId, + actorId: args.actorId, + reason: "inviter_removed", + }) + return "removed" + }) + }, + + async adminListMembers(args: { + organizationId: string + cursor: string | null + limit: number + }): Promise<{ items: AdminOrgMemberRecord[]; nextCursor: string | null }> { + const rows = await sql< + { + user_id: string + display_name: string + handle: string + created_at: Date + role: OrganizationMemberRole + joined_at: Date + cursor_at: string + }[] + >` + SELECT m.user_id, u.display_name, u.handle, u.created_at, m.role, m.joined_at, + ${keysetInstant(sql, sql`m.joined_at`)} AS cursor_at + FROM organization_members m + JOIN users u ON u.id = m.user_id + WHERE m.organization_id = ${args.organizationId} + ${memberCursorFilter(args.cursor)} + ORDER BY m.joined_at ASC, m.user_id ASC + LIMIT ${args.limit + 1} + ` + const page = paginateKeyset(rows, args.limit, (last) => ({ + atText: last.cursor_at, + id: last.user_id, + })) + return { + items: page.items.map((r) => ({ + user: { id: r.user_id, name: r.display_name, handle: r.handle, joined: r.created_at }, + role: r.role, + joinedAt: r.joined_at, + })), + nextCursor: page.nextCursor, + } + }, + + async adminAddMemberTx(args: AdminAddMemberArgs): Promise { + return sql.begin(async (tx): Promise => { + const org = await tx<{ id: string }[]>` + SELECT id FROM organizations + WHERE id = ${args.organizationId} AND deleted_at IS NULL + LIMIT 1 FOR UPDATE + ` + if (org.length === 0) return "not_found" + const user = await tx<{ id: string }[]>` + SELECT id FROM users WHERE id = ${args.userId} AND deleted_at IS NULL LIMIT 1 + ` + if (user.length === 0) return "user_not_found" + const existing = await tx<{ role: OrganizationMemberRole }[]>` + SELECT role FROM organization_members + WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} + LIMIT 1 + ` + if (existing.length > 0) return "already_member" + const transfer = args.role === "owner" + const previousOwner = transfer ? await demoteOwnerInTx(tx, args.organizationId) : null + await tx` + INSERT INTO organization_members (organization_id, user_id, role, joined_at) + VALUES (${args.organizationId}, ${args.userId}, ${args.role}, ${args.now}) + ` + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.member_added", + target: `organization:${args.organizationId}`, + meta: { targetUserId: args.userId, role: args.role, reason: args.reason }, + }) + if (transfer) { + await auditOwnershipTransferInTx(tx, { ...args, to: args.userId }, previousOwner) + } + return "added" + }) + }, + + async adminSetMemberRoleTx(args: AdminSetMemberRoleArgs): Promise { + return sql.begin(async (tx): Promise => { + await lockOrganizationInTx(tx, args.organizationId) + const existing = await lockMemberRoleInTx(tx, args.organizationId, args.userId) + if (existing === undefined) return "not_member" + if (existing === args.role) return "updated" + if (existing === "owner") return "sole_owner" + const transfer = args.role === "owner" + const previousOwner = transfer ? await demoteOwnerInTx(tx, args.organizationId) : null + await updateMemberRoleInTx(tx, args) + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.member_role_changed", + target: `organization:${args.organizationId}`, + meta: { + targetUserId: args.userId, + from: existing, + to: args.role, + reason: args.reason, + }, + }) + if (roleChangeWithdrawsInvites(existing, args.role)) { + await revokeInvitesByInviterInTx(tx, { + organizationId: args.organizationId, + inviterId: args.userId, + actorId: args.actorId, + reason: "inviter_demoted", + }) + } + if (transfer) { + await auditOwnershipTransferInTx(tx, { ...args, to: args.userId }, previousOwner) + } + return "updated" + }) + }, + } +} diff --git a/services/api/src/services/host/organization-profile.drizzle.ts b/services/api/src/services/host/organization-profile.drizzle.ts new file mode 100644 index 00000000..e6fb8698 --- /dev/null +++ b/services/api/src/services/host/organization-profile.drizzle.ts @@ -0,0 +1,290 @@ +import { AppError } from "@civfix/shared" +import type postgres from "postgres" +import type { Queryable, Sql } from "../../db/client.js" +import { uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" +import { writeHostAudit } from "./host-audit.js" +import { + organizationColumns, + readOrgHours, + toOrganizationRecord, + type OrganizationRowSelect, +} from "./organization-rows.drizzle.js" +import type { + CreateOrganizationArgs, + OrganizationRecord, + OrganizationRepository, + UpdateOrganizationAudit, + UpdateOrganizationOutcome, + UpdateOrganizationPatch, +} from "./organization-repository.types.js" + +const PG_UNIQUE_VIOLATION = "23505" + +/** The citext unique index on organizations.slug (0105). */ +const ORG_SLUG_INDEX = "organizations_slug_uidx" + +function isUniqueViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: unknown }).code === PG_UNIQUE_VIOLATION + ) +} + +/** + * A unique violation is only "slug taken" when it is THAT index. postgres.js surfaces the violated + * index/constraint on `constraint_name`; any other unique violation inside the transaction (the owner + * partial index, a media claim, ...) is a bug to surface, not a 409 to hand the caller. + */ +function isSlugTaken(err: unknown): boolean { + if (!isUniqueViolation(err)) return false + const e = err as { constraint_name?: unknown } + return typeof e.constraint_name === "string" && e.constraint_name === ORG_SLUG_INDEX +} + +async function claimOrgLogoInTx( + tx: Queryable, + organizationId: string, + logoMediaId: string | null, + claimantUserId: string, +): Promise { + if (logoMediaId === null) return + const claimed = await tx<{ id: string }[]>` + UPDATE media_assets + SET purpose = 'org_logo' + WHERE id = ${logoMediaId} + AND purpose <> 'verification' + AND report_id IS NULL AND post_id IS NULL AND chat_message_id IS NULL + AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) + AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_media_id = media_assets.id) + AND NOT EXISTS (SELECT 1 FROM chat_groups cg WHERE cg.avatar_media_id = media_assets.id) + AND NOT EXISTS ( + SELECT 1 FROM organizations og + WHERE og.id <> ${organizationId} AND og.logo_media_id = media_assets.id + ) + AND NOT EXISTS ( + SELECT 1 FROM cleanups oc + WHERE oc.cover_media_id = media_assets.id + OR oc.gallery_media_ids @> ARRAY[media_assets.id] + ) + AND ( + EXISTS ( + SELECT 1 FROM organizations cur + WHERE cur.id = ${organizationId} AND cur.logo_media_id = media_assets.id + ) + OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) + ) + RETURNING id + ` + if (claimed.length !== 1) { + throw AppError.validation({ logoMediaId: "That image is unavailable." }) + } +} + +async function readById( + tag: Queryable, + id: string, + viewerId: string | null, +): Promise { + const rows = await tag` + SELECT ${organizationColumns(tag, viewerId)} + FROM organizations o + LEFT JOIN media_assets am ON am.id = o.logo_media_id + WHERE o.id = ${id} AND o.deleted_at IS NULL + LIMIT 1 + ` + const row = rows[0] + if (row === undefined) return null + const hours = await readOrgHours(tag, [row.id]) + return toOrganizationRecord(row, hours.get(row.id)) +} + +/** + * An operator-created verified org gets the same decided verification row + audit entry a + * self-submitted application would end with, so adminGetOrg.verification and the audit trail read + * identically whichever path verified it (DECISIONS §32). + */ +async function recordOperatorVerificationInTx( + tx: Queryable, + args: CreateOrganizationArgs, + verifiedKind: NonNullable, +): Promise { + await tx` + INSERT INTO org_verifications ( + organization_id, status, kind, documents, note, + submitted_by, submitted_at, reviewed_by, reviewed_at + ) VALUES ( + ${args.organizationId}, 'verified', ${verifiedKind}, '[]'::jsonb, + 'Created verified by an operator.', + ${args.createdBy}, ${args.now}, ${args.createdBy}, ${args.now} + ) + ` + await writeHostAudit(tx, { + actorId: args.createdBy, + action: "org.verification_verified", + target: `organization:${args.organizationId}`, + meta: { + kind: verifiedKind, + reason: args.operatorReason ?? null, + source: "operator_create", + }, + }) +} + +function organizationPatchSet(sql: Sql, patch: UpdateOrganizationPatch, now: Date) { + const sets: postgres.Fragment[] = [sql`updated_at = ${now}`] + if (patch.name !== undefined) sets.push(sql`name = ${patch.name}`) + if (patch.slug !== undefined) sets.push(sql`slug = ${patch.slug}`) + if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) + if (patch.websiteUrl !== undefined) sets.push(sql`website_url = ${patch.websiteUrl}`) + if (patch.donationUrl !== undefined) sets.push(sql`donation_url = ${patch.donationUrl}`) + if (patch.logoMediaId !== undefined) sets.push(sql`logo_media_id = ${patch.logoMediaId}`) + if (patch.socialLinks !== undefined) { + sets.push( + sql`social_links = ${patch.socialLinks === null ? null : sql.json(patch.socialLinks)}`, + ) + } + return sets.reduce((acc, frag, i) => (i === 0 ? frag : sql`${acc}, ${frag}`)) +} + +export function makeOrganizationProfileMethods( + sql: Sql, +): Pick< + OrganizationRepository, + | "createOrganizationTx" + | "findOrganizationById" + | "findOrganizationBySlug" + | "listMyOrganizations" + | "updateOrganizationTx" +> { + return { + async createOrganizationTx( + args: CreateOrganizationArgs, + ): Promise { + const ownerUserId = args.ownerUserId ?? args.createdBy + const verifiedKind = args.verifiedKind ?? null + try { + return await sql.begin(async (tx) => { + await tx` + INSERT INTO organizations ( + id, slug, name, description, website_url, logo_media_id, social_links, + verified_status, verified_kind, verified_at, + created_by, created_at, updated_at + ) VALUES ( + ${args.organizationId}, + ${args.slug}, + ${args.name}, + ${args.description}, + ${args.websiteUrl}, + ${args.logoMediaId}, + ${args.socialLinks === null ? null : tx.json(args.socialLinks)}, + ${verifiedKind === null ? "unverified" : "verified"}, + ${verifiedKind}, + ${verifiedKind === null ? null : args.now}, + ${args.createdBy}, + ${args.now}, + ${args.now} + ) + ` + await tx` + INSERT INTO organization_members (organization_id, user_id, role, joined_at) + VALUES (${args.organizationId}, ${ownerUserId}, 'owner', ${args.now}) + ` + await claimOrgLogoInTx(tx, args.organizationId, args.logoMediaId, args.createdBy) + if (args.operatorReason !== undefined) { + await writeHostAudit(tx, { + actorId: args.createdBy, + action: "org.created", + target: `organization:${args.organizationId}`, + meta: { + reason: args.operatorReason, + ownerUserId, + slug: args.slug, + verifiedKind, + }, + }) + } + if (verifiedKind !== null) await recordOperatorVerificationInTx(tx, args, verifiedKind) + const created = await readById(tx, args.organizationId, ownerUserId) + if (created === null) throw AppError.internal() + return created + }) + } catch (err) { + if (isSlugTaken(err)) return "slug_taken" + throw err + } + }, + + findOrganizationById(id: string, viewerId: string | null): Promise { + return readById(sql, id, viewerId) + }, + + async findOrganizationBySlug( + slug: string, + viewerId: string | null, + ): Promise { + const rows = await sql` + SELECT ${organizationColumns(sql, viewerId)} + FROM organizations o + LEFT JOIN media_assets am ON am.id = o.logo_media_id + WHERE o.slug = ${slug} AND o.deleted_at IS NULL + LIMIT 1 + ` + const row = rows[0] + if (row === undefined) return null + const hours = await readOrgHours(sql, [row.id]) + return toOrganizationRecord(row, hours.get(row.id)) + }, + + async listMyOrganizations(userId: string, limit: number): Promise { + const rows = await sql` + SELECT ${organizationColumns(sql, userId)} + FROM organizations o + JOIN organization_members m ON m.organization_id = o.id AND m.user_id = ${userId} + LEFT JOIN media_assets am ON am.id = o.logo_media_id + WHERE o.deleted_at IS NULL + ORDER BY o.name ASC, o.id ASC + LIMIT ${limit} + ` + const hours = await readOrgHours( + sql, + rows.map((row) => row.id), + ) + return rows.map((row) => toOrganizationRecord(row, hours.get(row.id))) + }, + + async updateOrganizationTx( + id: string, + patch: UpdateOrganizationPatch, + now: Date, + actorId: string, + audit?: UpdateOrganizationAudit, + ): Promise { + const setList = organizationPatchSet(sql, patch, now) + try { + return await sql.begin(async (tx): Promise => { + const updated = await tx<{ id: string }[]>` + UPDATE organizations SET ${setList} + WHERE id = ${id} AND deleted_at IS NULL + RETURNING id + ` + if (updated.length === 0) return "not_found" + await claimOrgLogoInTx(tx, id, patch.logoMediaId ?? null, actorId) + if (audit !== undefined) { + await writeHostAudit(tx, { + actorId: audit.actorId, + action: "org.updated", + target: `organization:${id}`, + meta: { reason: audit.reason, changed: audit.changed }, + }) + } + return "updated" + }) + } catch (err) { + if (isSlugTaken(err)) return "slug_taken" + throw err + } + }, + } +} diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index ca5900fc..27fec5e7 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -1,1979 +1,17 @@ -import type { - OrganizationInviteRole, - OrganizationInviteStatus, - OrganizationMemberRole, - OrgVerificationKind, - OrgVerificationStatus, - SocialLinks, -} from "@civfix/shared" -import { AppError, MAX_ORG_INVITES_PER_ORG } from "@civfix/shared" -import type postgres from "postgres" -import type { Queryable, Sql } from "../../db/client.js" -import { - encodeTimeCursor, - isUuid, - keysetInstant, - keysetPredicate, - pageWith, - paginateKeyset, - parseKeysetCursor, - parseTimeCursor, -} from "../../db/cursor-helpers.js" -import { likeContains } from "../admin/like.js" -import { publicServedKeyExpr } from "../media-served-key.js" -import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" -import { userUploader } from "../media-uploader.js" -import { writeHostAudit } from "./host-audit.js" -import type { - AcceptOrganizationInviteOutcome, - DeclineOrganizationInviteOutcome, - PendingOrganizationInviteRecord, - AddOrganizationMemberOutcome, - AdminActorView, - AdminAddMemberArgs, - AdminAddMemberOutcome, - AdminOrganizationCounts, - AdminOrganizationListQuery, - AdminOrganizationRecord, - AdminOrgListQuery, - AdminOrgMemberRecord, - AdminOrgVerificationRecord, - AdminSetMemberRoleArgs, - AdminSetMemberRoleOutcome, - ApplyOrgVerificationArgs, - CreateOrganizationArgs, - CreateOrganizationInviteArgs, - CreateOrganizationInviteOutcome, - DecideOrgVerificationArgs, - DecideOrgVerificationOutcome, - OrganizationBaseRecord, - OrganizationInviteRecord, - OrganizationMemberRecord, - OrganizationOwnerRecord, - OrganizationRecord, - OrganizationRepository, - OrgHoursTotals, - OrgMemberIdentifier, - OrgVerificationRecord, - RemoveOrganizationMemberOutcome, - RevokeOrganizationInviteOutcome, - SetOrganizationMemberRoleOutcome, - SetOrganizationSuspendedArgs, - SetOrganizationSuspendedOutcome, - UpdateOrganizationAudit, - UpdateOrganizationOutcome, - UpdateOrganizationPatch, - InviterRevocationReason, - InviterStanding, -} from "./organization-repository.types.js" -import { - canManageOrgMembers, - inviterRevocationReason, - roleChangeWithdrawsInvites, -} from "./organization-repository.types.js" -import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" - -const PG_UNIQUE_VIOLATION = "23505" - -function isUniqueViolation(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - (err as { code?: unknown }).code === PG_UNIQUE_VIOLATION - ) -} - -/** The citext unique index on organizations.slug (0105). */ -const ORG_SLUG_INDEX = "organizations_slug_uidx" - -/** - * A unique violation is only "slug taken" when it is THAT index. postgres.js surfaces the violated - * index/constraint on `constraint_name`; any other unique violation inside the transaction (the owner - * partial index, a media claim, ...) is a bug to surface, not a 409 to hand the caller. - */ -function isSlugTaken(err: unknown): boolean { - if (!isUniqueViolation(err)) return false - const e = err as { constraint_name?: unknown } - return typeof e.constraint_name === "string" && e.constraint_name === ORG_SLUG_INDEX -} - -interface OrganizationRowSelect { - id: string - slug: string - name: string - description: string | null - website_url: string | null - donation_url: string | null - logo_media_id: string | null - logo_key: string | null - social_links: SocialLinks | null - verified_status: OrgVerificationStatus - verified_kind: OrgVerificationKind | null - verified_at: Date | null - created_by: string | null - created_at: Date - updated_at: Date - deleted_at: Date | null - suspended_at: Date | null - suspended_reason: string | null - member_count: number - event_count: number - my_role: OrganizationMemberRole | null -} - -interface OrgHoursRowSelect { - organization_id: string - volunteer_hours: number - volunteer_count: number -} - -const NO_ORG_HOURS: OrgHoursTotals = { volunteerHours: 0, volunteerCount: 0 } - -async function readOrgHours( - tag: Queryable, - organizationIds: readonly string[], -): Promise> { - const totals = new Map() - if (organizationIds.length === 0) return totals - const rows = await tag` - SELECT - c.organization_id, - COALESCE(sum(vh.hours), 0)::float8 AS volunteer_hours, - count(DISTINCT vh.user_id)::int AS volunteer_count - FROM volunteer_hours vh - JOIN cleanups c ON c.id = vh.cleanup_id - JOIN users u ON u.id = vh.user_id - WHERE c.organization_id = ANY(${[...organizationIds]}::uuid[]) - AND vh.source = 'event' - AND vh.voided_at IS NULL - AND u.show_volunteer_hours IS NOT FALSE - GROUP BY c.organization_id - ` - for (const row of rows) { - totals.set(row.organization_id, { - volunteerHours: Number(row.volunteer_hours), - volunteerCount: Number(row.volunteer_count), - }) - } - return totals -} - -interface AdminOrganizationRowSelect extends OrganizationRowSelect { - owner_id: string | null - owner_name: string | null - owner_handle: string | null - owner_joined: Date | null -} - -function toOrganizationBaseRecord(row: OrganizationRowSelect): OrganizationBaseRecord { - return { - id: row.id, - slug: row.slug, - name: row.name, - description: row.description, - websiteUrl: row.website_url, - donationUrl: row.donation_url, - logoMediaId: row.logo_media_id, - logoKey: row.logo_key, - socialLinks: row.social_links, - verifiedStatus: row.verified_status, - verifiedKind: row.verified_kind, - verifiedAt: row.verified_at, - createdBy: row.created_by, - createdAt: row.created_at, - updatedAt: row.updated_at, - deletedAt: row.deleted_at, - suspendedAt: row.suspended_at, - suspendedReason: row.suspended_reason, - memberCount: Number(row.member_count), - eventCount: Number(row.event_count), - myRole: row.my_role, - } -} - -function toOrganizationRecord( - row: OrganizationRowSelect, - hours: OrgHoursTotals | undefined, -): OrganizationRecord { - return { ...toOrganizationBaseRecord(row), ...(hours ?? NO_ORG_HOURS) } -} - -function toAdminOrganizationRecord(row: AdminOrganizationRowSelect): AdminOrganizationRecord { - return { - ...toOrganizationBaseRecord(row), - owner: - row.owner_id === null - ? null - : { - id: row.owner_id, - name: row.owner_name ?? "Unknown", - handle: row.owner_handle ?? "", - joined: row.owner_joined ?? new Date(0), - }, - } -} - -/** Owner columns; assumes the admin joins (own/ou) below are in the FROM. */ -function adminOrganizationColumns(sql: Queryable) { - return sql` - ${organizationColumns(sql, null)}, - ou.id AS owner_id, - ou.display_name AS owner_name, - ou.handle AS owner_handle, - ou.created_at AS owner_joined - ` -} - -function adminOrganizationJoins(sql: Queryable) { - return sql` - LEFT JOIN media_assets am ON am.id = o.logo_media_id - LEFT JOIN organization_members own ON own.organization_id = o.id AND own.role = 'owner' - LEFT JOIN users ou ON ou.id = own.user_id - ` -} - -function organizationColumns(sql: Queryable, viewerId: string | null) { - return sql` - o.id, - o.slug, - o.name, - o.description, - o.website_url, - o.donation_url, - o.logo_media_id, - ${publicServedKeyExpr(sql, "am")} AS logo_key, - o.social_links, - o.verified_status, - o.verified_kind, - o.verified_at, - o.created_by, - o.created_at, - o.updated_at, - o.deleted_at, - o.suspended_at, - o.suspended_reason, - (SELECT count(*)::int FROM organization_members om WHERE om.organization_id = o.id) - AS member_count, - ( - SELECT count(*)::int FROM cleanups c - WHERE c.organization_id = o.id - AND ( - (c.visibility = 'public' AND c.status <> 'cancelled') - OR EXISTS ( - SELECT 1 FROM organization_members vm - WHERE vm.organization_id = o.id AND vm.user_id = ${viewerId}::uuid - ) - ) - ) AS event_count, - ( - SELECT om.role FROM organization_members om - WHERE om.organization_id = o.id AND om.user_id = ${viewerId}::uuid - LIMIT 1 - ) AS my_role - ` -} - -interface VerificationRowSelect { - status: OrgVerificationStatus - kind: OrgVerificationKind - submitted_at: Date | null - reviewed_at: Date | null - rejection_reason: string | null -} - -function toVerificationRecord(row: VerificationRowSelect): OrgVerificationRecord { - return { - status: row.status, - kind: row.kind, - submittedAt: row.submitted_at, - reviewedAt: row.reviewed_at, - rejectionReason: row.rejection_reason, - } -} - -async function claimOrgLogoInTx( - tx: Queryable, - organizationId: string, - logoMediaId: string | null, - claimantUserId: string, -): Promise { - if (logoMediaId === null) return - const claimed = await tx<{ id: string }[]>` - UPDATE media_assets - SET purpose = 'org_logo' - WHERE id = ${logoMediaId} - AND purpose <> 'verification' - AND report_id IS NULL AND post_id IS NULL AND chat_message_id IS NULL - AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) - AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_media_id = media_assets.id) - AND NOT EXISTS (SELECT 1 FROM chat_groups cg WHERE cg.avatar_media_id = media_assets.id) - AND NOT EXISTS ( - SELECT 1 FROM organizations og - WHERE og.id <> ${organizationId} AND og.logo_media_id = media_assets.id - ) - AND NOT EXISTS ( - SELECT 1 FROM cleanups oc - WHERE oc.cover_media_id = media_assets.id - OR oc.gallery_media_ids @> ARRAY[media_assets.id] - ) - AND ( - EXISTS ( - SELECT 1 FROM organizations cur - WHERE cur.id = ${organizationId} AND cur.logo_media_id = media_assets.id - ) - OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) - ) - RETURNING id - ` - if (claimed.length !== 1) { - throw AppError.validation({ logoMediaId: "That image is unavailable." }) - } -} - -export function documentMediaIdsOf(documents: { mediaId?: string }[] | null | undefined): string[] { - const out: string[] = [] - for (const doc of documents ?? []) { - if (typeof doc.mediaId === "string" && !out.includes(doc.mediaId)) out.push(doc.mediaId) - } - return out -} - -async function claimVerificationDocumentsInTx( - tx: Queryable, - mediaIds: readonly string[], - claimantUserId: string, -): Promise { - if (mediaIds.length === 0) return - const claimed = await tx<{ id: string }[]>` - UPDATE media_assets - SET purpose = 'verification' - WHERE id = ANY(${[...mediaIds]}::uuid[]) - AND report_id IS NULL AND post_id IS NULL AND chat_message_id IS NULL - AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) - AND ${uploadedByClaimant(tx, [userUploader(claimantUserId)])} - AND NOT (${mediaBoundElsewhere(tx, null)}) - RETURNING id - ` - if (claimed.length !== new Set(mediaIds).size) { - throw AppError.validation({ documents: "One or more documents are unavailable." }) - } -} - -async function countAdminSeats(tx: Queryable, organizationId: string): Promise { - const rows = await tx<{ n: number }[]>` - SELECT count(*)::int AS n FROM organization_members - WHERE organization_id = ${organizationId} AND role IN ('owner','admin') - ` - return rows[0]?.n ?? 0 -} +import type { Sql } from "../../db/client.js" +import { makeOrganizationAdminMethods } from "./organization-admin.drizzle.js" +import { makeOrganizationInviteMethods } from "./organization-invites.drizzle.js" +import { makeOrganizationMemberMethods } from "./organization-members.drizzle.js" +import { makeOrganizationProfileMethods } from "./organization-profile.drizzle.js" +import type { OrganizationRepository } from "./organization-repository.types.js" +import { makeOrganizationVerificationMethods } from "./organization-verification.drizzle.js" export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationRepository { - function memberCursorFilter(raw: string | null) { - const cursor = parseKeysetCursor(raw, { direction: "asc" }) - return cursor === null - ? sql`` - : sql`AND ${keysetPredicate(sql, sql`m.joined_at`, sql`m.user_id`, cursor, { direction: "asc" })}` - } - - async function readById( - tag: Queryable, - id: string, - viewerId: string | null, - ): Promise { - const rows = await tag` - SELECT ${organizationColumns(tag, viewerId)} - FROM organizations o - LEFT JOIN media_assets am ON am.id = o.logo_media_id - WHERE o.id = ${id} AND o.deleted_at IS NULL - LIMIT 1 - ` - const row = rows[0] - if (row === undefined) return null - const hours = await readOrgHours(tag, [row.id]) - return toOrganizationRecord(row, hours.get(row.id)) - } - - async function adminRowFor( - tag: Queryable, - organizationId: string, - ): Promise { - const rows = await tag` - SELECT ${adminVerificationColumns(tag)} - FROM org_verifications v - JOIN organizations o ON o.id = v.organization_id - LEFT JOIN users su ON su.id = v.submitted_by - LEFT JOIN users ru ON ru.id = v.reviewed_by - WHERE v.organization_id = ${organizationId} - ORDER BY v.submitted_at DESC, v.id DESC - LIMIT 1 - ` - return rows[0] ? toAdminVerificationRecord(rows[0]) : null - } - - return { - async createOrganizationTx( - args: CreateOrganizationArgs, - ): Promise { - const ownerUserId = args.ownerUserId ?? args.createdBy - const verifiedKind = args.verifiedKind ?? null - try { - return await sql.begin(async (tx) => { - await tx` - INSERT INTO organizations ( - id, slug, name, description, website_url, logo_media_id, social_links, - verified_status, verified_kind, verified_at, - created_by, created_at, updated_at - ) VALUES ( - ${args.organizationId}, - ${args.slug}, - ${args.name}, - ${args.description}, - ${args.websiteUrl}, - ${args.logoMediaId}, - ${args.socialLinks === null ? null : tx.json(args.socialLinks)}, - ${verifiedKind === null ? "unverified" : "verified"}, - ${verifiedKind}, - ${verifiedKind === null ? null : args.now}, - ${args.createdBy}, - ${args.now}, - ${args.now} - ) - ` - await tx` - INSERT INTO organization_members (organization_id, user_id, role, joined_at) - VALUES (${args.organizationId}, ${ownerUserId}, 'owner', ${args.now}) - ` - await claimOrgLogoInTx(tx, args.organizationId, args.logoMediaId, args.createdBy) - if (args.operatorReason !== undefined) { - await writeHostAudit(tx, { - actorId: args.createdBy, - action: "org.created", - target: `organization:${args.organizationId}`, - meta: { - reason: args.operatorReason, - ownerUserId, - slug: args.slug, - verifiedKind, - }, - }) - } - if (verifiedKind !== null) { - // An operator-created verified org gets the same decided verification row + audit entry a - // self-submitted application would end with, so adminGetOrg.verification and the audit trail - // read identically whichever path verified it (DECISIONS §32). - await tx` - INSERT INTO org_verifications ( - organization_id, status, kind, documents, note, - submitted_by, submitted_at, reviewed_by, reviewed_at - ) VALUES ( - ${args.organizationId}, 'verified', ${verifiedKind}, '[]'::jsonb, - 'Created verified by an operator.', - ${args.createdBy}, ${args.now}, ${args.createdBy}, ${args.now} - ) - ` - await writeHostAudit(tx, { - actorId: args.createdBy, - action: "org.verification_verified", - target: `organization:${args.organizationId}`, - meta: { - kind: verifiedKind, - reason: args.operatorReason ?? null, - source: "operator_create", - }, - }) - } - const created = await readById(tx, args.organizationId, ownerUserId) - if (created === null) throw AppError.internal() - return created - }) - } catch (err) { - if (isSlugTaken(err)) return "slug_taken" - throw err - } - }, - - findOrganizationById(id: string, viewerId: string | null): Promise { - return readById(sql, id, viewerId) - }, - - async findOrganizationBySlug( - slug: string, - viewerId: string | null, - ): Promise { - const rows = await sql` - SELECT ${organizationColumns(sql, viewerId)} - FROM organizations o - LEFT JOIN media_assets am ON am.id = o.logo_media_id - WHERE o.slug = ${slug} AND o.deleted_at IS NULL - LIMIT 1 - ` - const row = rows[0] - if (row === undefined) return null - const hours = await readOrgHours(sql, [row.id]) - return toOrganizationRecord(row, hours.get(row.id)) - }, - - async listMyOrganizations(userId: string, limit: number): Promise { - const rows = await sql` - SELECT ${organizationColumns(sql, userId)} - FROM organizations o - JOIN organization_members m ON m.organization_id = o.id AND m.user_id = ${userId} - LEFT JOIN media_assets am ON am.id = o.logo_media_id - WHERE o.deleted_at IS NULL - ORDER BY o.name ASC, o.id ASC - LIMIT ${limit} - ` - const hours = await readOrgHours( - sql, - rows.map((row) => row.id), - ) - return rows.map((row) => toOrganizationRecord(row, hours.get(row.id))) - }, - - async updateOrganizationTx( - id: string, - patch: UpdateOrganizationPatch, - now: Date, - actorId: string, - audit?: UpdateOrganizationAudit, - ): Promise { - const sets: postgres.Fragment[] = [sql`updated_at = ${now}`] - if (patch.name !== undefined) sets.push(sql`name = ${patch.name}`) - if (patch.slug !== undefined) sets.push(sql`slug = ${patch.slug}`) - if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) - if (patch.websiteUrl !== undefined) sets.push(sql`website_url = ${patch.websiteUrl}`) - if (patch.donationUrl !== undefined) sets.push(sql`donation_url = ${patch.donationUrl}`) - if (patch.logoMediaId !== undefined) sets.push(sql`logo_media_id = ${patch.logoMediaId}`) - if (patch.socialLinks !== undefined) { - sets.push( - sql`social_links = ${patch.socialLinks === null ? null : sql.json(patch.socialLinks)}`, - ) - } - const setList = sets.reduce((acc, frag, i) => (i === 0 ? frag : sql`${acc}, ${frag}`)) - try { - return await sql.begin(async (tx): Promise => { - const updated = await tx<{ id: string }[]>` - UPDATE organizations SET ${setList} - WHERE id = ${id} AND deleted_at IS NULL - RETURNING id - ` - if (updated.length === 0) return "not_found" - await claimOrgLogoInTx(tx, id, patch.logoMediaId ?? null, actorId) - if (audit !== undefined) { - await writeHostAudit(tx, { - actorId: audit.actorId, - action: "org.updated", - target: `organization:${id}`, - meta: { reason: audit.reason, changed: audit.changed }, - }) - } - return "updated" - }) - } catch (err) { - if (isSlugTaken(err)) return "slug_taken" - throw err - } - }, - - async roleOf(organizationId: string, userId: string): Promise { - const rows = await sql<{ role: OrganizationMemberRole }[]>` - SELECT om.role - FROM organization_members om - JOIN organizations o ON o.id = om.organization_id AND o.deleted_at IS NULL - WHERE om.organization_id = ${organizationId} AND om.user_id = ${userId} - LIMIT 1 - ` - return rows[0]?.role ?? null - }, - - async listMembers(args: { - organizationId: string - cursor: string | null - limit: number - }): Promise<{ items: OrganizationMemberRecord[]; nextCursor: string | null }> { - const rows = await sql< - { - user_id: string - display_name: string - handle: string | null - bio: string | null - avatar_url: string | null - role: OrganizationMemberRole - joined_at: Date - cursor_at: string - }[] - >` - SELECT m.user_id, u.display_name, u.handle, u.bio, u.avatar_url, m.role, m.joined_at, - ${keysetInstant(sql, sql`m.joined_at`)} AS cursor_at - FROM organization_members m - JOIN users u ON u.id = m.user_id - WHERE m.organization_id = ${args.organizationId} - ${memberCursorFilter(args.cursor)} - ORDER BY m.joined_at ASC, m.user_id ASC - LIMIT ${args.limit + 1} - ` - const page = paginateKeyset(rows, args.limit, (last) => ({ - atText: last.cursor_at, - id: last.user_id, - })) - return { - items: page.items.map((r) => ({ - person: { - id: r.user_id, - displayName: r.display_name, - handle: r.handle, - bio: r.bio, - avatarUrl: r.avatar_url, - }, - role: r.role, - joinedAt: r.joined_at, - })), - nextCursor: page.nextCursor, - } - }, - - async findMember( - organizationId: string, - userId: string, - ): Promise { - const rows = await sql< - { - user_id: string - display_name: string - handle: string | null - bio: string | null - avatar_url: string | null - role: OrganizationMemberRole - joined_at: Date - }[] - >` - SELECT m.user_id, u.display_name, u.handle, u.bio, u.avatar_url, m.role, m.joined_at - FROM organization_members m - JOIN users u ON u.id = m.user_id - WHERE m.organization_id = ${organizationId} AND m.user_id = ${userId} - LIMIT 1 - ` - const r = rows[0] - if (r === undefined) return null - return { - person: { - id: r.user_id, - displayName: r.display_name, - handle: r.handle, - bio: r.bio, - avatarUrl: r.avatar_url, - }, - role: r.role, - joinedAt: r.joined_at, - } - }, - - async findOwner(organizationId: string): Promise { - const rows = await sql< - { - user_id: string - display_name: string - handle: string - email: string | null - created_at: Date - }[] - >` - SELECT m.user_id, u.display_name, u.handle, u.email, u.created_at - FROM organization_members m - JOIN users u ON u.id = m.user_id AND u.deleted_at IS NULL - WHERE m.organization_id = ${organizationId} AND m.role = 'owner' - LIMIT 1 - ` - const r = rows[0] - return r === undefined - ? null - : { - userId: r.user_id, - displayName: r.display_name, - handle: r.handle, - email: r.email, - joined: r.created_at, - } - }, - - async findUser(userId: string): Promise { - const rows = await sql< - { id: string; display_name: string; handle: string; created_at: Date }[] - >` - SELECT id, display_name, handle, created_at FROM users - WHERE id = ${userId} AND deleted_at IS NULL - LIMIT 1 - ` - const r = rows[0] - return r === undefined - ? null - : { id: r.id, name: r.display_name, handle: r.handle, joined: r.created_at } - }, - - async resolveUserByIdentifier(identifier: OrgMemberIdentifier): Promise { - const rows = - identifier.identifierKind === "handle" - ? await sql<{ id: string }[]>` - SELECT id FROM users - WHERE handle = ${identifier.identifier} AND deleted_at IS NULL - LIMIT 1 - ` - : await sql<{ id: string }[]>` - SELECT id FROM users - WHERE email = ${identifier.identifier} - AND email_verified = true - AND deleted_at IS NULL - LIMIT 1 - ` - return rows[0]?.id ?? null - }, - - async addMemberTx(args: { - organizationId: string - userId: string - role: "admin" | "member" - actorId: string - now: Date - }): Promise { - return sql.begin(async (tx): Promise => { - if (!(await lockOrgForActorIn(tx, args.organizationId, args.actorId))) return "forbidden" - const inserted = await tx<{ user_id: string }[]>` - INSERT INTO organization_members (organization_id, user_id, role, joined_at) - VALUES (${args.organizationId}, ${args.userId}, ${args.role}, ${args.now}) - ON CONFLICT (organization_id, user_id) DO NOTHING - RETURNING user_id - ` - if (inserted.length === 0) return "already_member" - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.member_added", - target: `organization:${args.organizationId}`, - meta: { targetUserId: args.userId, role: args.role, via: "handle" }, - }) - return "added" - }) - }, - - async setMemberRoleTx(args: { - organizationId: string - userId: string - role: "admin" | "member" - actorId: string - }): Promise { - return sql.begin(async (tx) => { - await tx` - SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE - ` - const current = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - LIMIT 1 - FOR UPDATE - ` - const existing = current[0] - if (existing === undefined) return "not_member" - if (existing.role === "owner") return "owner" - if (existing.role === args.role) return "updated" - if (existing.role === "admin" && args.role === "member") { - const seats = await countAdminSeats(tx, args.organizationId) - if (seats <= 1) return "last_admin" - } - await tx` - UPDATE organization_members SET role = ${args.role} - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - ` - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.member_role_changed", - target: `organization:${args.organizationId}`, - meta: { targetUserId: args.userId, from: existing.role, to: args.role }, - }) - if (roleChangeWithdrawsInvites(existing.role, args.role)) { - await revokeInvitesByInviterInTx(tx, { - organizationId: args.organizationId, - inviterId: args.userId, - actorId: args.actorId, - reason: "inviter_demoted", - }) - } - return "updated" - }) - }, - - async removeMemberTx(args: { - organizationId: string - userId: string - actorId: string - reason?: string - }): Promise { - return sql.begin(async (tx) => { - await tx` - SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE - ` - await tx` - SELECT id FROM users WHERE id = ${args.userId} LIMIT 1 FOR UPDATE - ` - const target = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - LIMIT 1 - FOR UPDATE - ` - if (target[0]?.role === "admin") { - const seats = await countAdminSeats(tx, args.organizationId) - if (seats <= 1) return "last_admin" - } - const removed = await tx<{ role: OrganizationMemberRole }[]>` - DELETE FROM organization_members - WHERE organization_id = ${args.organizationId} - AND user_id = ${args.userId} - AND role <> 'owner' - RETURNING role - ` - if (removed.length === 0) { - const still = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - LIMIT 1 - ` - return still.length > 0 ? "owner" : "not_member" - } - await tx` - UPDATE users SET primary_organization_id = NULL - WHERE id = ${args.userId} AND primary_organization_id = ${args.organizationId} - ` - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.member_removed", - target: `organization:${args.organizationId}`, - meta: { - targetUserId: args.userId, - role: removed[0]?.role ?? null, - ...(args.reason !== undefined ? { reason: args.reason } : {}), - }, - }) - await revokeInvitesByInviterInTx(tx, { - organizationId: args.organizationId, - inviterId: args.userId, - actorId: args.actorId, - reason: "inviter_removed", - }) - return "removed" - }) - }, - - async applyVerificationTx(args: ApplyOrgVerificationArgs): Promise { - return sql.begin(async (tx) => { - await tx` - SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE - ` - const open = await tx<{ id: string; documents: { mediaId?: string }[] | null }[]>` - SELECT id, documents FROM org_verifications - WHERE organization_id = ${args.organizationId} AND status = 'pending' - ORDER BY submitted_at DESC, id DESC - LIMIT 1 - FOR UPDATE - ` - const openRow = open[0] - const carried = documentMediaIdsOf(openRow?.documents) - const submitted = documentMediaIdsOf(args.documentMediaIds.map((mediaId) => ({ mediaId }))) - const effective = submitted.length === 0 ? carried : submitted - const fresh = effective.filter((mediaId) => !carried.includes(mediaId)) - const documents = tx.json(effective.map((mediaId) => ({ mediaId }))) - const updated = - openRow === undefined - ? [] - : await tx` - UPDATE org_verifications - SET kind = ${args.kind}, - ein_number = ${args.einNumber}, - ein_scrubbed_at = NULL, - documents = ${documents}, - note = ${args.note}, - submitted_by = ${args.submittedBy}, - submitted_at = ${args.now} - WHERE id = ${openRow.id} - RETURNING status, kind, submitted_at, reviewed_at, rejection_reason - ` - const rows = - updated.length > 0 - ? updated - : await tx` - INSERT INTO org_verifications ( - id, organization_id, status, kind, ein_number, documents, note, - submitted_by, submitted_at - ) VALUES ( - ${args.verificationId}, - ${args.organizationId}, - 'pending', - ${args.kind}, - ${args.einNumber}, - ${documents}, - ${args.note}, - ${args.submittedBy}, - ${args.now} - ) - RETURNING status, kind, submitted_at, reviewed_at, rejection_reason - ` - await claimVerificationDocumentsInTx(tx, fresh, args.submittedBy) - const dropped = carried.filter((mediaId) => !effective.includes(mediaId)) - if (dropped.length > 0) { - await tx` - UPDATE media_assets SET purpose = 'report' - WHERE id = ANY(${dropped}::uuid[]) AND purpose = 'verification' - ` - } - await tx` - UPDATE organizations - SET verified_status = 'pending', updated_at = ${args.now} - WHERE id = ${args.organizationId} AND verified_status <> 'verified' - ` - await writeHostAudit(tx, { - actorId: args.submittedBy, - action: "org.verification_submitted", - target: `organization:${args.organizationId}`, - meta: { kind: args.kind, documentCount: effective.length }, - }) - const row = rows[0] - if (row === undefined) throw AppError.internal() - return toVerificationRecord(row) - }) - }, - - async getVerification(organizationId: string): Promise { - const rows = await sql` - SELECT status, kind, submitted_at, reviewed_at, rejection_reason - FROM org_verifications - WHERE organization_id = ${organizationId} - ORDER BY submitted_at DESC, id DESC - LIMIT 1 - ` - return rows[0] ? toVerificationRecord(rows[0]) : null - }, - - async adminListVerifications(query: AdminOrgListQuery): Promise<{ - items: AdminOrgVerificationRecord[] - nextCursor: string | null - pendingCount: number - }> { - const cursor = parseTimeCursor(query.cursor) - const cursorFilter = - cursor !== null - ? sql`AND (v.submitted_at, v.id) < (${cursor.at}, ${cursor.id}::uuid)` - : sql`` - const statusFilter = query.status !== undefined ? sql`AND v.status = ${query.status}` : sql`` - const kindFilter = query.kind !== undefined ? sql`AND v.kind = ${query.kind}` : sql`` - const qFilter = - query.q !== undefined && query.q.length > 0 - ? sql`AND (o.name ILIKE ${likeContains(query.q)} ESCAPE '\\' - OR o.slug ILIKE ${likeContains(query.q)} ESCAPE '\\')` - : sql`` - const rows = await sql` - SELECT ${adminVerificationColumns(sql)} - FROM org_verifications v - JOIN organizations o ON o.id = v.organization_id - LEFT JOIN users su ON su.id = v.submitted_by - LEFT JOIN users ru ON ru.id = v.reviewed_by - WHERE TRUE - ${statusFilter} - ${kindFilter} - ${qFilter} - ${cursorFilter} - ORDER BY v.submitted_at DESC, v.id DESC - LIMIT ${query.limit + 1} - ` - const pending = await sql<{ count: number }[]>` - SELECT count(*)::int AS count FROM org_verifications WHERE status = 'pending' - ` - const page = pageWith(rows.map(toAdminVerificationRecord), query.limit, (last) => - last.submittedAt === null ? null : encodeTimeCursor({ at: last.submittedAt, id: last.id }), - ) - return { ...page, pendingCount: pending[0]?.count ?? 0 } - }, - - adminGetVerification(organizationId: string): Promise { - return adminRowFor(sql, organizationId) - }, - - async adminGetVerifications( - organizationIds: string[], - ): Promise> { - const out = new Map() - if (organizationIds.length === 0) return out - const rows = await sql` - SELECT DISTINCT ON (v.organization_id) ${adminVerificationColumns(sql)} - FROM org_verifications v - JOIN organizations o ON o.id = v.organization_id - LEFT JOIN users su ON su.id = v.submitted_by - LEFT JOIN users ru ON ru.id = v.reviewed_by - WHERE v.organization_id = ANY(${[...organizationIds]}::uuid[]) - ORDER BY v.organization_id, v.submitted_at DESC, v.id DESC - ` - for (const row of rows) out.set(row.organization_id, toAdminVerificationRecord(row)) - return out - }, - - async adminFindOrganization(id: string): Promise { - const rows = await sql` - SELECT ${adminOrganizationColumns(sql)} - FROM organizations o - ${adminOrganizationJoins(sql)} - WHERE o.id = ${id} AND o.deleted_at IS NULL - LIMIT 1 - ` - return rows[0] ? toAdminOrganizationRecord(rows[0]) : null - }, - - async adminListOrganizations(query: AdminOrganizationListQuery): Promise<{ - items: AdminOrganizationRecord[] - nextCursor: string | null - counts: AdminOrganizationCounts | null - }> { - const cursor = parseKeysetCursor(query.cursor) - const q = query.q?.trim() ?? "" - const qFilter = - q.length > 0 - ? sql`AND (o.name ILIKE ${likeContains(q)} ESCAPE '\\' - OR o.slug ILIKE ${likeContains(q)} ESCAPE '\\' - ${isUuid(q) ? sql`OR o.id = ${q}::uuid` : sql``})` - : sql`` - const verifiedFilter = - query.verified !== undefined ? sql`AND o.verified_status = ${query.verified}` : sql`` - const kindFilter = query.kind !== undefined ? sql`AND o.verified_kind = ${query.kind}` : sql`` - const suspendedFilter = - query.suspended === undefined - ? sql`` - : query.suspended - ? sql`AND o.suspended_at IS NOT NULL` - : sql`AND o.suspended_at IS NULL` - const cursorFilter = - cursor !== null - ? sql`AND ${keysetPredicate(sql, sql`o.created_at`, sql`o.id`, cursor)}` - : sql`` - const rows = await sql<(AdminOrganizationRowSelect & { cursor_at: string })[]>` - SELECT ${adminOrganizationColumns(sql)}, - ${keysetInstant(sql, sql`o.created_at`)} AS cursor_at - FROM organizations o - ${adminOrganizationJoins(sql)} - WHERE o.deleted_at IS NULL - ${qFilter} - ${verifiedFilter} - ${kindFilter} - ${suspendedFilter} - ${cursorFilter} - ORDER BY o.created_at DESC, o.id DESC - LIMIT ${query.limit + 1} - ` - const keyed = paginateKeyset(rows, query.limit, (last) => ({ - atText: last.cursor_at, - id: last.id, - })) - const page = { - items: keyed.items.map(toAdminOrganizationRecord), - nextCursor: keyed.nextCursor, - } - // Facet counts span the SEARCHED set but ignore the facets, and only on page one (the shared - // admin-list policy: the console reads the chip numbers off the first page). - let counts: AdminOrganizationCounts | null = null - if (cursor === null) { - const totals = await sql< - { all: number; verified: number; pending: number; suspended: number }[] - >` - SELECT - count(*)::int AS all, - count(*) FILTER (WHERE o.verified_status = 'verified')::int AS verified, - count(*) FILTER (WHERE o.verified_status = 'pending')::int AS pending, - count(*) FILTER (WHERE o.suspended_at IS NOT NULL)::int AS suspended - FROM organizations o - WHERE o.deleted_at IS NULL - ${qFilter} - ` - const t = totals[0] - counts = { - all: Number(t?.all ?? 0), - verified: Number(t?.verified ?? 0), - pending: Number(t?.pending ?? 0), - suspended: Number(t?.suspended ?? 0), - } - } - return { ...page, counts } - }, - - async setSuspendedTx( - args: SetOrganizationSuspendedArgs, - ): Promise { - return sql.begin(async (tx) => { - const updated = await tx<{ id: string }[]>` - UPDATE organizations - SET suspended_at = ${args.suspended ? args.now : null}, - suspended_reason = ${args.suspended ? args.reason : null}, - suspended_by = ${args.suspended ? args.actorId : null}, - updated_at = ${args.now} - WHERE id = ${args.organizationId} AND deleted_at IS NULL - RETURNING id - ` - if (updated.length === 0) return "not_found" - await writeHostAudit(tx, { - actorId: args.actorId, - action: args.suspended ? "org.suspended" : "org.unsuspended", - target: `organization:${args.organizationId}`, - meta: { reason: args.reason }, - }) - return "updated" - }) - }, - - async adminListMembers(args: { - organizationId: string - cursor: string | null - limit: number - }): Promise<{ items: AdminOrgMemberRecord[]; nextCursor: string | null }> { - const rows = await sql< - { - user_id: string - display_name: string - handle: string - created_at: Date - role: OrganizationMemberRole - joined_at: Date - cursor_at: string - }[] - >` - SELECT m.user_id, u.display_name, u.handle, u.created_at, m.role, m.joined_at, - ${keysetInstant(sql, sql`m.joined_at`)} AS cursor_at - FROM organization_members m - JOIN users u ON u.id = m.user_id - WHERE m.organization_id = ${args.organizationId} - ${memberCursorFilter(args.cursor)} - ORDER BY m.joined_at ASC, m.user_id ASC - LIMIT ${args.limit + 1} - ` - const page = paginateKeyset(rows, args.limit, (last) => ({ - atText: last.cursor_at, - id: last.user_id, - })) - return { - items: page.items.map((r) => ({ - user: { id: r.user_id, name: r.display_name, handle: r.handle, joined: r.created_at }, - role: r.role, - joinedAt: r.joined_at, - })), - nextCursor: page.nextCursor, - } - }, - - async adminAddMemberTx(args: AdminAddMemberArgs): Promise { - return sql.begin(async (tx): Promise => { - const org = await tx<{ id: string }[]>` - SELECT id FROM organizations - WHERE id = ${args.organizationId} AND deleted_at IS NULL - LIMIT 1 FOR UPDATE - ` - if (org.length === 0) return "not_found" - const user = await tx<{ id: string }[]>` - SELECT id FROM users WHERE id = ${args.userId} AND deleted_at IS NULL LIMIT 1 - ` - if (user.length === 0) return "user_not_found" - const existing = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - LIMIT 1 - ` - if (existing.length > 0) return "already_member" - let previousOwner: string | null = null - if (args.role === "owner") { - // Ownership transfer: exactly one owner per org (partial unique index), so the current owner - // steps down to admin in the same transaction before the new owner is seated. - const demoted = await tx<{ user_id: string }[]>` - UPDATE organization_members SET role = 'admin' - WHERE organization_id = ${args.organizationId} AND role = 'owner' - RETURNING user_id - ` - previousOwner = demoted[0]?.user_id ?? null - } - await tx` - INSERT INTO organization_members (organization_id, user_id, role, joined_at) - VALUES (${args.organizationId}, ${args.userId}, ${args.role}, ${args.now}) - ` - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.member_added", - target: `organization:${args.organizationId}`, - meta: { targetUserId: args.userId, role: args.role, reason: args.reason }, - }) - if (args.role === "owner") { - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.ownership_transferred", - target: `organization:${args.organizationId}`, - meta: { from: previousOwner, to: args.userId, reason: args.reason }, - }) - } - return "added" - }) - }, - - async adminSetMemberRoleTx(args: AdminSetMemberRoleArgs): Promise { - return sql.begin(async (tx): Promise => { - await tx` - SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE - ` - const current = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - LIMIT 1 - FOR UPDATE - ` - const existing = current[0] - if (existing === undefined) return "not_member" - if (existing.role === args.role) return "updated" - if (existing.role === "owner") return "sole_owner" - let previousOwner: string | null = null - if (args.role === "owner") { - const demoted = await tx<{ user_id: string }[]>` - UPDATE organization_members SET role = 'admin' - WHERE organization_id = ${args.organizationId} AND role = 'owner' - RETURNING user_id - ` - previousOwner = demoted[0]?.user_id ?? null - } - await tx` - UPDATE organization_members SET role = ${args.role} - WHERE organization_id = ${args.organizationId} AND user_id = ${args.userId} - ` - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.member_role_changed", - target: `organization:${args.organizationId}`, - meta: { - targetUserId: args.userId, - from: existing.role, - to: args.role, - reason: args.reason, - }, - }) - if (roleChangeWithdrawsInvites(existing.role, args.role)) { - await revokeInvitesByInviterInTx(tx, { - organizationId: args.organizationId, - inviterId: args.userId, - actorId: args.actorId, - reason: "inviter_demoted", - }) - } - if (args.role === "owner") { - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.ownership_transferred", - target: `organization:${args.organizationId}`, - meta: { from: previousOwner, to: args.userId, reason: args.reason }, - }) - } - return "updated" - }) - }, - - async countPendingInvites(organizationId: string, now: Date): Promise { - const rows = await sql<{ count: number }[]>` - SELECT count(*)::int AS count FROM organization_invites - WHERE organization_id = ${organizationId} AND status = 'pending' AND expires_at > ${now} - ` - return Number(rows[0]?.count ?? 0) - }, - - async createInviteTx( - args: CreateOrganizationInviteArgs, - ): Promise { - return sql.begin(async (tx): Promise => { - // The organization row lock also serializes concurrent inviters on one org (this is the only - // insert into organization_invites), so the cap below is counted, not raced. - if (!(await lockOrgForActorIn(tx, args.organizationId, args.invitedBy))) { - return { kind: "forbidden" } - } - await expireInvitesInTx(tx, args.organizationId, args.now) - // The cap is checked before the idempotent re-invite path on purpose: it must not depend on - // the address. - const pending = await tx<{ count: number }[]>` - SELECT count(*)::int AS count FROM organization_invites - WHERE organization_id = ${args.organizationId} - AND status = 'pending' - AND expires_at > ${args.now} - ` - if (Number(pending[0]?.count ?? 0) >= MAX_ORG_INVITES_PER_ORG) { - throw AppError.conflict(ORG_INVITE_CAP_MESSAGE) - } - const inserted = await tx<{ id: string }[]>` - INSERT INTO organization_invites ( - id, organization_id, email, user_id, role, token_hash, status, invited_by, created_at, - expires_at - ) VALUES ( - ${args.inviteId}, ${args.organizationId}, ${args.email}, ${args.userId}, ${args.role}, - ${args.tokenHash}, 'pending', ${args.invitedBy}, ${args.now}, ${args.expiresAt} - ) - ON CONFLICT (organization_id, email) WHERE status = 'pending' AND email IS NOT NULL - DO NOTHING - RETURNING id - ` - if (inserted.length === 0) { - // A re-invite of an address with an open invite answers with THAT invite (idempotent), so the - // inviter cannot tell an address apart by whether a second call 409s. - const open = await tx<{ id: string }[]>` - SELECT id FROM organization_invites - WHERE organization_id = ${args.organizationId} - AND email = ${args.email} - AND status = 'pending' - LIMIT 1 - ` - const openId = open[0]?.id - if (openId === undefined) throw AppError.internal() - const invite = await readInvite(tx, openId) - if (invite === null) throw AppError.internal() - return { kind: "already_invited", invite } - } - await writeHostAudit(tx, { - actorId: args.invitedBy, - action: "org.invite_created", - target: `organization:${args.organizationId}`, - meta: { inviteId: args.inviteId, role: args.role }, - }) - const invite = await readInvite(tx, args.inviteId) - if (invite === null) throw AppError.internal() - return { kind: "created", invite } - }) - }, - - async listInvites( - organizationId: string, - now: Date, - limit: number, - ): Promise { - await expireInvitesInTx(sql, organizationId, now) - const rows = await sql` - SELECT ${inviteColumns(sql)} - FROM organization_invites i - LEFT JOIN users iu ON iu.id = i.invited_by - LEFT JOIN users au ON au.id = i.user_id - WHERE i.organization_id = ${organizationId} - ORDER BY (i.status = 'pending') DESC, i.created_at DESC, i.id DESC - LIMIT ${limit} - ` - return rows.map(toInviteRecord) - }, - - async revokeInviteTx(args: { - organizationId: string - inviteId: string - actorId: string - now: Date - }): Promise { - return sql.begin(async (tx): Promise => { - const revoked = await tx<{ id: string }[]>` - UPDATE organization_invites - SET status = 'revoked', revoked_at = ${args.now} - WHERE id = ${args.inviteId} - AND organization_id = ${args.organizationId} - AND status = 'pending' - RETURNING id - ` - if (revoked.length === 0) return "not_found" - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.invite_revoked", - target: `organization:${args.organizationId}`, - meta: { inviteId: args.inviteId }, - }) - return "revoked" - }) - }, - - async listPendingInvitesForUser(args: { - userId: string - now: Date - limit: number - }): Promise { - const viewer = await sql<{ email: string | null }[]>` - SELECT email FROM users - WHERE id = ${args.userId} AND email_verified = true AND deleted_at IS NULL - LIMIT 1 - ` - const verifiedEmail = viewer[0]?.email ?? null - const addressed = - verifiedEmail === null - ? sql`i.user_id = ${args.userId}` - : sql`(i.user_id = ${args.userId} OR i.email = ${verifiedEmail})` - const rows = await sql< - { - id: string - role: OrganizationInviteRole - created_at: Date - expires_at: Date - invited_by_id: string | null - invited_by_name: string | null - invited_by_handle: string | null - invited_by_bio: string | null - invited_by_avatar_url: string | null - organization_id: string - organization_slug: string - organization_name: string - organization_logo_key: string | null - organization_verified_status: OrgVerificationStatus - organization_verified_kind: OrgVerificationKind | null - }[] - >` - SELECT - i.id, - i.role, - i.created_at, - i.expires_at, - iu.id AS invited_by_id, - iu.display_name AS invited_by_name, - iu.handle AS invited_by_handle, - iu.bio AS invited_by_bio, - iu.avatar_url AS invited_by_avatar_url, - o.id AS organization_id, - o.slug AS organization_slug, - o.name AS organization_name, - ${publicServedKeyExpr(sql, "am")} AS organization_logo_key, - o.verified_status AS organization_verified_status, - o.verified_kind AS organization_verified_kind - FROM organization_invites i - JOIN organizations o ON o.id = i.organization_id - LEFT JOIN users iu ON iu.id = i.invited_by - LEFT JOIN media_assets am ON am.id = o.logo_media_id - WHERE i.status = 'pending' - AND i.expires_at > ${args.now} - AND o.deleted_at IS NULL - AND o.suspended_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM organization_members m - WHERE m.organization_id = i.organization_id AND m.user_id = ${args.userId} - ) - AND ${addressed} - ORDER BY i.created_at DESC, i.id DESC - LIMIT ${args.limit} - ` - return rows.map((r) => ({ - id: r.id, - role: r.role, - createdAt: r.created_at, - expiresAt: r.expires_at, - invitedBy: - r.invited_by_id === null - ? null - : { - id: r.invited_by_id, - displayName: r.invited_by_name ?? "", - handle: r.invited_by_handle, - bio: r.invited_by_bio, - avatarUrl: r.invited_by_avatar_url, - }, - organization: { - id: r.organization_id, - slug: r.organization_slug, - name: r.organization_name, - logoKey: r.organization_logo_key, - donationUrl: null, - verifiedStatus: r.organization_verified_status, - verifiedKind: r.organization_verified_kind, - suspended: false, - }, - })) - }, - - async declineInviteTx(args: { - inviteId: string - userId: string - now: Date - }): Promise { - return sql.begin(async (tx): Promise => { - const rows = await tx< - { - id: string - organization_id: string - email: string | null - user_id: string | null - status: OrganizationInviteStatus - expires_at: Date - }[] - >` - SELECT id, organization_id, email, user_id, status, expires_at - FROM organization_invites - WHERE id = ${args.inviteId} - LIMIT 1 FOR UPDATE - ` - const invite = rows[0] - if (invite === undefined || invite.status !== "pending") return "invalid" - const addressed = - invite.user_id !== null && invite.user_id === args.userId - ? true - : invite.email === null - ? false - : ( - await tx<{ one: number }[]>` - SELECT 1 AS one FROM users - WHERE id = ${args.userId} - AND email = ${invite.email} - AND email_verified = true - AND deleted_at IS NULL - LIMIT 1 - ` - ).length > 0 - if (!addressed) return "invalid" - if (invite.expires_at.getTime() <= args.now.getTime()) { - await tx`UPDATE organization_invites SET status = 'expired' WHERE id = ${invite.id}` - return "expired" - } - await tx` - UPDATE organization_invites - SET status = 'declined', user_id = ${args.userId} - WHERE id = ${invite.id} - ` - await writeHostAudit(tx, { - actorId: args.userId, - action: "org.invite_declined", - target: `organization:${invite.organization_id}`, - meta: { inviteId: invite.id }, - }) - return "declined" - }) - }, - - async acceptInviteTx(args: { - by: { tokenHash: string } | { inviteId: string } - userId: string - now: Date - }): Promise { - const byToken = "tokenHash" in args.by - return sql.begin(async (tx): Promise => { - const found = byToken - ? await tx<{ id: string; organization_id: string }[]>` - SELECT id, organization_id FROM organization_invites - WHERE token_hash = ${(args.by as { tokenHash: string }).tokenHash} - LIMIT 1 - ` - : await tx<{ id: string; organization_id: string }[]>` - SELECT id, organization_id FROM organization_invites - WHERE id = ${(args.by as { inviteId: string }).inviteId} - LIMIT 1 - ` - const hit = found[0] - if (hit === undefined) return { kind: "invalid" } - // Lock order: organizations -> organization_members -> organization_invites. - const org = await tx<{ id: string; suspended: boolean }[]>` - SELECT id, (suspended_at IS NOT NULL) AS suspended FROM organizations - WHERE id = ${hit.organization_id} AND deleted_at IS NULL - LIMIT 1 FOR UPDATE - ` - const orgRow = org[0] - if (orgRow === undefined) return { kind: "invalid" } - const invites = await tx< - { - id: string - email: string | null - user_id: string | null - role: OrganizationInviteRole - status: OrganizationInviteStatus - expires_at: Date - invited_by: string | null - }[] - >` - SELECT id, email, user_id, role, status, expires_at, invited_by FROM organization_invites - WHERE id = ${hit.id} - LIMIT 1 FOR UPDATE - ` - const invite = invites[0] - if (invite === undefined || invite.status !== "pending") return { kind: "invalid" } - // The invite is a claim on the account that signs in with the invited address (verified), the - // same rule cleanup_team_invites applies - never on whoever holds the link. The id-addressed - // path has no token to prove anything, so the seat itself is the claim: the row must name this - // account, or carry an address this account has verified. This is decided BEFORE expiry, so a - // caller holding someone else's invite id never learns that the invite exists. - const addressed = - invite.user_id !== null && invite.user_id === args.userId - ? true - : invite.email === null - ? false - : ( - await tx<{ one: number }[]>` - SELECT 1 AS one FROM users - WHERE id = ${args.userId} - AND email = ${invite.email} - AND email_verified = true - AND deleted_at IS NULL - LIMIT 1 - ` - ).length > 0 - if (byToken ? invite.email !== null && !addressed : !addressed) { - return { kind: "wrong_recipient" } - } - if (invite.expires_at.getTime() <= args.now.getTime()) { - await tx`UPDATE organization_invites SET status = 'expired' WHERE id = ${invite.id}` - return { kind: "expired" } - } - const revocation = inviterRevocationReason( - await inviterStandingIn(tx, orgRow.id, invite.invited_by), - ) - if (revocation !== null) { - await tx` - UPDATE organization_invites - SET status = 'revoked', revoked_at = ${args.now} - WHERE id = ${invite.id} - ` - await writeHostAudit(tx, { - actorId: args.userId, - action: "org.invite_revoked", - target: `organization:${orgRow.id}`, - meta: { inviteId: invite.id, reason: revocation }, - }) - return { kind: "invalid" } - } - if (orgRow.suspended) return { kind: "suspended" } - const inserted = await tx<{ user_id: string }[]>` - INSERT INTO organization_members (organization_id, user_id, role, joined_at) - VALUES (${orgRow.id}, ${args.userId}, ${invite.role}, ${args.now}) - ON CONFLICT (organization_id, user_id) DO NOTHING - RETURNING user_id - ` - const alreadyMember = inserted.length === 0 - // An existing member keeps the role they already hold: accepting an invite never upgrades - // (or downgrades) a seat, it only closes the invite. - let role: OrganizationMemberRole = invite.role - if (alreadyMember) { - const seated = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${orgRow.id} AND user_id = ${args.userId} - LIMIT 1 - ` - role = seated[0]?.role ?? invite.role - } - await tx` - UPDATE organization_invites - SET status = 'accepted', accepted_at = ${args.now}, user_id = ${args.userId} - WHERE id = ${invite.id} - ` - await writeHostAudit(tx, { - actorId: args.userId, - action: "org.invite_accepted", - target: `organization:${orgRow.id}`, - meta: { inviteId: invite.id, role, alreadyMember }, - }) - return { - kind: "accepted", - organizationId: orgRow.id, - role, - alreadyMember, - } - }) - }, - - async decideVerificationTx( - args: DecideOrgVerificationArgs, - ): Promise { - return sql.begin(async (tx) => { - const org = await tx<{ id: string }[]>` - SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE - ` - if (org.length === 0) return "not_found" - const application = await tx< - { id: string; kind: OrgVerificationKind; ein_number: string | null }[] - >` - SELECT id, kind, ein_number FROM org_verifications - WHERE organization_id = ${args.organizationId} AND status = 'pending' - ORDER BY submitted_at DESC, id DESC - LIMIT 1 - FOR UPDATE - ` - const open = application[0] - if (open === undefined) return "no_application" - const grantedKind = args.decision === "verified" ? (args.kind ?? open.kind) : open.kind - await tx` - UPDATE org_verifications - SET status = ${args.decision}, - kind = ${grantedKind}, - rejection_reason = ${args.decision === "rejected" ? args.reason : null}, - reviewed_by = ${args.reviewedBy}, - reviewed_at = ${args.now} - WHERE id = ${open.id} - ` - await tx` - UPDATE organizations - SET verified_status = ${args.decision}, - verified_kind = ${args.decision === "verified" ? grantedKind : null}, - verified_at = ${args.decision === "verified" ? args.now : null}, - updated_at = ${args.now} - WHERE id = ${args.organizationId} - ` - await writeHostAudit(tx, { - actorId: args.reviewedBy, - action: - args.decision === "verified" - ? "org.verification_verified" - : "org.verification_rejected", - target: `organization:${args.organizationId}`, - meta: { kind: grantedKind, reason: args.reason }, - }) - return "decided" - }) - }, - - async scrubDecidedEins(before: Date, limit: number): Promise { - const rows = await sql<{ id: string }[]>` - UPDATE org_verifications SET ein_number = NULL, ein_scrubbed_at = now() - WHERE id IN ( - SELECT id FROM org_verifications - WHERE ein_number IS NOT NULL - AND ein_scrubbed_at IS NULL - AND reviewed_at IS NOT NULL - AND reviewed_at < ${before} - ORDER BY reviewed_at ASC - LIMIT ${limit} - ) - RETURNING id - ` - return rows.length - }, - } -} - -interface AdminVerificationRowSelect { - id: string - organization_id: string - slug: string - name: string - status: OrgVerificationStatus - kind: OrgVerificationKind - ein_last4: string | null - documents: { mediaId?: string }[] | null - note: string | null - submitted_at: Date | null - reviewed_at: Date | null - rejection_reason: string | null - submitted_by_id: string | null - submitted_by_name: string | null - submitted_by_handle: string | null - submitted_by_joined: Date | null - reviewed_by_id: string | null - reviewed_by_name: string | null - reviewed_by_handle: string | null - reviewed_by_joined: Date | null -} - -function adminVerificationColumns(sql: Queryable) { - return sql` - v.id, - v.organization_id, - o.slug, - o.name, - v.status, - v.kind, - right(v.ein_number, 4) AS ein_last4, - v.documents, - v.note, - v.submitted_at, - v.reviewed_at, - v.rejection_reason, - su.id AS submitted_by_id, - su.display_name AS submitted_by_name, - su.handle AS submitted_by_handle, - su.created_at AS submitted_by_joined, - ru.id AS reviewed_by_id, - ru.display_name AS reviewed_by_name, - ru.handle AS reviewed_by_handle, - ru.created_at AS reviewed_by_joined - ` -} - -function toAdminVerificationRecord(row: AdminVerificationRowSelect): AdminOrgVerificationRecord { - return { - id: row.id, - organizationId: row.organization_id, - slug: row.slug, - name: row.name, - status: row.status, - kind: row.kind, - einLast4: row.ein_last4, - documentMediaIds: documentMediaIdsOf(row.documents), - note: row.note, - submittedAt: row.submitted_at, - reviewedAt: row.reviewed_at, - rejectionReason: row.rejection_reason, - submittedBy: - row.submitted_by_id === null - ? null - : { - id: row.submitted_by_id, - name: row.submitted_by_name ?? "Unknown", - handle: row.submitted_by_handle ?? "", - joined: row.submitted_by_joined ?? new Date(0), - }, - reviewedBy: - row.reviewed_by_id === null - ? null - : { - id: row.reviewed_by_id, - name: row.reviewed_by_name ?? "Unknown", - handle: row.reviewed_by_handle ?? "", - joined: row.reviewed_by_joined ?? new Date(0), - }, - } -} - -/** The caller already holds the organizations row lock, so this keeps the documented lock order. */ -async function revokeInvitesByInviterInTx( - tx: Queryable, - args: { - organizationId: string - inviterId: string - actorId: string - reason: InviterRevocationReason - }, -): Promise { - await tx` - WITH revoked AS ( - UPDATE organization_invites - SET status = 'revoked', revoked_at = now() - WHERE organization_id = ${args.organizationId} - AND invited_by = ${args.inviterId} - AND status = 'pending' - RETURNING id - ) - INSERT INTO audit_log (actor_id, action, target, meta) - SELECT ${args.actorId}::uuid, 'org.invite_revoked', ${`organization:${args.organizationId}`}, - jsonb_build_object('inviteId', id, 'reason', ${args.reason}::text) - FROM revoked - ` -} - -/** - * Every role change and removal takes the organizations row lock first, so re-reading the actor's - * role under it sees the latest committed seat. FOR SHARE also holds off an account erasure, which - * deletes seats without the organization lock. - */ -async function lockOrgForActorIn( - tx: Queryable, - organizationId: string, - actorId: string, -): Promise { - await tx` - SELECT id FROM organizations WHERE id = ${organizationId} LIMIT 1 FOR UPDATE - ` - const rows = await tx<{ role: OrganizationMemberRole }[]>` - SELECT role FROM organization_members - WHERE organization_id = ${organizationId} AND user_id = ${actorId} - LIMIT 1 - FOR SHARE - ` - return canManageOrgMembers(rows[0]?.role ?? null) -} - -async function inviterStandingIn( - tx: Queryable, - organizationId: string, - inviterId: string | null, -): Promise { - if (inviterId === null) return null - const rows = await tx<{ role: OrganizationMemberRole | null; deleted: boolean }[]>` - SELECT m.role, (u.deleted_at IS NOT NULL) AS deleted - FROM users u - LEFT JOIN organization_members m ON m.organization_id = ${organizationId} AND m.user_id = u.id - WHERE u.id = ${inviterId} - LIMIT 1 - ` - const row = rows[0] - return row === undefined ? null : { role: row.role, deleted: row.deleted } -} - -async function expireInvitesInTx(tag: Queryable, organizationId: string, now: Date): Promise { - await tag` - UPDATE organization_invites SET status = 'expired' - WHERE organization_id = ${organizationId} AND status = 'pending' AND expires_at <= ${now} - ` -} - -interface InviteRowSelect { - id: string - organization_id: string - email: string | null - role: OrganizationInviteRole - status: OrganizationInviteStatus - created_at: Date - expires_at: Date - user_id: string | null - user_name: string | null - user_handle: string | null - user_bio: string | null - user_avatar_url: string | null - invited_by_id: string | null - invited_by_name: string | null - invited_by_handle: string | null - invited_by_bio: string | null - invited_by_avatar_url: string | null -} - -function inviteColumns(sql: Queryable) { - return sql` - i.id, - i.organization_id, - i.email, - i.role, - i.status, - i.created_at, - i.expires_at, - au.id AS user_id, - au.display_name AS user_name, - au.handle AS user_handle, - au.bio AS user_bio, - au.avatar_url AS user_avatar_url, - iu.id AS invited_by_id, - iu.display_name AS invited_by_name, - iu.handle AS invited_by_handle, - iu.bio AS invited_by_bio, - iu.avatar_url AS invited_by_avatar_url - ` -} - -function toInviteRecord(row: InviteRowSelect): OrganizationInviteRecord { return { - id: row.id, - organizationId: row.organization_id, - email: row.email, - user: - row.user_id === null - ? null - : { - id: row.user_id, - displayName: row.user_name ?? "Unknown", - handle: row.user_handle, - bio: row.user_bio, - avatarUrl: row.user_avatar_url, - }, - role: row.role, - status: row.status, - invitedBy: - row.invited_by_id === null - ? null - : { - id: row.invited_by_id, - displayName: row.invited_by_name ?? "Unknown", - handle: row.invited_by_handle, - bio: row.invited_by_bio, - avatarUrl: row.invited_by_avatar_url, - }, - createdAt: row.created_at, - expiresAt: row.expires_at, + ...makeOrganizationProfileMethods(sql), + ...makeOrganizationMemberMethods(sql), + ...makeOrganizationVerificationMethods(sql), + ...makeOrganizationAdminMethods(sql), + ...makeOrganizationInviteMethods(sql), } } - -async function readInvite( - tag: Queryable, - inviteId: string, -): Promise { - const rows = await tag` - SELECT ${inviteColumns(tag)} - FROM organization_invites i - LEFT JOIN users iu ON iu.id = i.invited_by - LEFT JOIN users au ON au.id = i.user_id - WHERE i.id = ${inviteId} - LIMIT 1 - ` - return rows[0] ? toInviteRecord(rows[0]) : null -} diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index 79b12007..e4e9dd3d 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -55,6 +55,12 @@ import { } from "./organization-repository.types.js" import { ORG_INVITE_CAP_MESSAGE } from "./organization-repository.types.js" +const SEED_DISPLAY_NAME = "Member" + +const SEED_CREATED_AT = "2025-01-01T00:00:00.000Z" + +const OPERATOR_VERIFIED_NOTE = "Created verified by an operator." + interface StoredOrganization { id: string slug: string @@ -126,6 +132,23 @@ interface StoredPerson { deletedAt: Date | null } +/** Cursors here are `|` of the last row served; the page resumes right after it. */ +function pageAfterCursor( + rows: readonly T[], + cursor: string | null, + limit: number, + keyOf: (row: T) => string, +): { page: T[]; nextCursor: string | null } { + const start = cursor === null ? 0 : rows.findIndex((row) => keyOf(row) === cursor) + 1 + const page = rows.slice(start, start + limit) + const next = rows.length > start + limit ? page[page.length - 1] : undefined + return { page, nextCursor: next === undefined ? null : keyOf(next) } +} + +function memberCursorKey(member: StoredMember): string { + return `${member.joinedAt.toISOString()}|${member.userId}` +} + export class InMemoryOrganizationRepository implements OrganizationRepository { readonly organizations = new Map() readonly members: StoredMember[] = [] @@ -144,13 +167,13 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { seedUser(over: Partial = {}): StoredPerson { const person: StoredPerson = { id: over.id ?? randomUUID(), - displayName: over.displayName ?? "Member", + displayName: over.displayName ?? SEED_DISPLAY_NAME, handle: over.handle ?? null, email: over.email ?? null, emailVerified: over.emailVerified ?? true, bio: over.bio ?? null, avatarUrl: over.avatarUrl ?? null, - createdAt: over.createdAt ?? new Date("2025-01-01T00:00:00.000Z"), + createdAt: over.createdAt ?? new Date(SEED_CREATED_AT), deletedAt: over.deletedAt ?? null, } this.users.set(person.id, person) @@ -261,7 +284,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { einNumber: null, einScrubbedAt: null, documentMediaIds: [], - note: "Created verified by an operator.", + note: OPERATOR_VERIFIED_NOTE, rejectionReason: null, submittedBy: args.createdBy, submittedAt: args.now, @@ -354,48 +377,17 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve(member?.role ?? null) } - listMembers(args: { - organizationId: string - cursor: string | null - limit: number - }): Promise<{ items: OrganizationMemberRecord[]; nextCursor: string | null }> { - const all = this.members - .filter((m) => m.organizationId === args.organizationId) + private membersByJoinOrder(organizationId: string): StoredMember[] { + return this.members + .filter((m) => m.organizationId === organizationId) .sort( (a, b) => a.joinedAt.getTime() - b.joinedAt.getTime() || a.userId.localeCompare(b.userId), ) - const start = - args.cursor === null - ? 0 - : all.findIndex((m) => `${m.joinedAt.toISOString()}|${m.userId}` === args.cursor) + 1 - const page = all.slice(start, start + args.limit) - const next = all.length > start + args.limit ? page[page.length - 1] : undefined - return Promise.resolve({ - items: page.map((m) => { - const person = this.personOf(m.userId) - return { - person: { - id: person.id, - displayName: person.displayName, - handle: person.handle, - bio: person.bio, - avatarUrl: person.avatarUrl, - }, - role: m.role, - joinedAt: m.joinedAt, - } - }), - nextCursor: next === undefined ? null : `${next.joinedAt.toISOString()}|${next.userId}`, - }) } - findMember(organizationId: string, userId: string): Promise { - const member = this.members.find( - (m) => m.organizationId === organizationId && m.userId === userId, - ) - if (member === undefined) return Promise.resolve(null) + private toMemberRecord(member: StoredMember): OrganizationMemberRecord { const person = this.personOf(member.userId) - return Promise.resolve({ + return { person: { id: person.id, displayName: person.displayName, @@ -405,7 +397,28 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { }, role: member.role, joinedAt: member.joinedAt, - }) + } + } + + listMembers(args: { + organizationId: string + cursor: string | null + limit: number + }): Promise<{ items: OrganizationMemberRecord[]; nextCursor: string | null }> { + const { page, nextCursor } = pageAfterCursor( + this.membersByJoinOrder(args.organizationId), + args.cursor, + args.limit, + memberCursorKey, + ) + return Promise.resolve({ items: page.map((m) => this.toMemberRecord(m)), nextCursor }) + } + + findMember(organizationId: string, userId: string): Promise { + const member = this.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ) + return Promise.resolve(member === undefined ? null : this.toMemberRecord(member)) } findOwner(organizationId: string): Promise { @@ -631,8 +644,6 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { private toAdminRecord(row: StoredVerification): AdminOrgVerificationRecord { const org = this.organizations.get(row.organizationId) - const submitter = row.submittedBy === null ? null : this.personOf(row.submittedBy) - const reviewer = row.reviewedBy === null ? null : this.personOf(row.reviewedBy) return { id: row.id, organizationId: row.organizationId, @@ -646,24 +657,8 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { submittedAt: row.submittedAt, reviewedAt: row.reviewedAt, rejectionReason: row.rejectionReason, - submittedBy: - submitter === null - ? null - : { - id: submitter.id, - name: submitter.displayName, - handle: submitter.handle ?? "", - joined: submitter.createdAt, - }, - reviewedBy: - reviewer === null - ? null - : { - id: reviewer.id, - name: reviewer.displayName, - handle: reviewer.handle ?? "", - joined: reviewer.createdAt, - }, + submittedBy: row.submittedBy === null ? null : this.actorOf(row.submittedBy), + reviewedBy: row.reviewedBy === null ? null : this.actorOf(row.reviewedBy), } } @@ -676,15 +671,15 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { .filter((v) => query.status === undefined || v.status === query.status) .filter((v) => query.kind === undefined || v.kind === query.kind) .sort((a, b) => b.submittedAt.getTime() - a.submittedAt.getTime()) - const start = - query.cursor === null - ? 0 - : rows.findIndex((v) => `${v.submittedAt.toISOString()}|${v.id}` === query.cursor) + 1 - const page = rows.slice(start, start + query.limit) - const next = rows.length > start + query.limit ? page[page.length - 1] : undefined + const { page, nextCursor } = pageAfterCursor( + rows, + query.cursor, + query.limit, + (v) => `${v.submittedAt.toISOString()}|${v.id}`, + ) return Promise.resolve({ items: page.map((row) => this.toAdminRecord(row)), - nextCursor: next === undefined ? null : `${next.submittedAt.toISOString()}|${next.id}`, + nextCursor, pendingCount: this.verifications.filter((v) => v.status === "pending").length, }) } @@ -739,12 +734,12 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { .filter((o) => query.kind === undefined || o.verifiedKind === query.kind) .filter((o) => query.suspended === undefined || (o.suspendedAt !== null) === query.suspended) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) - const start = - query.cursor === null - ? 0 - : rows.findIndex((o) => `${o.createdAt.toISOString()}|${o.id}` === query.cursor) + 1 - const page = rows.slice(start, start + query.limit) - const next = rows.length > start + query.limit ? page[page.length - 1] : undefined + const { page, nextCursor } = pageAfterCursor( + rows, + query.cursor, + query.limit, + (o) => `${o.createdAt.toISOString()}|${o.id}`, + ) const counts: AdminOrganizationCounts | null = query.cursor === null ? { @@ -756,7 +751,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { : null return Promise.resolve({ items: page.map((o) => this.toAdminOrganizationRecord(o)), - nextCursor: next === undefined ? null : `${next.createdAt.toISOString()}|${next.id}`, + nextCursor, counts, }) } @@ -781,24 +776,19 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { cursor: string | null limit: number }): Promise<{ items: AdminOrgMemberRecord[]; nextCursor: string | null }> { - const all = this.members - .filter((m) => m.organizationId === args.organizationId) - .sort( - (a, b) => a.joinedAt.getTime() - b.joinedAt.getTime() || a.userId.localeCompare(b.userId), - ) - const start = - args.cursor === null - ? 0 - : all.findIndex((m) => `${m.joinedAt.toISOString()}|${m.userId}` === args.cursor) + 1 - const page = all.slice(start, start + args.limit) - const next = all.length > start + args.limit ? page[page.length - 1] : undefined + const { page, nextCursor } = pageAfterCursor( + this.membersByJoinOrder(args.organizationId), + args.cursor, + args.limit, + memberCursorKey, + ) return Promise.resolve({ items: page.map((m) => ({ user: this.actorOf(m.userId), role: m.role, joinedAt: m.joinedAt, })), - nextCursor: next === undefined ? null : `${next.joinedAt.toISOString()}|${next.userId}`, + nextCursor, }) } @@ -811,6 +801,18 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return owner.userId } + private auditOwnershipTransfer( + args: { organizationId: string; userId: string; actorId: string; reason: string }, + previousOwner: string | null, + ): void { + this.audits.push({ + actorId: args.actorId, + action: "org.ownership_transferred", + target: `organization:${args.organizationId}`, + meta: { from: previousOwner, to: args.userId, reason: args.reason }, + }) + } + adminAddMemberTx(args: AdminAddMemberArgs): Promise { const org = this.organizations.get(args.organizationId) if (org === undefined || org.deletedAt !== null) return Promise.resolve("not_found") @@ -833,14 +835,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { target: `organization:${args.organizationId}`, meta: { targetUserId: args.userId, role: args.role, reason: args.reason }, }) - if (args.role === "owner") { - this.audits.push({ - actorId: args.actorId, - action: "org.ownership_transferred", - target: `organization:${args.organizationId}`, - meta: { from: previousOwner, to: args.userId, reason: args.reason }, - }) - } + if (args.role === "owner") this.auditOwnershipTransfer(args, previousOwner) return Promise.resolve("added") } @@ -863,14 +858,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { if (roleChangeWithdrawsInvites(from, args.role)) { this.revokeInvitesByInviter(args.organizationId, args.userId, args.actorId, "inviter_demoted") } - if (args.role === "owner") { - this.audits.push({ - actorId: args.actorId, - action: "org.ownership_transferred", - target: `organization:${args.organizationId}`, - meta: { from: previousOwner, to: args.userId, reason: args.reason }, - }) - } + if (args.role === "owner") this.auditOwnershipTransfer(args, previousOwner) return Promise.resolve("updated") } @@ -937,15 +925,17 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { } } + private openInviteCount(organizationId: string, now: Date): number { + return this.invites.filter( + (i) => + i.organizationId === organizationId && + i.status === "pending" && + i.expiresAt.getTime() > now.getTime(), + ).length + } + countPendingInvites(organizationId: string, now: Date): Promise { - return Promise.resolve( - this.invites.filter( - (i) => - i.organizationId === organizationId && - i.status === "pending" && - i.expiresAt.getTime() > now.getTime(), - ).length, - ) + return Promise.resolve(this.openInviteCount(organizationId, now)) } createInviteTx(args: CreateOrganizationInviteArgs): Promise { @@ -953,13 +943,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve({ kind: "forbidden" }) } this.expireInvites(args.organizationId, args.now) - const pending = this.invites.filter( - (i) => - i.organizationId === args.organizationId && - i.status === "pending" && - i.expiresAt.getTime() > args.now.getTime(), - ).length - if (pending >= MAX_ORG_INVITES_PER_ORG) { + if (this.openInviteCount(args.organizationId, args.now) >= MAX_ORG_INVITES_PER_ORG) { return Promise.reject(AppError.conflict(ORG_INVITE_CAP_MESSAGE)) } const email = args.email.toLowerCase() @@ -1122,10 +1106,11 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { userId: string now: Date }): Promise { - const byToken = "tokenHash" in args.by + const by = args.by + const byToken = "tokenHash" in by const invite = byToken - ? this.invites.find((i) => i.tokenHash === (args.by as { tokenHash: string }).tokenHash) - : this.invites.find((i) => i.id === (args.by as { inviteId: string }).inviteId) + ? this.invites.find((i) => i.tokenHash === by.tokenHash) + : this.invites.find((i) => i.id === by.inviteId) if (invite === undefined || invite.status !== "pending") return Promise.resolve({ kind: "invalid" }) const org = this.organizations.get(invite.organizationId) diff --git a/services/api/src/services/host/organization-rows.drizzle.ts b/services/api/src/services/host/organization-rows.drizzle.ts new file mode 100644 index 00000000..525688ea --- /dev/null +++ b/services/api/src/services/host/organization-rows.drizzle.ts @@ -0,0 +1,188 @@ +import type { + OrganizationMemberRole, + OrgVerificationKind, + OrgVerificationStatus, + SocialLinks, +} from "@civfix/shared" +import type { Queryable } from "../../db/client.js" +import type { CleanupPersonView } from "../cleanup-repository.types.js" +import { publicServedKeyExpr } from "../media-served-key.js" +import type { + AdminActorView, + OrganizationBaseRecord, + OrganizationRecord, + OrgHoursTotals, +} from "./organization-repository.types.js" + +export const UNKNOWN_PERSON_NAME = "Unknown" + +const NO_ORG_HOURS: OrgHoursTotals = { volunteerHours: 0, volunteerCount: 0 } + +export interface OrganizationRowSelect { + id: string + slug: string + name: string + description: string | null + website_url: string | null + donation_url: string | null + logo_media_id: string | null + logo_key: string | null + social_links: SocialLinks | null + verified_status: OrgVerificationStatus + verified_kind: OrgVerificationKind | null + verified_at: Date | null + created_by: string | null + created_at: Date + updated_at: Date + deleted_at: Date | null + suspended_at: Date | null + suspended_reason: string | null + member_count: number + event_count: number + my_role: OrganizationMemberRole | null +} + +interface OrgHoursRowSelect { + organization_id: string + volunteer_hours: number + volunteer_count: number +} + +export async function readOrgHours( + tag: Queryable, + organizationIds: readonly string[], +): Promise> { + const totals = new Map() + if (organizationIds.length === 0) return totals + const rows = await tag` + SELECT + c.organization_id, + COALESCE(sum(vh.hours), 0)::float8 AS volunteer_hours, + count(DISTINCT vh.user_id)::int AS volunteer_count + FROM volunteer_hours vh + JOIN cleanups c ON c.id = vh.cleanup_id + JOIN users u ON u.id = vh.user_id + WHERE c.organization_id = ANY(${[...organizationIds]}::uuid[]) + AND vh.source = 'event' + AND vh.voided_at IS NULL + AND u.show_volunteer_hours IS NOT FALSE + GROUP BY c.organization_id + ` + for (const row of rows) { + totals.set(row.organization_id, { + volunteerHours: Number(row.volunteer_hours), + volunteerCount: Number(row.volunteer_count), + }) + } + return totals +} + +export function toOrganizationBaseRecord(row: OrganizationRowSelect): OrganizationBaseRecord { + return { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + websiteUrl: row.website_url, + donationUrl: row.donation_url, + logoMediaId: row.logo_media_id, + logoKey: row.logo_key, + socialLinks: row.social_links, + verifiedStatus: row.verified_status, + verifiedKind: row.verified_kind, + verifiedAt: row.verified_at, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + suspendedAt: row.suspended_at, + suspendedReason: row.suspended_reason, + memberCount: Number(row.member_count), + eventCount: Number(row.event_count), + myRole: row.my_role, + } +} + +export function toOrganizationRecord( + row: OrganizationRowSelect, + hours: OrgHoursTotals | undefined, +): OrganizationRecord { + return { ...toOrganizationBaseRecord(row), ...(hours ?? NO_ORG_HOURS) } +} + +/** Assumes the logo join `LEFT JOIN media_assets am ON am.id = o.logo_media_id` is in the FROM. */ +export function organizationColumns(sql: Queryable, viewerId: string | null) { + return sql` + o.id, + o.slug, + o.name, + o.description, + o.website_url, + o.donation_url, + o.logo_media_id, + ${publicServedKeyExpr(sql, "am")} AS logo_key, + o.social_links, + o.verified_status, + o.verified_kind, + o.verified_at, + o.created_by, + o.created_at, + o.updated_at, + o.deleted_at, + o.suspended_at, + o.suspended_reason, + (SELECT count(*)::int FROM organization_members om WHERE om.organization_id = o.id) + AS member_count, + ( + SELECT count(*)::int FROM cleanups c + WHERE c.organization_id = o.id + AND ( + (c.visibility = 'public' AND c.status <> 'cancelled') + OR EXISTS ( + SELECT 1 FROM organization_members vm + WHERE vm.organization_id = o.id AND vm.user_id = ${viewerId}::uuid + ) + ) + ) AS event_count, + ( + SELECT om.role FROM organization_members om + WHERE om.organization_id = o.id AND om.user_id = ${viewerId}::uuid + LIMIT 1 + ) AS my_role + ` +} + +export function adminActorOf( + id: string | null, + name: string | null, + handle: string | null, + joined: Date | null, +): AdminActorView | null { + if (id === null) return null + return { + id, + name: name ?? UNKNOWN_PERSON_NAME, + handle: handle ?? "", + joined: joined ?? new Date(0), + } +} + +export function personViewOf( + person: { + id: string | null + name: string | null + handle: string | null + bio: string | null + avatarUrl: string | null + }, + fallbackName: string, +): CleanupPersonView | null { + if (person.id === null) return null + return { + id: person.id, + displayName: person.name ?? fallbackName, + handle: person.handle, + bio: person.bio, + avatarUrl: person.avatarUrl, + } +} diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index 4411e92e..43de3959 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -47,36 +47,65 @@ import type { UpdateOrganizationPatch, } from "./organization-repository.types.js" +const HOUR_SEC = 60 * 60 +const DAY_SEC = 24 * HOUR_SEC +const DAY_MS = DAY_SEC * 1000 + export const ORGS_CREATED_PER_DAY = 5 -const ORG_CREATE_WINDOW_SEC = 24 * 60 * 60 +const ORG_CREATE_WINDOW_SEC = DAY_SEC +const ORG_CREATE_COUNTER_KEY = "org:create" -export const ORG_INVITES_PER_HOUR = 20 -const ORG_INVITE_WINDOW_SEC = 60 * 60 +const ORG_INVITES_PER_HOUR = 20 +const ORG_INVITE_WINDOW_SEC = HOUR_SEC +const ORG_INVITE_COUNTER_KEY = "org:invites" -export const ORG_VERIFICATIONS_PER_DAY = 3 -const ORG_VERIFICATION_WINDOW_SEC = 24 * 60 * 60 +const ORG_VERIFICATIONS_PER_DAY = 3 +const ORG_VERIFICATION_WINDOW_SEC = DAY_SEC +const ORG_VERIFY_COUNTER_KEY = "org:verify" -export const MY_ORGANIZATIONS_CAP = 50 +const MY_ORGANIZATIONS_CAP = 50 export const ORG_MEMBERS_DEFAULT_LIMIT = 25 -export const EIN_RETENTION_DAYS = 90 +const EIN_RETENTION_DAYS = 90 /** Same lifetime as event team invites. */ -export const ORG_INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000 +const ORG_INVITE_TTL_DAYS = 14 +const ORG_INVITE_TTL_MS = ORG_INVITE_TTL_DAYS * DAY_MS -export const ORG_INVITE_TOKEN_BYTES = 32 +const ORG_INVITE_TOKEN_BYTES = 32 -export const ORG_INVITE_LIST_CAP = 100 +const ORG_INVITE_LIST_CAP = 100 /** The invitee's own inbox is a short triage list, not a feed. */ -export const MY_ORG_INVITES_CAP = 20 +const MY_ORG_INVITES_CAP = 20 + +const ACTION_EMAIL_TEMPLATE = "action" + +const TRAILING_SLASHES = /\/+$/ + +const ORG_INVITE_ACCEPT_PATH = "/manage/org-invites/accept" + +/** Where the in-app invite notice sends the invitee: the dashboard holds the invite inbox. */ +const ORG_INVITE_INBOX_PATH = "/dashboard" + +function orgProfilePath(slug: string): string { + return `/orgs/${slug}` +} + +function orgVerificationPath(organizationId: string): string { + return `/manage/orgs/${organizationId}/verification` +} + +function memberRoleArticle(role: "admin" | "member"): string { + return role === "admin" ? "an admin" : "a member" +} export const ADMIN_ORGS_DEFAULT_LIMIT = 25 export const ORG_LAST_ADMIN_CODE = "ORG_LAST_ADMIN" -export function lastAdminError(): AppError { +function lastAdminError(): AppError { return AppError.validation( { userId: ORG_LAST_ADMIN_CODE }, "An organization needs at least one admin.", @@ -124,7 +153,7 @@ export interface OrganizationServiceDeps { * OrgSlugSchema already trims + lowercases at the HTTP edge; this repeats it in the service so an internal * caller (admin tooling, seeds, tests) cannot store "Ballona-Creek" next to a lookup for "ballona-creek". */ -export function normalizeOrgSlug(slug: string): string { +function normalizeOrgSlug(slug: string): string { return slug.trim().toLowerCase() } @@ -147,7 +176,7 @@ export function orgInviteEmailVars(args: { role: "admin" | "member" link: string }): Record { - const roleLabel = args.role === "admin" ? "an admin" : "a member" + const roleLabel = memberRoleArticle(args.role) return { subject: `${args.inviterName} invited you to join ${args.orgName} on civfix`, paragraphs: [ @@ -156,7 +185,7 @@ export function orgInviteEmailVars(args: { ], ctaUrl: args.link, ctaLabel: "Accept the invitation", - note: "The invitation expires in 14 days. If you weren't expecting it, you can ignore this email.", + note: `The invitation expires in ${ORG_INVITE_TTL_DAYS} days. If you weren't expecting it, you can ignore this email.`, } } @@ -191,6 +220,13 @@ export function orgVerificationDecisionEmailVars(args: { } } +export interface InviteMemberResult { + ok: true + member: OrganizationMemberDTO | null + invited: boolean + invite?: OrganizationInviteDTO | null +} + export interface OrganizationService { createOrganization(input: CreateOrganizationRequest, actorId: string): Promise listMyOrganizations(actorId: string): Promise @@ -209,12 +245,7 @@ export interface OrganizationService { id: string, actorId: string, input: Omit, - ): Promise<{ - ok: true - member: OrganizationMemberDTO | null - invited: boolean - invite?: OrganizationInviteDTO | null - }> + ): Promise listInvites(id: string, actorId: string): Promise<{ items: OrganizationInviteDTO[] }> revokeInvite(id: string, actorId: string, inviteId: string): Promise<{ ok: true }> acceptInvite(userId: string, token: string): Promise @@ -290,10 +321,7 @@ function notFoundOrganization(): never { throw AppError.notFound("Organization not found") } -export function toOrganizationDTO( - record: OrganizationRecord, - logoUrl: string | null, -): OrganizationDTO { +function toOrganizationDTO(record: OrganizationRecord, logoUrl: string | null): OrganizationDTO { return { id: record.id, slug: record.slug, @@ -396,7 +424,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza const now = deps.now ?? (() => new Date()) const newId = deps.newId ?? (() => randomUUID()) const newToken = deps.newToken ?? (() => generateToken(ORG_INVITE_TOKEN_BYTES)) - const webBase = () => deps.webOrigin.replace(/\/+$/, "") + const webBase = () => deps.webOrigin.replace(TRAILING_SLASHES, "") async function logoUrlOf(record: OrganizationBaseRecord): Promise { if (record.logoKey === null || deps.presignLogo === undefined) return null @@ -550,8 +578,8 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza body: role === "owner" ? "You can manage its profile, team and events on civfix." - : `You're ${role === "admin" ? "an admin" : "a member"} of the organization on civfix.`, - link: `/orgs/${org.slug}`, + : `You're ${memberRoleArticle(role)} of the organization on civfix.`, + link: orgProfilePath(org.slug), }) } catch (err) { deps.logger?.warn?.( @@ -575,7 +603,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza token: string, ): Promise { if (deps.mailer === undefined) return - const link = `${webBase()}/manage/org-invites/accept#token=${encodeURIComponent(token)}` + const link = `${webBase()}${ORG_INVITE_ACCEPT_PATH}#token=${encodeURIComponent(token)}` let inviterName = `A member of ${org.name}` try { const inviter = await deps.repo.findMember(org.id, inviterId) @@ -589,7 +617,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza try { await deps.mailer.sendTransactional( email, - "action", + ACTION_EMAIL_TEMPLATE, orgInviteEmailVars({ inviterName, orgName: org.name, role, link }), ) } catch (err) { @@ -608,8 +636,8 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza await deps.notifier.createNotification(userId, { type: "org_invite", title: `You've been invited to join ${org.name}`, - body: `Open your event dashboard to accept or decline joining as ${role === "admin" ? "an admin" : "a member"}. It expires in 14 days.`, - link: "/dashboard", + body: `Open your event dashboard to accept or decline joining as ${memberRoleArticle(role)}. It expires in ${ORG_INVITE_TTL_DAYS} days.`, + link: ORG_INVITE_INBOX_PATH, }) } catch (err) { deps.logger?.warn?.( @@ -642,15 +670,15 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza } if (owner === null) return const base = webBase() - const orgPath = `/orgs/${org.slug}` - const verifyPath = `/manage/orgs/${org.id}/verification` + const orgPath = orgProfilePath(org.slug) + const verifyPath = orgVerificationPath(org.id) const kindLabel = verificationKindLabel(org.verifiedKind ?? null) const approved = decision === "verified" if (deps.mailer !== undefined && owner.email !== null) { try { await deps.mailer.sendTransactional( owner.email, - "action", + ACTION_EMAIL_TEMPLATE, orgVerificationDecisionEmailVars({ orgName: org.name, kindLabel, @@ -688,6 +716,86 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza } } + /** + * Every EMAIL invite is a pending record (0.41.0, DECISIONS §32), whether or not the address has an + * account: a hashed single-use token, an expiry, an email carrying the accept link, and the + * same `{ member: null, invited: true, invite }` answer with `invite.user` null until accepted. The + * per-org cap is the only refusal, and it is address-independent, so the inviter learns nothing + * about who is behind an address - not from the shape, not from a 409, and not from a second call + * (an open invite is returned again, not rejected). An address that already belongs to a member + * gets the same pending row; accepting closes it as a no-op. + */ + async function inviteByEmail( + organizationId: string, + org: OrganizationRecord, + actorId: string, + input: { email: string; role: "admin" | "member" }, + userId: string | null, + ): Promise { + const email = input.email.toLowerCase() + const token = newToken() + const at = now() + const outcome = await deps.repo.createInviteTx({ + inviteId: newId(), + organizationId, + email, + userId, + role: input.role, + tokenHash: await sha256Hex(token), + invitedBy: actorId, + expiresAt: new Date(at.getTime() + ORG_INVITE_TTL_MS), + now: at, + }) + if (outcome.kind === "forbidden") { + throw AppError.forbidden(hostForbiddenCopy("manage_org_members")) + } + if (outcome.kind === "created") { + await sendInviteEmail(outcome.invite.email ?? email, org, actorId, input.role, token) + if (userId !== null) await notifyInvitedUser(userId, org, input.role) + } + return { ok: true, member: null, invited: true, invite: toInviteDTO(outcome.invite) } + } + + /** + * A HANDLE is a public identifier, so a handle invite seats the account directly and returns the + * member row the contract allows (OrganizationMemberDTO | null); an unknown handle stays a quiet + * no-op. + */ + async function seatByHandle( + organizationId: string, + org: OrganizationRecord, + actorId: string, + role: "admin" | "member", + userId: string | null, + ): Promise { + if (userId === null) { + return { ok: true, member: null, invited: true, invite: null } + } + const outcome = await deps.repo.addMemberTx({ + organizationId, + userId, + role, + actorId, + now: now(), + }) + if (outcome === "forbidden") throw AppError.forbidden(hostForbiddenCopy("manage_org_members")) + if (outcome === "added") await notifyAddedMember(userId, org, role) + const member = await deps.repo.findMember(organizationId, userId) + return { + ok: true, + member: + member === null + ? null + : { + person: toAttendeePersonDTO(member.person, false), + role: member.role, + joinedAt: member.joinedAt.toISOString(), + canRemove: member.role !== "owner" && member.person.id !== actorId, + }, + invited: true, + } + } + return { async createOrganization( input: CreateOrganizationRequest, @@ -696,7 +804,10 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza assertOrgTextClean(input) const slug = normalizeOrgSlug(input.slug) assertSlugAllowed(slug, "slug") - const created = await counters.incr(`org:create:${actorId}`, ORG_CREATE_WINDOW_SEC) + const created = await counters.incr( + `${ORG_CREATE_COUNTER_KEY}:${actorId}`, + ORG_CREATE_WINDOW_SEC, + ) if (created > ORGS_CREATED_PER_DAY) { throw AppError.rateLimited( "You've created the maximum number of organizations for today. Please try again tomorrow.", @@ -795,15 +906,10 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza id: string, actorId: string, input: Omit, - ): Promise<{ - ok: true - member: OrganizationMemberDTO | null - invited: boolean - invite?: OrganizationInviteDTO | null - }> { + ): Promise { const org = await requireOrgCapability(id, actorId, "manage_org_members") assertNotSuspended(org) - const invites = await counters.incr(`org:invites:${id}`, ORG_INVITE_WINDOW_SEC) + const invites = await counters.incr(`${ORG_INVITE_COUNTER_KEY}:${id}`, ORG_INVITE_WINDOW_SEC) if (invites > ORG_INVITES_PER_HOUR) { throw AppError.rateLimited( "This organization has sent too many invitations recently. Please try again later.", @@ -813,66 +919,16 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza identifierKind: input.identifierKind, identifier: input.identifier, }) - // Every EMAIL invite is a pending record (0.41.0, DECISIONS §32), whether or not the address - // has an account: a hashed single-use token, a 14-day expiry, an email carrying the accept - // link, and the same `{ member: null, invited: true, invite }` answer with `invite.user` null - // until accepted. The per-org cap is the only refusal, and it is address-independent, so the - // inviter learns nothing about who is behind an address - not from the shape, not from a 409, - // and not from a second call (an open invite is returned again, not rejected). An address - // that already belongs to a member gets the same pending row; accepting closes it as a no-op. if (input.identifierKind === "email") { - const email = input.identifier.toLowerCase() - const token = newToken() - const at = now() - const outcome = await deps.repo.createInviteTx({ - inviteId: newId(), - organizationId: id, - email, + return inviteByEmail( + id, + org, + actorId, + { email: input.identifier, role: input.role }, userId, - role: input.role, - tokenHash: await sha256Hex(token), - invitedBy: actorId, - expiresAt: new Date(at.getTime() + ORG_INVITE_TTL_MS), - now: at, - }) - if (outcome.kind === "forbidden") { - throw AppError.forbidden(hostForbiddenCopy("manage_org_members")) - } - if (outcome.kind === "created") { - await sendInviteEmail(outcome.invite.email ?? email, org, actorId, input.role, token) - if (userId !== null) await notifyInvitedUser(userId, org, input.role) - } - return { ok: true, member: null, invited: true, invite: toInviteDTO(outcome.invite) } - } - // A HANDLE is a public identifier, so a handle invite seats the account directly and returns - // the member row the contract allows (OrganizationMemberDTO | null); an unknown handle stays a - // quiet no-op. - if (userId === null) { - return { ok: true, member: null, invited: true, invite: null } - } - const outcome = await deps.repo.addMemberTx({ - organizationId: id, - userId, - role: input.role, - actorId, - now: now(), - }) - if (outcome === "forbidden") throw AppError.forbidden(hostForbiddenCopy("manage_org_members")) - if (outcome === "added") await notifyAddedMember(userId, org, input.role) - const member = await deps.repo.findMember(id, userId) - return { - ok: true, - member: - member === null - ? null - : { - person: toAttendeePersonDTO(member.person, false), - role: member.role, - joinedAt: member.joinedAt.toISOString(), - canRemove: member.role !== "owner" && member.person.id !== actorId, - }, - invited: true, + ) } + return seatByHandle(id, org, actorId, input.role, userId) }, async listInvites(id: string, actorId: string): Promise<{ items: OrganizationInviteDTO[] }> { @@ -1010,7 +1066,10 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza if (record.verifiedStatus === "verified") { throw AppError.conflict("This organization is already verified.") } - const applications = await counters.incr(`org:verify:${id}`, ORG_VERIFICATION_WINDOW_SEC) + const applications = await counters.incr( + `${ORG_VERIFY_COUNTER_KEY}:${id}`, + ORG_VERIFICATION_WINDOW_SEC, + ) if (applications > ORG_VERIFICATIONS_PER_DAY) { throw AppError.rateLimited( "This organization has submitted too many verification applications today.", @@ -1301,7 +1360,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza }, scrubDecidedEins(limit: number): Promise { - const cutoff = new Date(now().getTime() - EIN_RETENTION_DAYS * 24 * 60 * 60 * 1000) + const cutoff = new Date(now().getTime() - EIN_RETENTION_DAYS * DAY_MS) return deps.repo.scrubDecidedEins(cutoff, limit) }, } diff --git a/services/api/src/services/host/organization-verification.drizzle.ts b/services/api/src/services/host/organization-verification.drizzle.ts new file mode 100644 index 00000000..ed5f98cf --- /dev/null +++ b/services/api/src/services/host/organization-verification.drizzle.ts @@ -0,0 +1,408 @@ +import { AppError, type OrgVerificationKind, type OrgVerificationStatus } from "@civfix/shared" +import type { Queryable, Sql } from "../../db/client.js" +import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" +import { likeContains } from "../admin/like.js" +import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" +import { writeHostAudit } from "./host-audit.js" +import { adminActorOf } from "./organization-rows.drizzle.js" +import type { + AdminOrgListQuery, + AdminOrgVerificationRecord, + ApplyOrgVerificationArgs, + DecideOrgVerificationArgs, + DecideOrgVerificationOutcome, + OrganizationRepository, + OrgVerificationRecord, +} from "./organization-repository.types.js" + +type VerificationDocuments = { mediaId?: string }[] | null | undefined + +interface VerificationRowSelect { + status: OrgVerificationStatus + kind: OrgVerificationKind + submitted_at: Date | null + reviewed_at: Date | null + rejection_reason: string | null +} + +interface AdminVerificationRowSelect { + id: string + organization_id: string + slug: string + name: string + status: OrgVerificationStatus + kind: OrgVerificationKind + ein_last4: string | null + documents: { mediaId?: string }[] | null + note: string | null + submitted_at: Date | null + reviewed_at: Date | null + rejection_reason: string | null + submitted_by_id: string | null + submitted_by_name: string | null + submitted_by_handle: string | null + submitted_by_joined: Date | null + reviewed_by_id: string | null + reviewed_by_name: string | null + reviewed_by_handle: string | null + reviewed_by_joined: Date | null +} + +function toVerificationRecord(row: VerificationRowSelect): OrgVerificationRecord { + return { + status: row.status, + kind: row.kind, + submittedAt: row.submitted_at, + reviewedAt: row.reviewed_at, + rejectionReason: row.rejection_reason, + } +} + +function documentMediaIdsOf(documents: VerificationDocuments): string[] { + const out: string[] = [] + for (const doc of documents ?? []) { + if (typeof doc.mediaId === "string" && !out.includes(doc.mediaId)) out.push(doc.mediaId) + } + return out +} + +function adminVerificationColumns(sql: Queryable) { + return sql` + v.id, + v.organization_id, + o.slug, + o.name, + v.status, + v.kind, + right(v.ein_number, 4) AS ein_last4, + v.documents, + v.note, + v.submitted_at, + v.reviewed_at, + v.rejection_reason, + su.id AS submitted_by_id, + su.display_name AS submitted_by_name, + su.handle AS submitted_by_handle, + su.created_at AS submitted_by_joined, + ru.id AS reviewed_by_id, + ru.display_name AS reviewed_by_name, + ru.handle AS reviewed_by_handle, + ru.created_at AS reviewed_by_joined + ` +} + +function toAdminVerificationRecord(row: AdminVerificationRowSelect): AdminOrgVerificationRecord { + return { + id: row.id, + organizationId: row.organization_id, + slug: row.slug, + name: row.name, + status: row.status, + kind: row.kind, + einLast4: row.ein_last4, + documentMediaIds: documentMediaIdsOf(row.documents), + note: row.note, + submittedAt: row.submitted_at, + reviewedAt: row.reviewed_at, + rejectionReason: row.rejection_reason, + submittedBy: adminActorOf( + row.submitted_by_id, + row.submitted_by_name, + row.submitted_by_handle, + row.submitted_by_joined, + ), + reviewedBy: adminActorOf( + row.reviewed_by_id, + row.reviewed_by_name, + row.reviewed_by_handle, + row.reviewed_by_joined, + ), + } +} + +async function claimVerificationDocumentsInTx( + tx: Queryable, + mediaIds: readonly string[], + claimantUserId: string, +): Promise { + if (mediaIds.length === 0) return + const claimed = await tx<{ id: string }[]>` + UPDATE media_assets + SET purpose = 'verification' + WHERE id = ANY(${[...mediaIds]}::uuid[]) + AND report_id IS NULL AND post_id IS NULL AND chat_message_id IS NULL + AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) + AND ${uploadedByClaimant(tx, [userUploader(claimantUserId)])} + AND NOT (${mediaBoundElsewhere(tx, null)}) + RETURNING id + ` + if (claimed.length !== new Set(mediaIds).size) { + throw AppError.validation({ documents: "One or more documents are unavailable." }) + } +} + +async function latestAdminVerification( + tag: Queryable, + organizationId: string, +): Promise { + const rows = await tag` + SELECT ${adminVerificationColumns(tag)} + FROM org_verifications v + JOIN organizations o ON o.id = v.organization_id + LEFT JOIN users su ON su.id = v.submitted_by + LEFT JOIN users ru ON ru.id = v.reviewed_by + WHERE v.organization_id = ${organizationId} + ORDER BY v.submitted_at DESC, v.id DESC + LIMIT 1 + ` + return rows[0] ? toAdminVerificationRecord(rows[0]) : null +} + +/** + * A resubmission with no documents keeps the ones already on the open application; otherwise the + * submitted set replaces it. `fresh` are the ids to claim, `dropped` the ones to release. + */ +function planVerificationDocuments( + carriedDocuments: VerificationDocuments, + submittedIds: readonly string[], +): { effective: string[]; fresh: string[]; dropped: string[] } { + const carried = documentMediaIdsOf(carriedDocuments) + const submitted = documentMediaIdsOf(submittedIds.map((mediaId) => ({ mediaId }))) + const effective = submitted.length === 0 ? carried : submitted + return { + effective, + fresh: effective.filter((mediaId) => !carried.includes(mediaId)), + dropped: carried.filter((mediaId) => !effective.includes(mediaId)), + } +} + +export function makeOrganizationVerificationMethods( + sql: Sql, +): Pick< + OrganizationRepository, + | "applyVerificationTx" + | "getVerification" + | "adminListVerifications" + | "adminGetVerification" + | "adminGetVerifications" + | "decideVerificationTx" + | "scrubDecidedEins" +> { + return { + async applyVerificationTx(args: ApplyOrgVerificationArgs): Promise { + return sql.begin(async (tx) => { + await tx` + SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE + ` + const open = await tx<{ id: string; documents: { mediaId?: string }[] | null }[]>` + SELECT id, documents FROM org_verifications + WHERE organization_id = ${args.organizationId} AND status = 'pending' + ORDER BY submitted_at DESC, id DESC + LIMIT 1 + FOR UPDATE + ` + const openRow = open[0] + const { effective, fresh, dropped } = planVerificationDocuments( + openRow?.documents, + args.documentMediaIds, + ) + const documents = tx.json(effective.map((mediaId) => ({ mediaId }))) + const updated = + openRow === undefined + ? [] + : await tx` + UPDATE org_verifications + SET kind = ${args.kind}, + ein_number = ${args.einNumber}, + ein_scrubbed_at = NULL, + documents = ${documents}, + note = ${args.note}, + submitted_by = ${args.submittedBy}, + submitted_at = ${args.now} + WHERE id = ${openRow.id} + RETURNING status, kind, submitted_at, reviewed_at, rejection_reason + ` + const rows = + updated.length > 0 + ? updated + : await tx` + INSERT INTO org_verifications ( + id, organization_id, status, kind, ein_number, documents, note, + submitted_by, submitted_at + ) VALUES ( + ${args.verificationId}, + ${args.organizationId}, + 'pending', + ${args.kind}, + ${args.einNumber}, + ${documents}, + ${args.note}, + ${args.submittedBy}, + ${args.now} + ) + RETURNING status, kind, submitted_at, reviewed_at, rejection_reason + ` + await claimVerificationDocumentsInTx(tx, fresh, args.submittedBy) + if (dropped.length > 0) { + await tx` + UPDATE media_assets SET purpose = 'report' + WHERE id = ANY(${dropped}::uuid[]) AND purpose = 'verification' + ` + } + await tx` + UPDATE organizations + SET verified_status = 'pending', updated_at = ${args.now} + WHERE id = ${args.organizationId} AND verified_status <> 'verified' + ` + await writeHostAudit(tx, { + actorId: args.submittedBy, + action: "org.verification_submitted", + target: `organization:${args.organizationId}`, + meta: { kind: args.kind, documentCount: effective.length }, + }) + const row = rows[0] + if (row === undefined) throw AppError.internal() + return toVerificationRecord(row) + }) + }, + + async getVerification(organizationId: string): Promise { + const rows = await sql` + SELECT status, kind, submitted_at, reviewed_at, rejection_reason + FROM org_verifications + WHERE organization_id = ${organizationId} + ORDER BY submitted_at DESC, id DESC + LIMIT 1 + ` + return rows[0] ? toVerificationRecord(rows[0]) : null + }, + + async adminListVerifications(query: AdminOrgListQuery): Promise<{ + items: AdminOrgVerificationRecord[] + nextCursor: string | null + pendingCount: number + }> { + const cursor = parseTimeCursor(query.cursor) + const cursorFilter = + cursor !== null + ? sql`AND (v.submitted_at, v.id) < (${cursor.at}, ${cursor.id}::uuid)` + : sql`` + const statusFilter = query.status !== undefined ? sql`AND v.status = ${query.status}` : sql`` + const kindFilter = query.kind !== undefined ? sql`AND v.kind = ${query.kind}` : sql`` + const qFilter = + query.q !== undefined && query.q.length > 0 + ? sql`AND (o.name ILIKE ${likeContains(query.q)} ESCAPE '\\' + OR o.slug ILIKE ${likeContains(query.q)} ESCAPE '\\')` + : sql`` + const rows = await sql` + SELECT ${adminVerificationColumns(sql)} + FROM org_verifications v + JOIN organizations o ON o.id = v.organization_id + LEFT JOIN users su ON su.id = v.submitted_by + LEFT JOIN users ru ON ru.id = v.reviewed_by + WHERE TRUE + ${statusFilter} + ${kindFilter} + ${qFilter} + ${cursorFilter} + ORDER BY v.submitted_at DESC, v.id DESC + LIMIT ${query.limit + 1} + ` + const pending = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM org_verifications WHERE status = 'pending' + ` + const page = pageWith(rows.map(toAdminVerificationRecord), query.limit, (last) => + last.submittedAt === null ? null : encodeTimeCursor({ at: last.submittedAt, id: last.id }), + ) + return { ...page, pendingCount: pending[0]?.count ?? 0 } + }, + + adminGetVerification(organizationId: string): Promise { + return latestAdminVerification(sql, organizationId) + }, + + async adminGetVerifications( + organizationIds: string[], + ): Promise> { + const out = new Map() + if (organizationIds.length === 0) return out + const rows = await sql` + SELECT DISTINCT ON (v.organization_id) ${adminVerificationColumns(sql)} + FROM org_verifications v + JOIN organizations o ON o.id = v.organization_id + LEFT JOIN users su ON su.id = v.submitted_by + LEFT JOIN users ru ON ru.id = v.reviewed_by + WHERE v.organization_id = ANY(${[...organizationIds]}::uuid[]) + ORDER BY v.organization_id, v.submitted_at DESC, v.id DESC + ` + for (const row of rows) out.set(row.organization_id, toAdminVerificationRecord(row)) + return out + }, + + async decideVerificationTx( + args: DecideOrgVerificationArgs, + ): Promise { + return sql.begin(async (tx) => { + const org = await tx<{ id: string }[]>` + SELECT id FROM organizations WHERE id = ${args.organizationId} LIMIT 1 FOR UPDATE + ` + if (org.length === 0) return "not_found" + const application = await tx< + { id: string; kind: OrgVerificationKind; ein_number: string | null }[] + >` + SELECT id, kind, ein_number FROM org_verifications + WHERE organization_id = ${args.organizationId} AND status = 'pending' + ORDER BY submitted_at DESC, id DESC + LIMIT 1 + FOR UPDATE + ` + const open = application[0] + if (open === undefined) return "no_application" + const verified = args.decision === "verified" + const grantedKind = verified ? (args.kind ?? open.kind) : open.kind + await tx` + UPDATE org_verifications + SET status = ${args.decision}, + kind = ${grantedKind}, + rejection_reason = ${args.decision === "rejected" ? args.reason : null}, + reviewed_by = ${args.reviewedBy}, + reviewed_at = ${args.now} + WHERE id = ${open.id} + ` + await tx` + UPDATE organizations + SET verified_status = ${args.decision}, + verified_kind = ${verified ? grantedKind : null}, + verified_at = ${verified ? args.now : null}, + updated_at = ${args.now} + WHERE id = ${args.organizationId} + ` + await writeHostAudit(tx, { + actorId: args.reviewedBy, + action: verified ? "org.verification_verified" : "org.verification_rejected", + target: `organization:${args.organizationId}`, + meta: { kind: grantedKind, reason: args.reason }, + }) + return "decided" + }) + }, + + async scrubDecidedEins(before: Date, limit: number): Promise { + const rows = await sql<{ id: string }[]>` + UPDATE org_verifications SET ein_number = NULL, ein_scrubbed_at = now() + WHERE id IN ( + SELECT id FROM org_verifications + WHERE ein_number IS NOT NULL + AND ein_scrubbed_at IS NULL + AND reviewed_at IS NOT NULL + AND reviewed_at < ${before} + ORDER BY reviewed_at ASC + LIMIT ${limit} + ) + RETURNING id + ` + return rows.length + }, + } +} diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index b62bf3f4..d4ea4079 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -15,17 +15,21 @@ import { parseMarkdownSubset, MARKDOWN_SUBSET_MAX_CHARS } from "@civfix/shared/m import { can, type HostStanding } from "@civfix/shared/host" import { assertNoSlur } from "../../abuse/slur-filter.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" -import { RESERVED_SLUGS } from "./slugs.js" +import { assertSlugAllowed, RESERVED_SLUGS } from "./slugs.js" import type { CounterStore } from "../../abuse/counter-store.js" import { toEventPageDTO, toEventQuestionDTO, toPublicTicketType } from "./registration-dto.js" import type { HostRegistrationRepository, PageRecord } from "./registration-repository.types.js" import type { RegistrationAudit } from "./registration-service.js" -export const HOST_PAGE_PUBLISH_COUNTER_KEY = "host:pagePublish" +const HOST_PAGE_PUBLISH_COUNTER_KEY = "host:pagePublish" export const HOST_PAGE_PUBLISH_PER_DAY = 20 -export const DAY_SECONDS = 24 * 60 * 60 +const DAY_SECONDS = 24 * 60 * 60 + +const EXTERNAL_LINK_SCHEME = "https://" + +const RESERVED_PAGE_ADDRESS_MESSAGE = "that address is reserved" const RESERVED_SLUG_SUFFIXES = ["-event", "-2", "-3"] as const @@ -88,7 +92,7 @@ function markdownFields(block: EventPageBlock): string[] { } } -export function blockMediaIds(blocks: readonly EventPageBlock[]): string[] { +function blockMediaIds(blocks: readonly EventPageBlock[]): string[] { const ids: string[] = [] for (const block of blocks) { if (block.kind === "hero" && block.mediaId != null) ids.push(block.mediaId) @@ -147,7 +151,7 @@ function mediaUrlFields(block: EventPageBlock): BlockMediaUrl[] { return [] } -export function stripResolvedMediaUrls(blocks: readonly EventPageBlock[]): EventPageBlock[] { +function stripResolvedMediaUrls(blocks: readonly EventPageBlock[]): EventPageBlock[] { return blocks.map((block) => { if (block.kind === "hero" && block.mediaId != null && block.imageUrl != null) { const { imageUrl: _dropped, ...rest } = block @@ -219,7 +223,7 @@ export function validatePageBlocks( } for (const link of externalUrls(block)) { - if (!link.url.startsWith("https://")) { + if (!link.url.startsWith(EXTERNAL_LINK_SCHEME)) { throw AppError.validation({ [`blocks.${index}.${link.field}`]: "must be an https:// link", }) @@ -256,15 +260,13 @@ export function makePageService(deps: PageServiceDeps): PageService { blocks: readonly EventPageBlock[], ): Promise { const ids = blockMediaIds(blocks) - if (ids.length === 0 || deps.presignCover === undefined) return [...blocks] + const presign = deps.presignCover + if (ids.length === 0 || presign === undefined) return [...blocks] const keys = await deps.repo.mediaKeysFor(cleanupId, ids) const urls = new Map() await mapWithLimit([...keys.entries()], PRESIGN_CONCURRENCY, async ([id, key]) => { try { - urls.set( - id, - (await (deps.presignCover as (k: string) => Promise<{ url: string }>)(key)).url, - ) + urls.set(id, (await presign(key)).url) } catch (err) { deps.logger?.warn({ err }, "event page: block media presign failed (suppressed)") } @@ -340,9 +342,7 @@ export function makePageService(deps: PageServiceDeps): PageService { async save(input, actorId): Promise { validatePageBlocks(input.blocks, deps.mediaUrlPrefixes ?? []) - if (input.slug != null && RESERVED_SLUGS.has(input.slug)) { - throw AppError.validation({ slug: "that address is reserved" }) - } + if (input.slug != null) assertSlugAllowed(input.slug, "slug", RESERVED_PAGE_ADDRESS_MESSAGE) const blocks = stripResolvedMediaUrls(input.blocks) const outcome = await deps.repo.savePage({ diff --git a/services/api/src/services/host/slugs.ts b/services/api/src/services/host/slugs.ts index afbdf4d3..506f450e 100644 --- a/services/api/src/services/host/slugs.ts +++ b/services/api/src/services/host/slugs.ts @@ -1,5 +1,7 @@ import { AppError } from "@civfix/shared" +const RESERVED_NAME_MESSAGE = "that name is reserved" + export const RESERVED_SLUGS: ReadonlySet = new Set([ "about", "account", @@ -77,8 +79,12 @@ export const RESERVED_SLUGS: ReadonlySet = new Set([ "www", ]) -export function assertSlugAllowed(slug: string, field: string): void { +export function assertSlugAllowed( + slug: string, + field: string, + message: string = RESERVED_NAME_MESSAGE, +): void { if (RESERVED_SLUGS.has(slug)) { - throw AppError.validation({ [field]: "that name is reserved" }) + throw AppError.validation({ [field]: message }) } }