diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30cd207..f6eb845e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -64,8 +64,8 @@ jobs: --health-timeout 5s --health-retries 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -86,8 +86,8 @@ jobs: matrix: deployment: [split, vps] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -97,7 +97,7 @@ jobs: # PostgreSQL/Redis containers. No deployed database or hosting keys needed. - run: npm run test:e2e:${{ matrix.deployment }} # Retry traces of failed specs (disposable data and credentials only). - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: name: playwright-${{ matrix.deployment }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 64d586c4..fba5974c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -45,8 +45,8 @@ jobs: S3_REGION: us-east-1 ALLOW_INSECURE_PRODUCTION_TESTS: "1" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44147a91..2b326c0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: release: ${{ steps.release.outputs.release }} prerelease: ${{ steps.release.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ github.event.workflow_run.head_sha || github.sha }} persist-credentials: false @@ -116,12 +116,12 @@ jobs: org.opencontainers.image.version=${{ needs.prepare.outputs.version }} org.opencontainers.image.licenses=AGPL-3.0-or-later steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ needs.prepare.outputs.sha }} persist-credentials: false - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -137,7 +137,7 @@ jobs: - name: Build backend id: backend - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ env.BUILD_ACTIONS_KEY }} with: @@ -157,7 +157,7 @@ jobs: - name: Build frontend id: frontend - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ env.BUILD_ACTIONS_KEY }} with: @@ -183,7 +183,7 @@ jobs: mkdir -p digests/spaceworks-events-backend digests/spaceworks-events-frontend touch "digests/spaceworks-events-backend/${BACKEND#sha256:}" \ "digests/spaceworks-events-frontend/${FRONTEND#sha256:}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: digests-${{ matrix.runner }} path: digests/ @@ -196,13 +196,13 @@ jobs: contents: read packages: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: digests-* merge-multiple: true path: digests - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/yuyu-upstream.yml b/.github/workflows/yuyu-upstream.yml index aea0d632..aef866fc 100644 --- a/.github/workflows/yuyu-upstream.yml +++ b/.github/workflows/yuyu-upstream.yml @@ -17,7 +17,7 @@ jobs: check: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: dev fetch-depth: 0 diff --git a/Dockerfile b/Dockerfile index 2805767d..7f0e5547 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,28 +23,25 @@ COPY prisma ./prisma COPY scripts/prisma.mjs ./scripts/prisma.mjs CMD ["node", "scripts/prisma.mjs", "migrate", "deploy"] -# Prisma CLI only, pinned to the lockfile's version, so the runner can apply -# migrations on start (MIGRATE_ON_START=1) on platforms without a release job, -# such as the Render free plan. Kept apart from the app's node_modules. +# Prisma CLI only, so the runner can apply migrations on start +# (MIGRATE_ON_START=1) on platforms without a release job, such as the Render +# free plan. Kept apart from the app's node_modules and installed with `npm ci` +# from its own committed lockfile, so every transitive dependency is pinned +# too. tests/unit/docker-deps.test.ts keeps its version equal to the root +# lockfile's. FROM node:22-alpine AS prisma-cli WORKDIR /opt/prisma-cli -COPY --from=deps /app/node_modules/prisma/package.json /tmp/prisma-package.json -RUN npm install --omit=dev --no-save --no-audit --no-fund \ - "prisma@$(node -p "require('/tmp/prisma-package.json').version")" \ - && npm cache clean --force +COPY docker/deps/prisma-cli/package.json docker/deps/prisma-cli/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force -# Clients the VPS operator tools (docker/vps/*.mjs) import, pinned to the -# lockfile's versions. The application bundles its own copies into its build. +# Clients the VPS operator tools (docker/vps/*.mjs) import, installed with +# `npm ci` from their own committed lockfile (same direct versions as the root +# lockfile, checked by tests/unit/docker-deps.test.ts). The application +# bundles its own copies into its build. FROM node:22-alpine AS ops-modules WORKDIR /opt/ops -COPY --from=deps /app/node_modules/ioredis/package.json /tmp/ioredis-package.json -COPY --from=deps /app/node_modules/@aws-sdk/client-s3/package.json /tmp/s3-package.json -COPY --from=deps /app/node_modules/nodemailer/package.json /tmp/nodemailer-package.json -RUN npm install --omit=dev --no-save --no-audit --no-fund \ - "ioredis@$(node -p "require('/tmp/ioredis-package.json').version")" \ - "@aws-sdk/client-s3@$(node -p "require('/tmp/s3-package.json').version")" \ - "nodemailer@$(node -p "require('/tmp/nodemailer-package.json').version")" \ - && npm cache clean --force +COPY docker/deps/ops/package.json docker/deps/ops/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force # Stage 2: Rebuild the source code FROM node:22-alpine AS builder diff --git a/app/actions/admission-applicant.ts b/app/actions/admission-applicant.ts index c2355b65..9b068f69 100644 --- a/app/actions/admission-applicant.ts +++ b/app/actions/admission-applicant.ts @@ -44,7 +44,7 @@ export async function loadApplicationReview(input: unknown): Promise { const gate = await reviewerGate(reviewSchema, input); - return gate.ok ? done(gate.ctx, await submitReview(gate.ctx, gate.data.assignmentId, { scores: gate.data.scores, notes: gate.data.notes })) : gate; + return gate.ok ? done(gate.ctx, await submitReview(gate.ctx, gate.data.assignmentId, { answersRevision: gate.data.answersRevision, scores: gate.data.scores, notes: gate.data.notes })) : gate; } export async function recuseFromApplication(input: unknown): Promise> { diff --git a/app/actions/admission/schemas.ts b/app/actions/admission/schemas.ts index b979a8c0..cbbec3b1 100644 --- a/app/actions/admission/schemas.ts +++ b/app/actions/admission/schemas.ts @@ -69,6 +69,7 @@ export const onBehalfSchema = eventScope.extend({ applicationId: id, response: z export const reviewSchema = eventScope.extend({ assignmentId: id, + answersRevision: z.number().int().min(1), scores: z.record(z.string().max(ADMISSION_CRITERION_MAX), z.number()), notes: z.string().max(ADMISSION_NOTES_MAX), }).strict(); diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 33bb0b17..ddae122e 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -42,6 +42,11 @@ export async function signUpWithPassword( return { ok: false, error: "New account creation is currently unavailable." }; } + // Every attempt spends the address's creation quota before any lookup or + // hashing. A limited attempt creates and sends nothing but answers exactly + // like the others, so the limit can't tell existing addresses from new ones. + if (await isActionRateLimited("signup", email)) return { ok: true, data: { email } }; + // The super admin's account and password come from the server's recovery // tool. Answer like any other sign-up so the address isn't revealed. if (isSuperAdminEmail(email)) return { ok: true, data: { email } }; @@ -59,13 +64,6 @@ export async function signUpWithPassword( return { ok: true, data: { email } }; } - // A double-submit or network retry can arrive just after the first request - // created the account. Check for that case before consuming the creation - // quota so a real account creation is never presented as a rate-limit error. - if (await isActionRateLimited("signup", email)) { - return { ok: false, error: "Too many attempts. Please try again later." }; - } - try { await prisma.$transaction(async (tx) => { const user = await tx.user.create({ diff --git a/app/actions/checkin/offline.ts b/app/actions/checkin/offline.ts index 22940141..45059f1d 100644 --- a/app/actions/checkin/offline.ts +++ b/app/actions/checkin/offline.ts @@ -3,9 +3,10 @@ import { gateCheckInForStatus } from "@/lib/checkIn"; import { prisma } from "@/lib/db"; import { offlineCheckInRosterSchema, syncOfflineCheckInsSchema } from "@/lib/validators"; import type { ActionResult } from "../org"; -import { commitCheckInProjection } from "@/lib/checkInMutations"; +import { commitOfflineCheckIn } from "@/lib/checkInMutations"; import { getCheckInDetails, getRegistrationDetails } from "@/lib/checkInDetails"; import { deskAttendanceState } from "@/lib/attendanceCheck"; +import { recordAuditEvent } from "@/lib/audit"; import { attendeeLabel, requireOrgMemberForEvent, revalidatePath } from "./access"; import type { OfflineCheckInRosterRow } from "../checkin"; @@ -52,32 +53,36 @@ export async function downloadOfflineCheckInRoster( else answersByRsvp.set(answer.rsvpId, [answer]); } - return { - ok: true, - data: { - generatedAt: new Date().toISOString(), - rows: rsvps.map((r) => { - const user = r.userId ? { name: r.userName, email: r.userEmail } : null; - const rsvpAnswers = answersByRsvp.get(r.id) ?? []; - return { - rsvpId: r.id, - ticketToken: r.checkInToken, - displayName: attendeeLabel({ user, guestName: r.guestName, guestEmail: r.guestEmail }), - email: user?.email ?? r.guestEmail, - status: r.status, - checkedInAt: r.checkedInAt?.toISOString() ?? null, - checkInDetails: getCheckInDetails(rsvpAnswers, r.crewFood != null ? { foodPreference: r.crewFood } : null), - registrationDetails: getRegistrationDetails(rsvpAnswers), - teamName: r.teamName, - // The attendance reply only (docs/ATTENDANCE.md); never the note. - attendance: deskAttendanceState(r.attendanceAsked ? { response: r.attendanceResponse } : null), - isCrew: r.isCrew, - }; - }), - }, - }; + const rows = rsvps.map((r): OfflineCheckInRosterRow => { + const user = r.userId ? { name: r.userName, email: r.userEmail } : null; + const rsvpAnswers = answersByRsvp.get(r.id) ?? []; + return { + rsvpId: r.id, + ticketToken: r.checkInToken, + displayName: attendeeLabel({ user, guestName: r.guestName, guestEmail: r.guestEmail }), + email: user?.email ?? r.guestEmail, + status: r.status, + checkedInAt: r.checkedInAt?.toISOString() ?? null, + checkInDetails: getCheckInDetails(rsvpAnswers, r.crewFood != null ? { foodPreference: r.crewFood } : null), + registrationDetails: getRegistrationDetails(rsvpAnswers), + teamName: r.teamName, + // The attendance reply only (docs/ATTENDANCE.md); never the note. + attendance: deskAttendanceState(r.attendanceAsked ? { response: r.attendanceResponse } : null), + isCrew: r.isCrew, + }; + }); + // The roster carries ticket links, contacts and answers: who took one is + // recorded (web and mobile alike), with the row count only in metadata. + await recordAuditEvent({ + action: "CHECK_IN_ROSTER_DOWNLOADED", actorUserId: ctx.actorUserId, organisationId: ctx.event.organisationId, + targetType: "Event", targetId: ctx.event.id, metadata: { rows: rows.length }, + }); + return { ok: true, data: { generatedAt: new Date().toISOString(), rows } }; } +/** Offline check-ins older than this are refused (docs/SECURITY.md); their replay records are kept a day longer. */ +export const OFFLINE_CHECK_IN_MAX_AGE_MS = 7 * 24 * 60 * 60_000; + export async function syncOfflineCheckIns( input: unknown, ): Promise> { @@ -92,7 +97,7 @@ export async function syncOfflineCheckIns( const rsvps = await prisma.rSVP.findMany({ where: { eventId: ctx.event.id, id: { in: checkIns.map((checkIn) => checkIn.rsvpId) } }, - select: { id: true, status: true, checkedInAt: true }, + select: { id: true }, }); const rsvpById = new Map(rsvps.map((rsvp) => [rsvp.id, rsvp])); const syncedIds: string[] = []; @@ -111,11 +116,6 @@ export async function syncOfflineCheckIns( failed.push({ rsvpId: checkIn.rsvpId, error: "RSVP not found for this event." }); continue; } - const gate = gateCheckInForStatus(rsvp.status, checkIn.force); - if (!gate.ok) { - failed.push({ rsvpId: checkIn.rsvpId, error: gate.reason }); - continue; - } const checkedInAt = new Date(checkIn.checkedInAt); const earliest = ctx.event.startDateTime.getTime() - 24 * 60 * 60_000; const latest = Math.min(Date.now() + 5 * 60_000, ctx.event.endDateTime.getTime() + 60 * 60_000); @@ -123,17 +123,26 @@ export async function syncOfflineCheckIns( failed.push({ rsvpId: checkIn.rsvpId, error: "Offline check-in time is outside the event operating window." }); continue; } - if (rsvp.checkedInAt) { - alreadyCheckedInIds.push(rsvp.id); + if (checkedInAt.getTime() < Date.now() - OFFLINE_CHECK_IN_MAX_AGE_MS) { + failed.push({ rsvpId: checkIn.rsvpId, error: "Offline check-ins older than 7 days are no longer accepted." }); continue; } - const committed = await commitCheckInProjection({ + // Claimed with its outcome in the check-in transaction: a retry returns + // the first outcome, whatever happened since (an undo included). + const commit = await commitOfflineCheckIn({ + eventId: ctx.event.id, + clientMutationId: checkIn.clientMutationId, rsvpId: rsvp.id, actorUserId: ctx.actorUserId, source: "offline-sync", checkedInAt, force: checkIn.force, }); + if (commit.kind === "mismatch") { + failed.push({ rsvpId: rsvp.id, error: "This offline check-in was already sent with different details." }); + continue; + } + const committed = commit.result; if (committed.state === "checked-in") { syncedIds.push(rsvp.id); } else if (committed.state === "already-checked-in") { diff --git a/app/actions/event-collaborators.ts b/app/actions/event-collaborators.ts index 6e4065e3..4526ada6 100644 --- a/app/actions/event-collaborators.ts +++ b/app/actions/event-collaborators.ts @@ -16,7 +16,7 @@ import { isActionRateLimited } from "@/lib/actionRateLimit"; import type { ActionResult } from "./org"; import { lockCollaborator, requireNoMentoringInProgress, syncMentorGrantLocked } from "@/lib/core/mentorDesk/grant"; import { DeskError } from "@/lib/core/mentorDesk/shared"; -import { syncMentorCrewAfterGrant } from "@/lib/core/mentorDesk/crewLink"; +import { emitWebhookEvent, type WebhookEmission } from "@/lib/webhooks/emit"; const revalidatePath = trackedRevalidation(nextRevalidatePath); @@ -99,18 +99,19 @@ export async function revokeEventCollaborator(input: unknown): Promise prisma.$transaction(async (tx) => { const existing = await lockCollaborator(tx, where); + let webhook: WebhookEmission | null = null; if (existing?.eventId) { if (existing.permissions.includes(EventPermission.MENTOR)) await requireNoMentoringInProgress(tx, existing.eventId, existing.userId); - await syncMentorGrantLocked(tx, existing.eventId, existing.userId, existing.permissions, [], session.user!.id!); + webhook = await syncMentorGrantLocked(tx, existing.eventId, existing.userId, existing.permissions, [], session.user!.id!); } - return { deleted: await tx.eventCollaborator.deleteMany({ where }), existing }; + return { deleted: await tx.eventCollaborator.deleteMany({ where }), webhook }; })); if (!outcome.ok) return outcome; - const { deleted, existing } = outcome.data; - if (deleted.count && existing?.eventId) await syncMentorCrewAfterGrant({ eventId: existing.eventId, userId: existing.userId, before: existing.permissions, after: [], actorUserId: session.user.id }); + const { deleted, webhook } = outcome.data; + if (webhook) emitWebhookEvent(webhook); if (!deleted.count) return { ok: false, error: "Collaborator not found." }; await recordAuditEvent({ action: "EVENT_COLLABORATOR_REVOKED", actorUserId: session.user.id, organisationId: org.id, targetType: "EventCollaborator", targetId: parsed.data.collaboratorId }); if (parsed.data.eventId) revalidatePath(`/dashboard/${org.slug}/event/${parsed.data.eventId}`); @@ -132,16 +133,17 @@ export async function updateEventCollaboratorPermissions(input: unknown): Promis }; const outcome = await mentorGrantChange(() => prisma.$transaction(async (tx) => { const existing = await lockCollaborator(tx, where); + let webhook: WebhookEmission | null = null; if (existing?.eventId) { if (existing.permissions.includes(EventPermission.MENTOR) && !parsed.data.permissions.includes(EventPermission.MENTOR)) await requireNoMentoringInProgress(tx, existing.eventId, existing.userId); - await syncMentorGrantLocked(tx, existing.eventId, existing.userId, existing.permissions, parsed.data.permissions, session.user!.id!); + webhook = await syncMentorGrantLocked(tx, existing.eventId, existing.userId, existing.permissions, parsed.data.permissions, session.user!.id!); } - return { updated: await tx.eventCollaborator.updateMany({ where, data: { permissions: parsed.data.permissions } }), existing }; + return { updated: await tx.eventCollaborator.updateMany({ where, data: { permissions: parsed.data.permissions } }), webhook }; })); if (!outcome.ok) return outcome; - const { updated, existing } = outcome.data; - if (updated.count && existing?.eventId) await syncMentorCrewAfterGrant({ eventId: existing.eventId, userId: existing.userId, before: existing.permissions, after: parsed.data.permissions, actorUserId: session.user.id }); + const { updated, webhook } = outcome.data; if (!updated.count) return { ok: false, error: "Collaborator not found." }; + if (webhook) emitWebhookEvent(webhook); await recordAuditEvent({ action: "EVENT_COLLABORATOR_PERMISSIONS_UPDATED", actorUserId: session.user.id, organisationId: org.id, targetType: "EventCollaborator", targetId: parsed.data.collaboratorId, metadata: { permissions: parsed.data.permissions.join(",") } }); if (parsed.data.eventId) revalidatePath(`/dashboard/${org.slug}/event/${parsed.data.eventId}`); revalidatePath("/dashboard"); diff --git a/app/actions/event/remove.ts b/app/actions/event/remove.ts index a065dbed..c03d1e77 100644 --- a/app/actions/event/remove.ts +++ b/app/actions/event/remove.ts @@ -10,7 +10,8 @@ import { flattenZodErrors } from "../utils"; import { isActionRateLimited } from "@/lib/actionRateLimit"; import { recordAuditEvent } from "@/lib/audit"; import { z } from "zod"; -import { hasPaidOrders, notifyEventCancelled, refundCancelledEvent } from "@/lib/payments/eventCancellation"; +import { hasPaidOrders, notifyEventCancelled, PAID_EVENT_REFUSAL, refundCancelledEvent } from "@/lib/payments/eventCancellation"; +import { fenceOrganisation } from "@/lib/payments/targets"; import { revalidateEventPaths, revalidatePath } from "./shared"; import { emitWebhookEvent } from "@/lib/webhooks/emit"; @@ -46,21 +47,26 @@ export async function deleteEvent(input: unknown): Promise { where: { id: eventId, organisationId: org.id }, }); if (!event) return { ok: false, error: "Event not found." }; - // Deleting would leave people who paid without their tickets: cancel instead, which refunds them. - if (await hasPaidOrders({ eventId: event.id })) { - return { ok: false, error: "People have paid for this event. Cancel it instead, which refunds them in full." }; - } + // Retain organiser access to every order while any money is held or in flight. + if (await hasPaidOrders({ eventId: event.id })) return { ok: false, error: PAID_EVENT_REFUSAL }; const slug = event.slug; try { - await prisma.$transaction(async (tx) => { - if (event.status === EventStatus.PUBLISHED && event.endDateTime > new Date()) await notifyEventCancelled(tx, { organisationId: org.id, eventId: event.id, title: event.title }); - // Claims outlive the event; their policy terms stay in each claim's snapshot. - // The event lock first, as when claims are filed, approved or paid (Event, then claim). + const refused = await prisma.$transaction(async (tx) => { + // The locks a reservation takes (docs/PAYMENTS.md): the organisation + // fence, then the event. A paid order committed before this is seen + // below; one that waits finds the event gone. + await fenceOrganisation(tx, org.id); await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${event.id} FOR UPDATE`; + if (await hasPaidOrders({ eventId: event.id }, tx)) return true; + if (event.status === EventStatus.PUBLISHED && event.endDateTime > new Date()) await notifyEventCancelled(tx, { organisationId: org.id, eventId: event.id, title: event.title }); + // Claims outlive the event; their policy terms stay in each claim's snapshot + // (the event lock is held, as when claims are filed, approved or paid). await tx.expenseClaim.updateMany({ where: { policy: { eventId: event.id } }, data: { policyId: null } }); await tx.event.delete({ where: { id: event.id } }); + return false; }); + if (refused) return { ok: false, error: PAID_EVENT_REFUSAL }; await recordAuditEvent({ action: "EVENT_DELETED", actorUserId: session.user.id, @@ -104,6 +110,7 @@ export async function cancelEvent(input: unknown): Promise { + await fenceOrganisation(tx, org.id); await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${event.id} FOR UPDATE`; if (event.status === EventStatus.CANCELLED) return 0; cancelledNow = true; diff --git a/app/actions/finance.ts b/app/actions/finance.ts index 7217978d..3f28da8b 100644 --- a/app/actions/finance.ts +++ b/app/actions/finance.ts @@ -64,7 +64,7 @@ export async function submitStaffClaimAction(formData: FormData): Promise { const scope = await resolveScope(scopeInput, false); if ("error" in scope) return { ok: false, error: scope.error }; - return done(scope, await withdrawClaim(String(claimId ?? "").slice(0, 128), { userId: scope.userId })); + return done(scope, await withdrawClaim(String(claimId ?? "").slice(0, 128), { userId: scope.userId, organisationId: scope.organisationId, eventId: scope.eventId })); } export async function approveClaimAction(scopeInput: unknown, input: unknown): Promise { diff --git a/app/actions/invite-acceptance.ts b/app/actions/invite-acceptance.ts index 0f8a2a6e..7d701a3a 100644 --- a/app/actions/invite-acceptance.ts +++ b/app/actions/invite-acceptance.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { auth } from "@/lib/auth"; import { isActionRateLimited } from "@/lib/actionRateLimit"; import { acceptEventCollaboratorInviteToken, acceptOrganisationInviteToken } from "@/lib/inviteAcceptance"; -import { syncMentorCrewAfterGrant } from "@/lib/core/mentorDesk/crewLink"; import type { ActionResult } from "./org"; const tokenSchema = z.string().trim().min(20).max(256).regex(/^[A-Za-z0-9_-]+$/); @@ -27,9 +26,7 @@ export async function acceptEventCollaboratorInvite(input: unknown): Promise { @@ -56,24 +75,26 @@ export async function updateMemberRole( return { ok: false, error: "For security, sign in again before changing member roles." }; } - const target = await getMembership(targetUserId, org.id); - if (!target) return { ok: false, error: "Member not found." }; - if (target.role === "OWNER") { - return { ok: false, error: "The owner role cannot be changed here." }; - } - - await prisma.membership.update({ - where: { id: target.id }, - data: { role }, - }); - await recordAuditEvent({ - action: "MEMBERSHIP_ROLE_CHANGED", - actorUserId: session.user.id, - organisationId: org.id, - targetType: "membership", - targetId: target.id, - metadata: { role }, + const actorUserId = session.user.id; + const refusal = await prisma.$transaction(async (tx) => { + const { actor: current, target } = await lockMembers(tx, org.id, actorUserId, targetUserId); + if (current?.role !== "OWNER") return "Only the organisation owner can change roles."; + if (!target) return "Member not found."; + if (target.role === "OWNER") return "The owner role cannot be changed here."; + const updated = await tx.membership.updateMany({ where: { id: target.id, organisationId: org.id, role: { not: "OWNER" } }, data: { role } }); + if (updated.count !== 1) return "Member not found."; + await recordAuditEvent({ + action: "MEMBERSHIP_ROLE_CHANGED", + actorUserId, + organisationId: org.id, + targetType: "membership", + targetId: target.id, + metadata: { role }, + client: tx, + }); + return null; }); + if (refusal) return { ok: false, error: refusal }; revalidatePath(`/dashboard/${org.slug}/members`); revalidatePath(`/dashboard/${org.slug}`); @@ -110,23 +131,27 @@ export async function removeMember(input: unknown): Promise { return { ok: false, error: "You do not have permission to remove members." }; } - const target = await getMembership(targetUserId, org.id); - if (!target) return { ok: false, error: "Member not found." }; - if (target.role === "OWNER") { - return { ok: false, error: "Cannot remove the organisation owner." }; - } - if (actor.role === "ADMIN" && target.role !== "MEMBER") { - return { ok: false, error: "Admins can only remove members." }; - } - - await prisma.membership.delete({ where: { id: target.id } }); - await recordAuditEvent({ - action: "MEMBER_REMOVED", - actorUserId: session.user.id, - organisationId: org.id, - targetType: "membership", - targetId: target.id, + const actorUserId = session.user.id; + const refusal = await prisma.$transaction(async (tx) => { + const { actor: current, target } = await lockMembers(tx, org.id, actorUserId, targetUserId); + if (!current || !isOrgAdmin(current.role)) return "You do not have permission to remove members."; + if (!target) return "Member not found."; + if (target.role === "OWNER") return "Cannot remove the organisation owner."; + if (current.role === "ADMIN" && target.role !== "MEMBER") return "Admins can only remove members."; + // Constrained to the role checked above, so a change committed meanwhile can't be swept away. + const removed = await tx.membership.deleteMany({ where: { id: target.id, organisationId: org.id, role: current.role === "OWNER" ? { not: "OWNER" } : "MEMBER" } }); + if (removed.count !== 1) return "Member not found."; + await recordAuditEvent({ + action: "MEMBER_REMOVED", + actorUserId, + organisationId: org.id, + targetType: "membership", + targetId: target.id, + client: tx, + }); + return null; }); + if (refusal) return { ok: false, error: refusal }; revalidatePath(`/dashboard/${org.slug}/members`); revalidatePath(`/dashboard/${org.slug}`); @@ -161,24 +186,22 @@ export async function deleteOrganisation(input: unknown): Promise return { ok: false, error: "For security, sign in again before deleting an organisation." }; } - // Deleting would erase the tickets and payment records of people who paid - // for something still to come: cancelling those events refunds them first. - const now = new Date(); - const paidUpcoming = await prisma.order.count({ - where: { - organisationId: org.id, - totalMinor: { gt: 0 }, - status: { in: [OrderStatus.CONFIRMED, OrderStatus.PENDING, OrderStatus.REQUESTED] }, - OR: [{ event: { endDateTime: { gt: now }, status: { not: EventStatus.CANCELLED } } }, { series: { status: { not: EventStatus.CANCELLED }, instances: { some: { endDateTime: { gt: now }, cancelledAt: null } } } }], - }, - }); - if (paidUpcoming > 0) { - return { ok: false, error: "People have paid for upcoming events. Cancel those events (or their dates) first, which refunds them." }; - } - const slug = org.slug; - // Guests of upcoming events and dates are told they won't go ahead. - await prisma.$transaction(async (tx) => { + const actorUserId = session.user.id; + // One transaction, the Organisation row first: every path that creates an + // order or a payment takes its key-share fence (docs/PAYMENTS.md), so it + // either committed before this check or waits and then finds nothing. + // Other Event-locked commands can deadlock through an Organisation FK. + // Retry the entire transaction, including obligations and queued effects; + // nothing escapes an aborted attempt (docs/PAYMENTS.md). + const refusal = await retryOrganisationDeletion(() => prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw>`SELECT "id" FROM "Organisation" WHERE "id" = ${org.id} FOR UPDATE`; + if (locked.length === 0) return "Organisation not found."; + const now = new Date(); + // Deleting would erase payment records and refunds still owed or in flight. + const blocker = await organisationMoneyBlocker(tx, org.id, now); + if (blocker) return blocker; + // Guests of upcoming events and dates are told they won't go ahead. const [events, dates] = await Promise.all([ tx.event.findMany({ where: { organisationId: org.id, status: EventStatus.PUBLISHED, endDateTime: { gt: now } }, select: { id: true, title: true } }), tx.eventInstance.findMany({ where: { series: { organisationId: org.id, status: EventStatus.PUBLISHED }, endDateTime: { gt: now }, cancelledAt: null }, select: { id: true, series: { select: { title: true } } } }), @@ -190,16 +213,17 @@ export async function deleteOrganisation(input: unknown): Promise for (const [title, emails] of people) { await noticeEach(tx, emails, { subject: noticeSubject("Cancelled", title), lines: [`${title} has been cancelled: ${org.name} closed its account.`] }); } - }, { timeout: 60_000 }); - // Deliberately omit organisationId: deleting the organisation cascades its - // scoped records, while this event must survive as an instance-level record. - await recordAuditEvent({ - action: "ORGANISATION_DELETED", - actorUserId: session.user.id, - targetType: "organisation", - targetId: org.id, - }); - await prisma.organisation.delete({ where: { id: org.id } }); + // Deliberately omit organisationId: deleting the organisation cascades its + // scoped records, while this event must survive as an instance-level record. + await recordAuditEvent({ action: "ORGANISATION_DELETED", actorUserId, targetType: "organisation", targetId: org.id, client: tx }); + // Its stored files are queued here too (lib/storage/deletionQueue.ts): the + // queue has no foreign key, so it outlives the cascade, and the row lock + // orders this against an upload publishing its Asset. + await queueOrganisationStorage(tx, org.id); + await tx.organisation.delete({ where: { id: org.id } }); + return null; + }, { timeout: 60_000 })); + if (refusal) return { ok: false, error: refusal }; revalidatePath("/dashboard"); revalidatePath(`/${slug}`); diff --git a/app/actions/mfa-step-up.ts b/app/actions/mfa-step-up.ts index 6381bd22..a49580cd 100644 --- a/app/actions/mfa-step-up.ts +++ b/app/actions/mfa-step-up.ts @@ -6,6 +6,7 @@ import { prisma } from "@/lib/db"; import { requireAuth } from "@/lib/permissions"; import { decryptMfaSecret, consumeMfaCode } from "@/lib/mfa"; import { createMfaProof, MFA_PROOF_COOKIE, MFA_PROOF_MAX_AGE_SECONDS } from "@/lib/mfaProof"; +import { currentSessionSid } from "@/lib/sessionSid"; import { isActionRateLimited } from "@/lib/actionRateLimit"; import type { ActionResult } from "./org"; @@ -19,6 +20,9 @@ export async function verifyStepUpMfa(input: unknown): Promise { } const parsed = schema.safeParse(input); if (!parsed.success) return { ok: false, error: "Enter a six-digit authenticator code." }; + // The proof is bound to this browser's sign-in (lib/sessionSid.ts); without one there is nothing to bind it to. + const sid = await currentSessionSid(session.user.id); + if (!sid) return { ok: false, error: "Sign in again, then confirm your authenticator code." }; const user = await prisma.user.findUnique({ where: { id: session.user.id }, @@ -36,7 +40,7 @@ export async function verifyStepUpMfa(input: unknown): Promise { } if (!valid) return { ok: false, error: "Invalid authenticator code." }; - (await cookies()).set(MFA_PROOF_COOKIE, createMfaProof(session.user.id, user.sessionVersion), { + (await cookies()).set(MFA_PROOF_COOKIE, createMfaProof(session.user.id, user.sessionVersion, sid), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", diff --git a/app/actions/orders.ts b/app/actions/orders.ts index bce54cbf..b6c503b9 100644 --- a/app/actions/orders.ts +++ b/app/actions/orders.ts @@ -1,6 +1,6 @@ "use server"; -import { CREW_EMAIL_REFUSED, lockedHasCrewEmail } from "@/lib/core/crew/shared"; +import { CREW_EMAIL_REFUSED, hasCrewEmail } from "@/lib/core/crew/shared"; import { z } from "zod"; import { revalidatePath as nextRevalidatePath } from "next/cache"; import type { ActionResult } from "@/app/actions/org"; @@ -12,7 +12,9 @@ import { enqueueRsvpConfirmation } from "@/lib/outbox"; import { AdmissionManagedError, cancelOrderAsBuyer, PaymentOperationError } from "@/lib/payments/operations"; import { validateAndNormalizeAnswers } from "@/lib/rsvpCore"; import { isValidAttendeeName } from "@/lib/validators"; -import { ORDER_SUBJECT_SELECT, orderSubject } from "@/lib/payments/targets"; +import { lockOrderTargets, ORDER_SUBJECT_SELECT, orderSubject } from "@/lib/payments/targets"; +import { replaceRegistrationAnswers } from "@/lib/admission/answers"; +import { AdmissionError } from "@/lib/admission/state"; import { OpenClaimsError, transferTicket } from "@/lib/payments/transfers"; const revalidatePath = trackedRevalidation(nextRevalidatePath); @@ -38,19 +40,9 @@ export async function updateOrderAttendee(input: unknown): Promise if (!isValidAttendeeName(parsed.data.name)) return { ok: false, error: "Enter a valid name." }; const order = await prisma.order.findUnique({ where: { accessToken: parsed.data.orderToken }, - select: { id: true, organisationId: true, status: true, eventId: true, ...ORDER_SUBJECT_SELECT }, + select: { id: true, organisationId: true, status: true, eventId: true, eventSeriesId: true, ...ORDER_SUBJECT_SELECT }, }); if (!order) return { ok: false, error: "Order not found." }; - const rsvp = await prisma.rSVP.findFirst({ - where: { id: parsed.data.rsvpId, orderId: order.id }, - select: { id: true, userId: true, guestEmail: true, checkedInAt: true, checkInToken: true, status: true, attendeeKey: true }, - }); - if (!rsvp) return { ok: false, error: "Ticket not found." }; - if (rsvp.checkedInAt) return { ok: false, error: "This ticket has already been used to check in." }; - const email = parsed.data.email || null; - if (rsvp.userId && email) return { ok: false, error: "This ticket belongs to a signed-in account; its email can't change here." }; - if (rsvp.guestEmail && email && email !== rsvp.guestEmail) return { ok: false, error: "To give this ticket to someone else, ask the organiser to transfer it." }; - const form = order.eventId ? await prisma.eventRegistrationForm.findUnique({ where: { eventId: order.eventId }, include: { fields: { orderBy: { sortOrder: "asc" } } } }) : null; const validated = validateAndNormalizeAnswers({ fields: (form?.fields ?? []).map((field) => ({ id: field.id, key: field.key, label: field.label, type: field.type, required: field.required, options: field.options, settings: field.settings })), @@ -58,11 +50,23 @@ export async function updateOrderAttendee(input: unknown): Promise }); if ("error" in validated) return { ok: false, error: validated.error }; - const newEmail = !rsvp.userId && !rsvp.guestEmail && email ? email : null; + const email = parsed.data.email || null; try { const refused = await prisma.$transaction(async (tx) => { + // The event or dates, then the order's tickets by id (docs/PAYMENTS.md lock order); read under the lock. + await lockOrderTargets(tx, order); + await tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "orderId" = ${order.id} ORDER BY "id" FOR UPDATE`; + const rsvp = await tx.rSVP.findFirst({ + where: { id: parsed.data.rsvpId, orderId: order.id }, + select: { id: true, eventId: true, teamId: true, userId: true, guestEmail: true, checkedInAt: true, checkInToken: true, status: true, attendeeKey: true }, + }); + if (!rsvp) return "Ticket not found."; + if (rsvp.checkedInAt) return "This ticket has already been used to check in."; + if (rsvp.userId && email) return "This ticket belongs to a signed-in account; its email can't change here."; + if (rsvp.guestEmail && email && email !== rsvp.guestEmail) return "To give this ticket to someone else, ask the organiser to transfer it."; + const newEmail = !rsvp.userId && !rsvp.guestEmail && email ? email : null; // A crew member already holds a free crew ticket (docs/CREW.md); checked under the Event lock. - if (newEmail && order.eventId && (await lockedHasCrewEmail(tx, order.eventId, [newEmail]))) return CREW_EMAIL_REFUSED; + if (newEmail && order.eventId && (await hasCrewEmail(tx, order.eventId, [newEmail]))) return CREW_EMAIL_REFUSED; // The same person on every date of a pass. if (!rsvp.userId) { await tx.rSVP.updateMany({ @@ -70,12 +74,8 @@ export async function updateOrderAttendee(input: unknown): Promise data: { guestName: parsed.data.name, ...(newEmail ? { guestEmail: newEmail, attendeeKey: `guest:${newEmail}` } : {}) }, }); } - await tx.rsvpAnswer.deleteMany({ where: { rsvpId: rsvp.id } }); - if (validated.rows.length > 0) { - await tx.rsvpAnswer.createMany({ - data: validated.rows.map((row) => ({ rsvpId: rsvp.id, fieldId: row.fieldId, valueText: row.valueText ?? null, valueBool: row.valueBool ?? null, valueNumber: row.valueNumber ?? null, valueDate: row.valueDate ?? null })), - }); - } + // Applications keep their answer rules and revisions (lib/admission/answers.ts). + await replaceRegistrationAnswers(tx, { id: rsvp.id, eventId: rsvp.eventId, teamId: rsvp.teamId, attendeeKey: newEmail ? `guest:${newEmail}` : rsvp.attendeeKey }, validated.rows); // A confirmed ticket that just got its holder's address is sent to them. if (newEmail && rsvp.status === "CONFIRMED") { await enqueueRsvpConfirmation(tx, { to: newEmail, eventTitle: orderSubject(order)?.title ?? "Your event", status: "CONFIRMED", checkInToken: rsvp.checkInToken }); @@ -85,6 +85,7 @@ export async function updateOrderAttendee(input: unknown): Promise }); if (refused) return { ok: false, error: refused }; } catch (error) { + if (error instanceof AdmissionError) return { ok: false, error: error.message }; if (typeof error === "object" && error !== null && "code" in error && (error as { code: string }).code === "P2002") { return { ok: false, error: "That person is already registered for this event." }; } diff --git a/app/actions/org.ts b/app/actions/org.ts index 27a04cfa..b2b47f68 100644 --- a/app/actions/org.ts +++ b/app/actions/org.ts @@ -4,6 +4,7 @@ import { revalidatePath as nextRevalidatePath } from "next/cache"; import { trackedRevalidation } from "@/lib/frontend/revalidation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; +import { getMembership } from "@/lib/permissions"; import { createOrganisationSchema, updateOrganisationSchema, @@ -128,15 +129,8 @@ export async function updateOrganisation( }); if (!org) return { ok: false, error: "Organisation not found." }; - const membership = await prisma.membership.findUnique({ - where: { - userId_organisationId: { - userId: session.user.id, - organisationId: org.id, - }, - }, - select: { role: true }, - }); + // The MFA-aware helper: staff powers wait for this session's authenticator check. + const membership = await getMembership(session.user.id, org.id); if ( !membership || diff --git a/app/actions/participant-claims.ts b/app/actions/participant-claims.ts index 93240351..3eca8fca 100644 --- a/app/actions/participant-claims.ts +++ b/app/actions/participant-claims.ts @@ -16,8 +16,8 @@ import { requestRefund } from "@/lib/refundRequests"; const TOKEN = /^[A-Za-z0-9_-]{8,256}$/; -/** File a claim from a ticket (multipart: `ticketToken`, the fields, an optional `policyId`, up to three `receipts`). */ -export async function submitParticipantClaimAction(formData: FormData): Promise { +/** File a claim from a ticket (multipart: `ticketToken`, the fields, an optional `policyId`, up to three `receipts`); answers the new claim's id. */ +export async function submitParticipantClaimAction(formData: FormData): Promise> { const ticketToken = String(formData.get("ticketToken") ?? ""); if (!TOKEN.test(ticketToken)) return { ok: false, error: "Registration not found." }; if (await isActionRateLimited("action", ticketToken)) return { ok: false, error: "Too many claims. Try again shortly." }; @@ -25,7 +25,7 @@ export async function submitParticipantClaimAction(formData: FormData): Promise< const field = (name: string) => String(formData.get(name) ?? ""); const files = formData.getAll("receipts").filter((value): value is File => value instanceof File); const result = await submitParticipantClaim({ ticketToken, actorUserId: session?.user?.id ?? null }, { category: field("category"), description: field("description"), amount: field("amount"), policyId: field("policyId") }, files); - return result.ok ? { ok: true } : result; + return result.ok ? { ok: true, data: { claimId: result.data.claimId } } : result; } /** Withdraw one of this ticket's claims before it is decided. */ @@ -35,7 +35,7 @@ export async function withdrawParticipantClaimAction(ticketToken: unknown, claim const rsvp = await prisma.rSVP.findUnique({ where: { checkInToken: ticketToken }, select: { id: true, userId: true } }); const session = await auth(); if (!rsvp || (rsvp.userId && rsvp.userId !== session?.user?.id)) return { ok: false, error: "Claim not found." }; - return withdrawClaim(claimId.slice(0, 128), { userId: null, rsvpId: rsvp.id }); + return withdrawClaim(claimId.slice(0, 128), { rsvpId: rsvp.id }); } /** Ask for a refund outside the automatic policy, from the order page. */ diff --git a/app/actions/payment-operations.ts b/app/actions/payment-operations.ts index 7073ad50..90051a1b 100644 --- a/app/actions/payment-operations.ts +++ b/app/actions/payment-operations.ts @@ -10,7 +10,9 @@ import { prisma } from "@/lib/db"; import { canAccessEvent } from "@/lib/eventAccess"; import { trackedRevalidation } from "@/lib/frontend/revalidation"; import { parseMoneyToMinor } from "@/lib/payments/money"; -import { AdmissionManagedError, amendOfflinePayment, PaymentOperationError, refundOrder, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; +import { AdmissionManagedError, amendOfflinePayment, PaymentOperationError, refundEndedOrder, refundOrder, retryFailedRefund, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; +import { getMembership } from "@/lib/permissions"; +import { hasRecentAuthentication } from "@/lib/reauth"; import { submitManualTicketOrderCore } from "@/lib/rsvpCore"; import { attendeeNameSchema } from "@/lib/validators"; @@ -87,6 +89,55 @@ export async function refundPaidOrder(input: unknown): Promise { })); } +const retrySchema = z.object({ ...target, refundId: z.string().trim().min(1).max(64) }).strict(); + +/** Owners and admins only (MANAGE_REGISTRATIONS collaborators can't), after a fresh sign-in. */ +async function ownerOrAdminRecently(context: { userId: string; organisation: { id: string; }; }, doing: string) { + const membership = await getMembership(context.userId, context.organisation.id); + if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") return `Only the organisation's owners and admins can ${doing}.`; + if (!(await hasRecentAuthentication())) return `For security, sign in again before you ${doing}.`; + return null; +} + +/** Retry a refund the provider refused (docs/PAYMENTS.md). */ +export async function retryFailedRefundAction(input: unknown): Promise { + const parsed = retrySchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Choose a refund." }; + const context = await access(parsed.data); + if ("error" in context) return { ok: false, error: context.error ?? "Event not found." }; + const refused = await ownerOrAdminRecently(context, "retry refunds"); + if (refused) return { ok: false, error: refused }; + const refund = await prisma.paymentRefund.findFirst({ where: { id: parsed.data.refundId, payment: { organisationId: context.organisation.id, order: context.scope } }, select: { id: true } }); + if (!refund) return { ok: false, error: "Refund not found." }; + return run(context.path, () => retryFailedRefund({ refundId: refund.id, organisationId: context.organisation.id, actorUserId: context.userId })); +} + +const endedRefundSchema = z.object({ + ...orderTarget, + /** Major units; blank refunds everything left. */ + amount: z.string().trim().max(20).default(""), + reason: z.string().trim().min(1, "Say why you're refunding.").max(500), +}).strict(); + +/** + * Refund money left on an ended order or a cancelled target (its tickets + * may be gone): the Refundable orders list (docs/PAYMENTS.md). Owners and + * admins after a fresh sign-in, like retrying a refund. + */ +export async function refundEndedOrderAction(input: unknown): Promise { + const parsed = endedRefundSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Check the refund." }; + const context = await access(parsed.data); + if ("error" in context) return { ok: false, error: context.error ?? "Event not found." }; + const refused = await ownerOrAdminRecently(context, "refund cancelled orders"); + if (refused) return { ok: false, error: refused }; + const order = await orderOf(context.scope, parsed.data.orderId); + if (!order) return { ok: false, error: "Order not found." }; + const amountMinor = parsed.data.amount ? parseMoneyToMinor(parsed.data.amount, order.currency) : null; + if (parsed.data.amount && amountMinor == null) return { ok: false, error: `Enter the amount in ${order.currency}, for example 250.` }; + return run(context.path, () => refundEndedOrder({ orderId: order.id, organisationId: context.organisation.id, actorUserId: context.userId, amountMinor, reason: parsed.data.reason })); +} + const settleSchema = z.object({ ...orderTarget, mode: z.enum(["offline", "waive"]), diff --git a/app/actions/rsvp-admin.ts b/app/actions/rsvp-admin.ts index fb58c9fa..0641b3ee 100644 --- a/app/actions/rsvp-admin.ts +++ b/app/actions/rsvp-admin.ts @@ -1,6 +1,6 @@ "use server"; -import { CREW_EMAIL_REFUSED, CREW_REGISTRATION_ERROR, lockedHasCrewEmail } from "@/lib/core/crew/shared"; +import { CREW_EMAIL_REFUSED, CREW_REGISTRATION_ERROR, hasCrewEmail } from "@/lib/core/crew/shared"; import { Prisma, RsvpStatus } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; @@ -19,10 +19,13 @@ import { revalidatePath, revalidateRsvpPaths, settleTeamAfterRemoval, teamDeleti import { restoreRsvp as restoreRsvpAction } from "./rsvp-admin/restore"; import { captureSubmissionSnapshotTx, type SubmissionSnapshot } from "@/lib/core/submissionLifecycle"; import { emitTeamWebhooks, type RegistrationStatusWebhook } from "@/lib/teamUnits"; +import { newTicketToken, retireHolderCapabilities } from "@/lib/rsvpHolder"; +import { replaceRegistrationAnswers } from "@/lib/admission/answers"; +import { AdmissionError } from "@/lib/admission/state"; export async function addManualRsvp( input: unknown, -): Promise> { +): Promise> { const session = await auth(); if (!session?.user?.id) return { ok: false, error: "You must be signed in." }; if (await isActionRateLimited("rsvp", session.user.id)) { @@ -92,45 +95,38 @@ export async function updateRsvpRegistration(input: unknown): Promise { - if (toCrewEmail && rsvp.eventId && (await lockedHasCrewEmail(tx, rsvp.eventId, [guestEmail]))) return CREW_EMAIL_REFUSED; + // The Event, then the registration (docs/PAYMENTS.md lock order); read again under the lock. + if (rsvp.eventId) await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${rsvp.eventId} FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "id" = ${rsvp.id} FOR UPDATE`; + const current = await tx.rSVP.findUnique({ where: { id: rsvp.id }, select: { userId: true, guestName: true, guestEmail: true, status: true, checkInToken: true, attendeeKey: true, teamId: true } }); + if (!current) return "RSVP not found."; + const guestEmail = current.userId ? current.guestEmail : parsed.data.guestEmail?.trim().toLowerCase() || current.guestEmail; + // A new address is a new holder: a new ticket link, and the old holder's + // attendance, submission and team links stop working (lib/rsvpHolder.ts). + const newHolder = Boolean(!current.userId && guestEmail && guestEmail !== current.guestEmail); + // Moving a ticket to a crew member's email would give them a second, capacity-taking place. + if (newHolder && rsvp.eventId && (await hasCrewEmail(tx, rsvp.eventId, [guestEmail]))) return CREW_EMAIL_REFUSED; + const checkInToken = newHolder ? newTicketToken() : current.checkInToken; + const attendeeKey = current.userId ? current.attendeeKey : `guest:${guestEmail}`; await tx.rSVP.update({ where: { id: rsvp.id }, - data: { - ...guestUpdate, - ...(rsvp.userId ? {} : { attendeeKey: `guest:${guestEmail}` }), - }, + data: current.userId ? {} : { guestName: parsed.data.name?.trim() || current.guestName, guestEmail, attendeeKey, checkInToken }, }); + if (newHolder) await retireHolderCapabilities(tx, rsvp.id); const title = rsvp.event?.title ?? rsvp.eventInstance?.series.title; - const live = rsvp.status === RsvpStatus.CONFIRMED || rsvp.status === RsvpStatus.WAITLISTED || rsvp.status === RsvpStatus.PENDING_APPROVAL; - if (guestEmail && guestEmail !== rsvp.guestEmail && title && live) { - await enqueueRsvpConfirmation(tx, { to: guestEmail, eventTitle: title, status: rsvp.status, checkInToken: rsvp.checkInToken }); - } - await tx.rsvpAnswer.deleteMany({ where: { rsvpId: rsvp.id } }); - if (validated.rows.length > 0) { - await tx.rsvpAnswer.createMany({ - data: validated.rows.map((row) => ({ - rsvpId: rsvp.id, - fieldId: row.fieldId, - valueText: row.valueText ?? null, - valueBool: row.valueBool ?? null, - valueNumber: row.valueNumber ?? null, - valueDate: row.valueDate ?? null, - })), - }); + const live = current.status === RsvpStatus.CONFIRMED || current.status === RsvpStatus.WAITLISTED || current.status === RsvpStatus.PENDING_APPROVAL; + if (newHolder && guestEmail && title && live) { + await enqueueRsvpConfirmation(tx, { to: guestEmail, eventTitle: title, status: current.status, checkInToken }); } + // Applications keep their answer rules and revisions (lib/admission/answers.ts). + await replaceRegistrationAnswers(tx, { id: rsvp.id, eventId: rsvp.eventId, teamId: current.teamId, attendeeKey }, validated.rows); return null; }); if (refused) return { ok: false, error: refused }; } catch (error) { + if (error instanceof AdmissionError) return { ok: false, error: error.message }; if (typeof error === "object" && error !== null && "code" in error && (error as { code?: string; }).code === "P2002") { return { ok: false, error: "This email is already registered for this event." }; } diff --git a/app/actions/rsvp-admin/restore.ts b/app/actions/rsvp-admin/restore.ts index 60db23b7..702935af 100644 --- a/app/actions/rsvp-admin/restore.ts +++ b/app/actions/rsvp-admin/restore.ts @@ -36,7 +36,12 @@ export async function restoreRsvp(input: unknown): Promise { if (!undo) return { ok: false, error: "This undo has expired or is unavailable." }; if (isTeamDeletionSnapshot(undo.payload)) { const teamEventId = (undo.payload as { eventId?: unknown }).eventId; - if (typeof teamEventId === "string" && (await prisma.event.count({ where: { id: teamEventId, admissionEnabled: true } }))) return { ok: false, error: ADMISSION_NO_RESTORE }; + if (typeof teamEventId !== "string" || !teamEventId) return { ok: false, error: "Could not restore the team." }; + // As for a single RSVP: the deleter's access may have changed since. + if (!(await canManageRegistrationsFor({ userId: session.user.id, organisationId: org.id, eventId: teamEventId }))) { + return { ok: false, error: "You do not have permission to restore RSVPs." }; + } + if (await prisma.event.count({ where: { id: teamEventId, admissionEnabled: true } })) return { ok: false, error: ADMISSION_NO_RESTORE }; const restoredTeam = await restoreTeamDeletionSnapshot(undo, org.id); if (!restoredTeam.ok || !restoredTeam.data) return restoredTeam.ok ? { ok: false, error: "Could not restore the team." } : restoredTeam; revalidateRsvpPaths(org.slug, { eventId: restoredTeam.data.eventId, eventInstanceId: null, eventSlug: restoredTeam.data.eventSlug }); diff --git a/app/actions/rsvp-lifecycle.ts b/app/actions/rsvp-lifecycle.ts index 0f46e026..ace0ccab 100644 --- a/app/actions/rsvp-lifecycle.ts +++ b/app/actions/rsvp-lifecycle.ts @@ -270,6 +270,7 @@ export async function promoteFromWaitlist(input: unknown): Promise if (result === "full") { return { ok: false, error: "Event is still at capacity.", code: "CAPACITY_FULL" }; } + if (result === "closed") return { ok: false, error: "This event was cancelled, so nobody can be promoted." }; if (result !== "confirmed") return { ok: false, error: "This RSVP was changed by another organiser." }; await recordAuditEvent({ action: "RSVP_WAITLIST_PROMOTED", actorUserId: session.user.id, organisationId: org.id, targetType: "RSVP", targetId: rsvp.id }); revalidateRsvpPaths({ @@ -292,6 +293,7 @@ export async function promoteFromWaitlist(input: unknown): Promise if (result === "full") { return { ok: false, error: "This occurrence is still at capacity.", code: "CAPACITY_FULL" }; } + if (result === "closed") return { ok: false, error: "This occurrence was cancelled, so nobody can be promoted." }; if (result !== "confirmed") return { ok: false, error: "This RSVP was changed by another organiser." }; await recordAuditEvent({ action: "RSVP_WAITLIST_PROMOTED", actorUserId: session.user.id, organisationId: org.id, targetType: "RSVP", targetId: rsvp.id }); revalidateRsvpPaths({ diff --git a/app/actions/rsvp.ts b/app/actions/rsvp.ts index 6967906a..ff56e3a2 100644 --- a/app/actions/rsvp.ts +++ b/app/actions/rsvp.ts @@ -11,7 +11,7 @@ const revalidatePath = trackedRevalidation(nextRevalidatePath); export async function submitRsvp( input: unknown, -): Promise> { +): Promise> { const session = await auth(); const subject = typeof input === "object" && input !== null && "guestEmail" in input && typeof (input as { guestEmail?: unknown; }).guestEmail === "string" diff --git a/app/actions/series.ts b/app/actions/series.ts index f68909cc..3388ce0d 100644 --- a/app/actions/series.ts +++ b/app/actions/series.ts @@ -1,6 +1,6 @@ "use server"; -import { EventStatus, RsvpStatus } from "@prisma/client"; +import { EventStatus } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { buildRecurrenceIcs, materializeInstances } from "@/lib/recurrence"; @@ -15,11 +15,15 @@ import type { ActionResult } from "./org"; import { flattenZodErrors } from "./utils"; import { recordAuditEvent } from "@/lib/audit"; import { isActionRateLimited } from "@/lib/actionRateLimit"; -import { hasPaidOrders, peopleWithPlaces } from "@/lib/payments/eventCancellation"; +import { hasPaidOrders, PAID_SERIES_REFUSAL, peopleWithPlaces } from "@/lib/payments/eventCancellation"; +import { fenceOrganisation } from "@/lib/payments/targets"; import { noticeEach, noticeSubject, uniqueEmails } from "@/lib/notices"; import { cancelOccurrence, setOccurrenceCapacity } from "@/lib/payments/occurrences"; import { PaymentOperationError } from "@/lib/payments/operations"; +import { cancelUpcomingOccurrences } from "@/lib/payments/occurrenceCancellation"; +import { seatTakingWhere } from "@/lib/payments/holds"; import { cancelDateSchema, capacitySchema, dateAccess, revalidateAllSeriesPaths, revalidatePath } from "./series/helpers"; +import { removeSeriesTx } from "./series/remove"; export async function createEventSeries( input: unknown, @@ -173,16 +177,21 @@ export async function updateEventSeriesMeta( if (!series) return { ok: false, error: "Series not found." }; const saved = await prisma.$transaction(async (tx) => { + await fenceOrganisation(tx, org.id); await tx.$queryRaw`SELECT "id" FROM "EventSeries" WHERE "id" = ${series.id} FOR UPDATE`; if (data.capacity != null) { + // Taken seats (confirmed, and paid holds still live), counted under the + // series lock every reservation takes first, with the time read after it. const counts = await tx.rSVP.groupBy({ by: ["eventInstanceId"], // Dates with their own capacity aren't bound by the series one. - where: { eventInstance: { eventSeriesId: series.id, capacity: null }, status: RsvpStatus.CONFIRMED }, + where: { eventInstance: { eventSeriesId: series.id, capacity: null }, ...seatTakingWhere(new Date()) }, _count: { _all: true }, }); if (counts.some((row) => row._count._all > data.capacity!)) return false; } + const upcoming = data.status === EventStatus.CANCELLED && series.status !== EventStatus.CANCELLED + ? await cancelUpcomingOccurrences(tx, { organisationId: org.id, seriesId: series.id, actorUserId: session.user.id, now: new Date() }) : []; await tx.eventSeries.update({ where: { id: series.id }, data: { @@ -194,18 +203,15 @@ export async function updateEventSeriesMeta( privacyType: data.privacyType, }, }); - return true; - }); - if (!saved) return { ok: false, error: "Capacity cannot be lower than confirmed attendance for an occurrence." }; + return upcoming; + }, { timeout: 60_000 }); + if (!saved) return { ok: false, error: "Capacity cannot be lower than the places already taken (or being paid for) on an occurrence." }; // Cancelling the series cancels each upcoming date: refunds, and an email to everyone on it. - if (data.status === EventStatus.CANCELLED && series.status !== EventStatus.CANCELLED) { - const upcoming = await prisma.eventInstance.findMany({ where: { eventSeriesId: series.id, endDateTime: { gt: new Date() }, cancelledAt: null }, select: { id: true }, orderBy: { startDateTime: "asc" } }); - for (const date of upcoming) { - await cancelOccurrence({ organisationId: org.id, seriesId: series.id, instanceId: date.id, actorUserId: session.user.id, reason: "The series was cancelled." }).catch((error: unknown) => { - if (!(error instanceof PaymentOperationError)) throw error; - }); - } + for (const date of saved) { + await cancelOccurrence({ organisationId: org.id, seriesId: series.id, instanceId: date.id, actorUserId: session.user.id, reason: "The series was cancelled." }).catch((error: unknown) => { + if (!(error instanceof PaymentOperationError)) throw error; + }); } await recordAuditEvent({ @@ -261,26 +267,31 @@ export async function deleteEventSeries(input: unknown): Promise { }); if (!series) return { ok: false, error: "Series not found." }; - // Deleting would erase the tickets of people who paid: cancel dates instead, - // which refunds them. - if (await hasPaidOrders({ eventSeriesId: series.id })) { - return { ok: false, error: "People have paid for this series. Cancel its dates instead, which refunds them." }; - } + // Retain organiser access to every order while any money is held or in flight. + if (await hasPaidOrders({ eventSeriesId: series.id })) return { ok: false, error: PAID_SERIES_REFUSAL }; const instances = await prisma.eventInstance.findMany({ where: { eventSeriesId: series.id }, select: { id: true }, }); - await prisma.$transaction(async (tx) => { + const refused = await prisma.$transaction(async (tx) => { + // The locks a reservation takes first (docs/PAYMENTS.md): the organisation + // fence, then the series row. A paid order committed before this is seen + // here; one that waits finds the series gone. + await fenceOrganisation(tx, org.id); + await tx.$queryRaw`SELECT "id" FROM "EventSeries" WHERE "id" = ${series.id} FOR UPDATE`; + if (await hasPaidOrders({ eventSeriesId: series.id }, tx)) return true; // People booked on upcoming dates are told the series won't go ahead. if (series.status === EventStatus.PUBLISHED) { const upcoming = await tx.eventInstance.findMany({ where: { eventSeriesId: series.id, endDateTime: { gt: new Date() }, cancelledAt: null }, select: { id: true } }); const people = uniqueEmails((await Promise.all(upcoming.map((date) => peopleWithPlaces(tx, { eventInstanceId: date.id })))).flat()); await noticeEach(tx, people, { organisationId: org.id, subject: noticeSubject("Cancelled", series.title), lines: [`${series.title} has been cancelled by the organiser, with all its upcoming dates.`] }); } - await tx.eventSeries.delete({ where: { id: series.id } }); + await removeSeriesTx(tx, series.id); + return false; }); + if (refused) return { ok: false, error: PAID_SERIES_REFUSAL }; await recordAuditEvent({ action: "EVENT_SERIES_DELETED", diff --git a/app/actions/series/remove.ts b/app/actions/series/remove.ts new file mode 100644 index 00000000..87941cdd --- /dev/null +++ b/app/actions/series/remove.ts @@ -0,0 +1,14 @@ +import "server-only"; + +import type { Prisma } from "@prisma/client"; + +/** Caller holds the organisation fence and series lock and has checked obligations. */ +export async function removeSeriesTx(tx: Prisma.TransactionClient, seriesId: string) { + await tx.$queryRaw`SELECT "id" FROM "EventInstance" WHERE "eventSeriesId" = ${seriesId} ORDER BY "id" FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "eventSeriesId" = ${seriesId} ORDER BY "id" FOR UPDATE`; + // The two SET NULL foreign keys can otherwise leave a date without its series + // between cascade statements, violating Order_one_target. Preserve fully + // refunded rows for organisation totals and buyer receipts (docs/PAYMENTS.md). + await tx.order.updateMany({ where: { eventSeriesId: seriesId }, data: { eventSeriesId: null, eventInstanceId: null } }); + await tx.eventSeries.delete({ where: { id: seriesId } }); +} diff --git a/app/api/check-in/station/route.ts b/app/api/check-in/station/route.ts index c95ed064..e647a4b6 100644 --- a/app/api/check-in/station/route.ts +++ b/app/api/check-in/station/route.ts @@ -13,6 +13,9 @@ import { commitCheckInProjection, commitUndoCheckInProjection } from "@/lib/chec import { listRecentCheckIns } from "@/lib/checkInRecent"; import { badgeBrandingFor, badgeDesignFor, loadBadgeEvent } from "@/lib/badge/data"; import { attendeePhotoUrl } from "@/lib/badge/imageUrl"; +import { readBoundedJson } from "@/lib/boundedBody"; + +const MAX_BODY_BYTES = 128 * 1024; const bodySchema = z.object({ organisationSlug: z.string().trim().min(1).max(120), eventSlug: z.string().trim().min(1).max(160), @@ -40,8 +43,11 @@ function toData(rsvp: StationRsvp, force = false) { } export async function POST(request: Request) { - let input: z.infer; - try { input = bodySchema.parse(await request.json()); } catch { return failure("Invalid request."); } + const read = await readBoundedJson(request, MAX_BODY_BYTES); + if (read === "too_large") return failure("Request is too large.", 413); + const parsed = read === "invalid" ? null : bodySchema.safeParse(read.value); + if (!parsed?.success) return failure("Invalid request."); + const input = parsed.data; const event = await prisma.event.findFirst({ where: { slug: input.eventSlug, organisation: { slug: input.organisationSlug } }, select: { id: true, title: true, slug: true, organisationId: true, startDateTime: true, endDateTime: true, checkInStationPinHash: true, checkInStationSecretVersion: true, organisation: { select: { slug: true, name: true } } } }); if (!event || !event.checkInStationPinHash) return failure(); if (input.action === "unlock") { diff --git a/app/api/client-errors/route.ts b/app/api/client-errors/route.ts index 05d4e97b..41e89a96 100644 --- a/app/api/client-errors/route.ts +++ b/app/api/client-errors/route.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { readBoundedJson } from "@/lib/boundedBody"; import { reportOpsAlert, sanitizeAlertPath } from "@/lib/opsAlerts"; import { checkRateLimitById, getClientIpFromHeaders } from "@/lib/rateLimit"; @@ -28,15 +29,10 @@ export async function POST(request: Request) { if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return noContent(); if (!(await checkRateLimitById("clientError", `ip:${getClientIpFromHeaders(request.headers)}`))) return noContent(); - let body: unknown; - try { - const text = await request.text(); - if (text.length > MAX_BODY_BYTES) return noContent(); - body = JSON.parse(text); - } catch { - return noContent(); - } - const parsed = reportSchema.safeParse(body); + // Read with a hard cap, so a chunked body without Content-Length stops at 4 KiB. + const body = await readBoundedJson(request, MAX_BODY_BYTES); + if (typeof body === "string") return noContent(); + const parsed = reportSchema.safeParse(body.value); if (!parsed.success) return noContent(); const { boundary, name, message, path } = parsed.data; diff --git a/app/api/exports/events/[eventId]/attendees/route.ts b/app/api/exports/events/[eventId]/attendees/route.ts index 895aeadb..2e32d8aa 100644 --- a/app/api/exports/events/[eventId]/attendees/route.ts +++ b/app/api/exports/events/[eventId]/attendees/route.ts @@ -1,4 +1,4 @@ -import { EventPermission, type Prisma } from "@prisma/client"; +import { EventPermission, RegistrationFieldType, type Prisma } from "@prisma/client"; import { auth } from "@/lib/auth"; import { recordAuditEvent } from "@/lib/audit"; import { csvStreamResponse, exportFilename, exportFilterNames, exportNotFound } from "@/lib/csvExport"; @@ -29,7 +29,9 @@ function isFiltered(view: AttendeeListView) { return view.status !== "all" || view.kind !== "all" || view.team !== "all" || view.attendance !== "all" || Boolean(view.query); } -function answerValue(answer: { valueText: string | null; valueBool: boolean | null; valueNumber: number | null; valueDate: Date | null }) { +type StoredAnswer = { fieldId: string; valueText: string | null; valueBool: boolean | null; valueNumber: number | null; valueDate: Date | null }; + +function answerValue(answer: StoredAnswer) { return answer.valueText ?? (answer.valueBool == null ? null : answer.valueBool ? "Yes" : "No") ?? (answer.valueNumber == null ? null : String(answer.valueNumber)) @@ -37,6 +39,32 @@ function answerValue(answer: { valueText: string | null; valueBool: boolean | nu ?? ""; } +type AnswerColumn = { id: string; multi: boolean; rank: Map }; + +/** One column per question; a multi-select's options are ranked in the question's order. */ +function answerColumns(fields: ReadonlyArray<{ id: string; type: RegistrationFieldType; options: Prisma.JsonValue }>): AnswerColumn[] { + return fields.map((field) => { + const options = Array.isArray(field.options) ? field.options.filter((option): option is string => typeof option === "string").map((option) => option.trim()) : []; + return { id: field.id, multi: field.type === RegistrationFieldType.MULTI_SELECT, rank: new Map(options.map((option, index) => [option, index])) }; + }); +} + +/** + * One cell per question. A multi-select keeps one answer row per choice, so + * its cell joins every choice with "; ": the question's options in their + * order, then any other text ("Other", or an option since removed) by value. + */ +function answerCells(columns: AnswerColumn[], answers: ReadonlyArray) { + const byField = new Map(); + for (const answer of answers) byField.set(answer.fieldId, [...(byField.get(answer.fieldId) ?? []), answerValue(answer)]); + return columns.map((column) => { + const values = byField.get(column.id) ?? []; + if (!column.multi) return values[0] ?? ""; + const rank = (value: string) => column.rank.get(value) ?? column.rank.size; + return values.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)).join("; "); + }); +} + /** Team name and role for one registration; solo registrants get an empty team and "Solo", crew "Crew". */ function teamCells(rsvp: { id: string; isCrew: boolean; team?: { name: string; leaderRsvpId: string | null } | null }) { if (rsvp.isCrew) return ["", "Crew"]; @@ -62,7 +90,7 @@ export async function GET(request: Request, context: RouteContext) { organisationId: true, registrationMode: true, registrationForm: { - select: { fields: { select: { id: true, label: true }, orderBy: { sortOrder: "asc" } } }, + select: { fields: { select: { id: true, label: true, type: true, options: true }, orderBy: { sortOrder: "asc" } } }, }, // One column per custom check-in list, after the registration answers, // so existing columns keep their positions. @@ -79,6 +107,7 @@ export async function GET(request: Request, context: RouteContext) { if (!(await canAccessEvent({ userId: session.user.id, organisationId: event.organisationId, eventId: event.id, permission: EventPermission.MANAGE_REGISTRATIONS }))) return exportNotFound(); const fields = event.registrationForm?.fields ?? []; + const columns = answerColumns(fields); // TEAM events add Team and Team role columns (docs/TEAMS.md); other exports keep their columns. const teamColumns = event.registrationMode === "TEAM"; const lists = event.checkInLists; @@ -130,7 +159,6 @@ export async function GET(request: Request, context: RouteContext) { if (!rsvps.length) break; const rows = rsvps.map((rsvp) => { - const answers = new Map(rsvp.answers.map((answer) => [answer.fieldId, answerValue(answer)])); const listCheckIns = new Map(rsvp.checkInListRecords.map((record) => [record.listId, record.checkedInAt.toISOString()])); return [ rsvp.user?.name ?? rsvp.guestName ?? "", @@ -145,7 +173,7 @@ export async function GET(request: Request, context: RouteContext) { rsvp.order?.reference ?? "", rsvp.order?.status ?? "", rsvp.order ? `${rsvp.order.currency} ${minorToDecimalString(rsvp.order.totalMinor, rsvp.order.currency)}` : "", - ...fields.map((field) => answers.get(field.id) ?? ""), + ...answerCells(columns, rsvp.answers), ...lists.map((list) => listCheckIns.get(list.id) ?? ""), ...attendanceCsvCells(rsvp, { showNotAsked: attendanceAsked }), ]; diff --git a/app/api/exports/events/[eventId]/finance/route.ts b/app/api/exports/events/[eventId]/finance/route.ts index ae9580ec..87cffd42 100644 --- a/app/api/exports/events/[eventId]/finance/route.ts +++ b/app/api/exports/events/[eventId]/finance/route.ts @@ -36,7 +36,7 @@ export async function GET(_request: Request, context: RouteContext) { prisma.payment.findMany({ where: { order: { eventId }, status: { in: [PaymentStatus.PAID_ONLINE, PaymentStatus.PAID_OFFLINE] } }, select: { - amountMinor: true, currency: true, paidAt: true, createdAt: true, provider: true, offlineMethod: true, methodSummary: true, description: true, + amountMinor: true, currency: true, paidAt: true, createdAt: true, provider: true, offlineMethod: true, methodSummary: true, description: true, testMode: true, status: true, order: { select: { reference: true, buyerName: true, submissionId: true } }, refunds: { where: { status: { in: [PaymentRefundStatus.PENDING, PaymentRefundStatus.SUCCEEDED] } }, select: { amountMinor: true, settledAt: true, createdAt: true, offline: true, status: true } }, }, @@ -49,10 +49,12 @@ export async function GET(_request: Request, context: RouteContext) { for (const payment of payments) { const at = payment.paidAt ?? payment.createdAt; const method = payment.provider === "OFFLINE" || payment.offlineMethod ? offlineMethodLabel(payment.offlineMethod ?? "OTHER") : payment.methodSummary ?? payment.provider; - rows.push({ at, cells: [at.toISOString(), payment.order.submissionId ? "Entry fee" : "Ticket payment", payment.order.buyerName, payment.description, amount(payment.amountMinor, payment.currency, 1), payment.currency, method, payment.order.reference] }); + // Test-key payments moved no real money: labelled, as on the Finance tab. + const test = payment.testMode && payment.status === PaymentStatus.PAID_ONLINE ? " (test)" : ""; + rows.push({ at, cells: [at.toISOString(), `${payment.order.submissionId ? "Entry fee" : "Ticket payment"}${test}`, payment.order.buyerName, payment.description, amount(payment.amountMinor, payment.currency, 1), payment.currency, method, payment.order.reference] }); for (const refund of payment.refunds) { const refundAt = refund.settledAt ?? refund.createdAt; - rows.push({ at: refundAt, cells: [refundAt.toISOString(), refund.status === PaymentRefundStatus.PENDING ? "Refund (pending)" : "Refund", payment.order.buyerName, payment.description, amount(refund.amountMinor, payment.currency, -1), payment.currency, refund.offline ? "In person" : method, payment.order.reference] }); + rows.push({ at: refundAt, cells: [refundAt.toISOString(), `${refund.status === PaymentRefundStatus.PENDING ? "Refund (pending)" : "Refund"}${test}`, payment.order.buyerName, payment.description, amount(refund.amountMinor, payment.currency, -1), payment.currency, refund.offline ? "In person" : method, payment.order.reference] }); } } for (const payout of claimPayouts) { diff --git a/app/api/exports/events/[eventId]/volunteer-hours/route.ts b/app/api/exports/events/[eventId]/volunteer-hours/route.ts index 74f4be2f..6311c22f 100644 --- a/app/api/exports/events/[eventId]/volunteer-hours/route.ts +++ b/app/api/exports/events/[eventId]/volunteer-hours/route.ts @@ -3,7 +3,7 @@ import { recordAuditEvent } from "@/lib/audit"; import { resolveOrganiserEvent } from "@/lib/core/teams"; import { csvStreamResponse, exportFilename, exportNotFound } from "@/lib/csvExport"; import { prisma } from "@/lib/db"; -import { VOLUNTEER_HOURS_HEADER, volunteerHoursRows } from "@/lib/volunteer/export"; +import { VOLUNTEER_HOURS_HEADER, volunteerHoursBatches } from "@/lib/volunteer/export"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -27,13 +27,24 @@ export async function GET(_request: Request, context: RouteContext) { if (!event) return exportNotFound(); const ctx = await resolveOrganiserEvent(userId, event.organisation.slug, event.id); if (!ctx) return exportNotFound(); - const rows = await volunteerHoursRows(ctx.eventId); - await recordAuditEvent({ action: "VOLUNTEER_HOURS_EXPORTED", actorUserId: userId, organisationId: ctx.organisationId, targetType: "Event", targetId: ctx.eventId, metadata: { rows: rows.length } }); return csvStreamResponse({ header: [...VOLUNTEER_HOURS_HEADER], filename: exportFilename("volunteer-hours", event.title), errorMessage: "Unable to export volunteer hours.", - // One bounded batch: at most 200 shifts of at most 500 crew each, read above. - batches: async function* batches() { yield rows; }, + // Every sign-up, a page at a time. The audit counts the rows actually sent, + // and records a stopped or failed download too (`complete: false`). + batches: async function* batches() { + let rows = 0; + let complete = false; + try { + for await (const batch of volunteerHoursBatches(ctx.eventId)) { + rows += batch.length; + yield batch; + } + complete = true; + } finally { + await recordAuditEvent({ action: "VOLUNTEER_HOURS_EXPORTED", actorUserId: userId, organisationId: ctx.organisationId, targetType: "Event", targetId: ctx.eventId, metadata: { rows, complete } }); + } + }, }); } diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts index d937daeb..5d3835be 100644 --- a/app/api/feedback/route.ts +++ b/app/api/feedback/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from "next/server"; import { checkRateLimitById, getClientIpFromHeaders } from "@/lib/rateLimit"; import { submitFeedback } from "@/lib/feedback"; import { withApiMonitoring } from "@/lib/apiMonitor"; +import { optionalMobileUser } from "@/lib/mobileAuth"; +import { runAsRequestActor } from "@/lib/requestActor"; const MAX_FEEDBACK_BODY_BYTES = 128 * 1024; @@ -46,6 +48,10 @@ export const POST = withApiMonitoring("POST /api/feedback", async (request: Requ const subject = crypto.createHash("sha256").update(email).digest("hex").slice(0, 32); if (!(await checkRateLimitById("feedback", `email:${subject}`))) return json({ ok: false, error: "Too many attempts. Try again shortly." }, 429); } - const result = await submitFeedback(body); + // The website sends its session cookie; the app may send its bearer, so an + // invited guest can answer an invite-only event's form (lib/feedback.ts). + const appUser = await optionalMobileUser(request); + if (request.headers.get("authorization") && !appUser) return json({ ok: false, error: "Sign in again to send feedback." }, 401); + const result = appUser ? await runAsRequestActor(appUser, () => submitFeedback(body)) : await submitFeedback(body); return json(result, result.ok ? 200 : 400); }); diff --git a/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts b/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts index 9a64c0bc..eb6be495 100644 --- a/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts +++ b/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts @@ -2,6 +2,7 @@ import { submitParticipantClaimAction } from "@/app/actions/participant-claims"; import { mobileActionError, mobileOk, readFormBody, notFound } from "@/lib/mobile/http"; import { loadMyParticipantClaims, ownRegistration } from "@/lib/mobile/attendee"; import { authedMobileRoute } from "@/lib/mobile/route"; +import { claimFiledDtoSchema } from "@/lib/mobile/schemas"; /** Three 5 MB receipt photos and the fields; read with a hard cap before parsing. */ const MAX_BYTES = 16 * 1024 * 1024; @@ -16,7 +17,8 @@ export const GET = authedMobileRoute<{ rsvpId: string }>("GET /api/mobile/v1/me/ * File a claim: multipart form with `category`, `description`, `amount`, an * optional `policyId` (a reimbursement policy from GET …/reimbursements) and * up to three `receipts` photos, exactly as the ticket page. Receipts are - * re-encoded and stored privately. + * re-encoded and stored privately. Answers the claim list, or only the new + * claim's id when the claim was filed but the list could not be read back. */ export const POST = authedMobileRoute<{ rsvpId: string }>("POST /api/mobile/v1/me/registrations/:rsvpId/claims", async (request, { rsvpId }, { auth }) => { const rsvp = await ownRegistration(auth.userId, rsvpId); @@ -30,5 +32,9 @@ export const POST = authedMobileRoute<{ rsvpId: string }>("POST /api/mobile/v1/m for (const file of form.getAll("receipts")) if (file instanceof File) fields.append("receipts", file); const result = await submitParticipantClaimAction(fields); if (!result.ok) return mobileActionError(result); - return mobileOk(await loadMyParticipantClaims(auth.userId, rsvpId), 201); + const claims = await loadMyParticipantClaims(auth.userId, rsvpId).catch(() => undefined); + if (claims !== undefined) return mobileOk(claims, 201); + // The claim is filed: answer with its id rather than a 5xx, whose outcome a retry could not tell. + console.error("[mobile-api] claim filed but its list could not be read back"); + return mobileOk(claimFiledDtoSchema.parse({ claimId: result.data?.claimId }), 201); }, { idempotent: { maxBytes: MAX_BYTES } }); diff --git a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts index 5187be26..cc29fd0a 100644 --- a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts +++ b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts @@ -6,7 +6,7 @@ import { authedMobileRoute } from "@/lib/mobile/route"; type Params = { orgSlug: string; eventId: string; assignmentId: string }; const POST_ROUTE = "POST /api/mobile/v1/orgs/:orgSlug/events/:eventId/applications/reviews/:assignmentId"; -const bodySchema = z.object({ scores: z.record(z.string().max(60), z.number()), notes: z.string().max(2000) }).strict(); +const bodySchema = z.object({ answersRevision: z.number().int().min(1), scores: z.record(z.string().max(60), z.number()), notes: z.string().max(2000) }).strict(); /** * One assigned application, as the stage allows: a blind stage shows no @@ -23,8 +23,10 @@ export const GET = authedMobileRoute("GET /api/mobile/v1/orgs/:orgSlug/e /** * Submit the review: every criterion scored 1 to 5 and optional notes for * the organisers. Reviews are final once submitted. Only while the stage is - * open and the application is in review there. Needs an Idempotency-Key, - * scoped to this assignment. + * open and the application is in review there. `answersRevision` is the + * one GET returned: answers changed since are CONFLICT ("The answers + * changed. Reload to see them."), and the reloaded submission needs a new + * Idempotency-Key, scoped to this assignment. */ export const POST = authedMobileRoute(POST_ROUTE, async (request, { orgSlug, eventId, assignmentId }, { auth }) => { const ctx = await resolveMobileReviewer(auth.userId, orgSlug, eventId); diff --git a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/review/route.ts b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/review/route.ts index 4c16e08f..f64ab0d0 100644 --- a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/review/route.ts +++ b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/review/route.ts @@ -4,7 +4,7 @@ import { saveJudgeScores, submitJudgeReview } from "@/lib/core/judging"; import { mobileActionError, mobileError, parseJsonBody, zodFieldErrors } from "@/lib/mobile/http"; import { judgeDetailResponse, judgingRoute } from "@/lib/mobile/judging"; import { judgeReviewSchema } from "@/lib/validators"; -import { currentRoundReview, refuseUnlessCurrentRound } from "@/lib/mobile/judgeReview"; +import { pinnedRoundReview, pinScoringRound } from "@/lib/mobile/judgeReview"; type Params = { orgSlug: string; eventId: string; submissionId: string }; @@ -20,24 +20,24 @@ const bodySchema = z.object({ * the project as `GET …/judging/submissions/:id`. */ export const POST = judgingRoute("POST /api/mobile/v1/orgs/:orgSlug/events/:eventId/judging/submissions/:submissionId/review", "action", async (request, { submissionId }, { judge }) => { - // Scores and reviews are written to the current round only. - const notCurrent = await refuseUnlessCurrentRound(judge, new URL(request.url).searchParams.get("roundId")); - if (notCurrent) return notCurrent; + // Scores and reviews are written to the current round only, the same one for the whole request. + const pin = await pinScoringRound(judge, new URL(request.url).searchParams.get("roundId")); + if ("response" in pin) return pin.response; const body = await parseJsonBody(request, bodySchema); if ("response" in body) return body.response; const { scores, comment } = body.data; const review = judgeReviewSchema.safeParse({ submissionId, ...(comment !== undefined ? { comment } : {}), submit: true }); if (!review.success) return mobileError("INVALID_REQUEST", "Check the highlighted fields.", { fieldErrors: zodFieldErrors(review.error) }); if (scores && scores.length > 0) { - const saved = await saveJudgeScores(judge, { submissionId, scores }); + const saved = await saveJudgeScores(judge, { submissionId, scores }, pin.roundId); if (!saved.ok) return mobileActionError(saved); } // Without a new comment, keep the saved draft comment. const existing = comment === undefined - ? (await currentRoundReview(judge, submissionId))?.comment ?? "" + ? (await pinnedRoundReview(judge, pin.roundId, submissionId))?.comment ?? "" : review.data.comment; - const submitted = await submitJudgeReview(judge, { submissionId, comment: existing, submit: true }); + const submitted = await submitJudgeReview(judge, { submissionId, comment: existing, submit: true }, pin.roundId); revalidatePath(`/dashboard/${judge.orgSlug}/event/${judge.eventId}`); if (!submitted.ok) return mobileActionError(submitted); - return judgeDetailResponse(judge, submissionId); + return judgeDetailResponse(judge, submissionId, pin.roundId ?? undefined); }, { idempotent: true }); diff --git a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts index e993206b..3dedd5d5 100644 --- a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts +++ b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts @@ -6,7 +6,7 @@ import { mobileActionError, mobileError, mobileOk, parseJsonBody, zodFieldErrors import { judgingRoute } from "@/lib/mobile/judging"; import { judgeScoresSavedDtoSchema } from "@/lib/mobile/schemas"; import { judgeReviewSchema } from "@/lib/validators"; -import { currentRoundReview, refuseUnlessCurrentRound } from "@/lib/mobile/judgeReview"; +import { pinnedRoundReview, pinScoringRound } from "@/lib/mobile/judgeReview"; type Params = { orgSlug: string; eventId: string; submissionId: string }; @@ -19,12 +19,13 @@ const bodySchema = z.object({ * Save some or all of this judge's scores (and, optionally, the private * comment as a draft) without submitting. The core re-checks the judge, * judging status (JUDGING_CLOSED), visibility (NOT_VISIBLE) and each - * criterion's range inside its transaction. + * criterion's range inside its transaction. Both writes and the response + * read use one round, pinned at the start (JUDGING_CLOSED if it changes). */ export const PUT = judgingRoute("PUT /api/mobile/v1/orgs/:orgSlug/events/:eventId/judging/submissions/:submissionId/scores", "action", async (request, { submissionId }, { judge }) => { - // Scores and reviews are written to the current round only. - const notCurrent = await refuseUnlessCurrentRound(judge, new URL(request.url).searchParams.get("roundId")); - if (notCurrent) return notCurrent; + // Scores and reviews are written to the current round only, the same one for the whole request. + const pin = await pinScoringRound(judge, new URL(request.url).searchParams.get("roundId")); + if ("response" in pin) return pin.response; const body = await parseJsonBody(request, bodySchema); if ("response" in body) return body.response; const { scores, comment } = body.data; @@ -33,14 +34,14 @@ export const PUT = judgingRoute("PUT /api/mobile/v1/orgs/:orgSlug/events const review = judgeReviewSchema.safeParse({ submissionId, comment, submit: false }); if (!review.success) return mobileError("INVALID_REQUEST", "Check the highlighted fields.", { fieldErrors: zodFieldErrors(review.error) }); } - const saved = await saveJudgeScores(judge, { submissionId, scores }); + const saved = await saveJudgeScores(judge, { submissionId, scores }, pin.roundId); if (!saved.ok) return mobileActionError(saved); if (comment !== undefined) { - const draft = await submitJudgeReview(judge, { submissionId, comment, submit: false }); + const draft = await submitJudgeReview(judge, { submissionId, comment, submit: false }, pin.roundId); if (!draft.ok) return mobileActionError(draft); } revalidatePath(`/dashboard/${judge.orgSlug}/event/${judge.eventId}`); - const review = await currentRoundReview(judge, submissionId); + const review = await pinnedRoundReview(judge, pin.roundId, submissionId); if (!saved.data) return mobileError("INTERNAL_ERROR"); return mobileOk(judgeScoresSavedDtoSchema.parse({ scored: saved.data.scored, diff --git a/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts b/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts index 6a6861c4..1fb1fe59 100644 --- a/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts +++ b/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts @@ -12,4 +12,6 @@ export const GET = mobileRoute<{ orgSlug: string; eventSlug: string }>( const form = await loadPublicFeedbackForm(orgSlug.data, eventSlug.data); return form ? mobileOk(form) : notFound(); }, + // A signed-in invited guest may open an invite-only event's form. + { optionalAuth: true }, ); diff --git a/app/api/reports/event/[eventId]/route.ts b/app/api/reports/event/[eventId]/route.ts index 7aa9ec95..c6397c05 100644 --- a/app/api/reports/event/[eventId]/route.ts +++ b/app/api/reports/event/[eventId]/route.ts @@ -1,27 +1,27 @@ import { auth } from "@/lib/auth"; import { recordAuditEvent } from "@/lib/audit"; -import { createEventReportPdf, getEventReportMetrics, isReportAvailable, reportFilename } from "@/lib/eventReport"; -import { getMembership, isOrgAdmin } from "@/lib/permissions"; +import { createEventReportPdf, getEventReportMetrics, reportFilename } from "@/lib/eventReport"; +import { reportAccess } from "@/lib/eventReport/access"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -function missing() { - return new Response(null, { status: 404, headers: { "Cache-Control": "private, no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" } }); +function denied(status: 404 | 429 = 404) { + return new Response(null, { status, headers: { "Cache-Control": "private, no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", ...(status === 429 ? { "Retry-After": "600" } : {}) } }); } type ReportRouteContext = { params: Promise<{ eventId: string }> }; export async function GET(_request: Request, context: ReportRouteContext) { const session = await auth(); - if (!session?.user?.id) return missing(); + if (!session?.user?.id) return denied(); const { eventId } = await context.params; + // Allowance, ownership and availability first: the metrics read the whole event. + const access = await reportAccess(session.user.id, { eventId }); + if (!access.ok) return denied(access.status); const metrics = await getEventReportMetrics(eventId); - if (!metrics || !isReportAvailable(metrics.endDateTime)) return missing(); - - const membership = await getMembership(session.user.id, metrics.organisationId); - if (!membership || !isOrgAdmin(membership.role)) return missing(); + if (!metrics || metrics.organisationId !== access.organisationId) return denied(); const pdf = await createEventReportPdf(metrics); await recordAuditEvent({ action: "EVENT_REPORT_DOWNLOADED", actorUserId: session.user.id, organisationId: metrics.organisationId, targetType: "Event", targetId: metrics.targetId }); diff --git a/app/api/reports/instance/[instanceId]/route.ts b/app/api/reports/instance/[instanceId]/route.ts index d45129fd..219fd1c8 100644 --- a/app/api/reports/instance/[instanceId]/route.ts +++ b/app/api/reports/instance/[instanceId]/route.ts @@ -1,27 +1,27 @@ import { auth } from "@/lib/auth"; import { recordAuditEvent } from "@/lib/audit"; -import { createEventReportPdf, getOccurrenceReportMetrics, isReportAvailable, reportFilename } from "@/lib/eventReport"; -import { getMembership, isOrgAdmin } from "@/lib/permissions"; +import { createEventReportPdf, getOccurrenceReportMetrics, reportFilename } from "@/lib/eventReport"; +import { reportAccess } from "@/lib/eventReport/access"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -function missing() { - return new Response(null, { status: 404, headers: { "Cache-Control": "private, no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" } }); +function denied(status: 404 | 429 = 404) { + return new Response(null, { status, headers: { "Cache-Control": "private, no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", ...(status === 429 ? { "Retry-After": "600" } : {}) } }); } type ReportRouteContext = { params: Promise<{ instanceId: string }> }; export async function GET(_request: Request, context: ReportRouteContext) { const session = await auth(); - if (!session?.user?.id) return missing(); + if (!session?.user?.id) return denied(); const { instanceId } = await context.params; + // Allowance, ownership and availability first: the metrics read the whole event. + const access = await reportAccess(session.user.id, { instanceId }); + if (!access.ok) return denied(access.status); const metrics = await getOccurrenceReportMetrics(instanceId); - if (!metrics || !isReportAvailable(metrics.endDateTime)) return missing(); - - const membership = await getMembership(session.user.id, metrics.organisationId); - if (!membership || !isOrgAdmin(membership.role)) return missing(); + if (!metrics || metrics.organisationId !== access.organisationId) return denied(); const pdf = await createEventReportPdf(metrics); await recordAuditEvent({ action: "EVENT_OCCURRENCE_REPORT_DOWNLOADED", actorUserId: session.user.id, organisationId: metrics.organisationId, targetType: "EventInstance", targetId: metrics.targetId }); diff --git a/app/api/rsvp/route.ts b/app/api/rsvp/route.ts index c96974ad..aece8e40 100644 --- a/app/api/rsvp/route.ts +++ b/app/api/rsvp/route.ts @@ -7,14 +7,16 @@ import { flattenZodErrors } from "@/app/actions/utils"; import { withApiMonitoring } from "@/lib/apiMonitor"; import crypto from "node:crypto"; import { checkRateLimitById, getClientIpFromHeaders } from "@/lib/rateLimit"; +import { readBoundedJson } from "@/lib/boundedBody"; + +// The same cap as the app's registration route (POST /api/mobile/v1/rsvp). +const MAX_BODY_BYTES = 128 * 1024; export const POST = withApiMonitoring("POST /api/rsvp", async (request: Request) => { - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ ok: false, error: "Invalid JSON." }, { status: 400 }); - } + const read = await readBoundedJson(request, MAX_BODY_BYTES); + if (read === "too_large") return NextResponse.json({ ok: false, error: "Request is too large." }, { status: 413 }); + if (read === "invalid") return NextResponse.json({ ok: false, error: "Invalid JSON." }, { status: 400 }); + const body = read.value; const guestEmail = typeof body === "object" && body !== null && "guestEmail" in body && typeof (body as { guestEmail?: unknown }).guestEmail === "string" ? (body as { guestEmail: string }).guestEmail.trim().toLowerCase() diff --git a/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx b/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx index da99bd3c..90c5676c 100644 --- a/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx +++ b/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx @@ -10,7 +10,9 @@ import { TicketTypesManager } from "@/components/event-dashboard/settings/Ticket import { AddOnsManager } from "@/components/event-dashboard/settings/AddOnsManager"; import { PromoCodesManager, RefundPolicyControl, TransfersControl } from "@/components/event-dashboard/settings/PromoCodesManager"; import { EventReportDownloadButton } from "@/components/reports/EventReportDownloadButton"; -import { Card, PageHeader } from "@/components/ui/display"; +import { Card, CardHeader, PageHeader } from "@/components/ui/display"; +import { FailedRefundsList } from "@/components/payments/FailedRefundsList"; +import { RefundableOrdersList } from "@/components/payments/RefundableOrdersList"; import { formatDayTime } from "@/lib/format/datetime"; import type { Metadata } from "next"; import { cachedDateTimeFormat } from "@/lib/format/intlCache"; @@ -27,7 +29,7 @@ function eventDateInput(date: Date, timeZone: string) { } export default async function SeriesManagePage({ params }: Props) { - const { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, now, attendeesPage, rollup, dates, tickets, revenue } = await loadSeriesManageView({ params }); + const { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, now, attendeesPage, rollup, dates, pastCancelledDates, tickets, revenue, failedRefunds, refundableOrders } = await loadSeriesManageView({ params }); return (
@@ -65,6 +67,13 @@ export default async function SeriesManagePage({ params }: Props) { + {pastCancelledDates.length > 0 ? ( +
+

Past cancelled dates

+ +
+ ) : null} + {tickets ? (
: null} + {failedRefunds.length > 0 ? ( + + Failed refunds} description="Refunds the payment provider refused. Check your balance with the provider, then retry." /> + + + ) : null} + + {isAdmin && (tickets || refundableOrders.length > 0) ? ( + + Refundable orders} description="Cancelled or expired orders with money left on them. Confirmed orders are refunded from the guest list." /> + + + ) : null} + {canDownloadReports ? (

Reports

diff --git a/app/super-admin/operations/page.tsx b/app/super-admin/operations/page.tsx index e0f3e2e3..2d3bfd2b 100644 --- a/app/super-admin/operations/page.tsx +++ b/app/super-admin/operations/page.tsx @@ -27,12 +27,21 @@ function safeErrorSummary(value: string | null) { export default async function SuperAdminOperationsPage() { await requireSuperAdminMfa(); - const { prerequisites, backupProvider, backupFresh, backupRetentionDays, reportedBackupAt, lastRestoreDrill, outbox, failedCount, expiredTokens, schedulerFresh, schedulerHeartbeat, beatFresh, beatHeartbeat, oldestPending, failedMessages, serverBackup, serverBackupProblem } = await loadSuperAdminOperationsView(); + const { storageDeletions, prerequisites, backupProvider, backupFresh, backupRetentionDays, reportedBackupAt, lastRestoreDrill, outbox, failedCount, expiredTokens, schedulerFresh, schedulerHeartbeat, beatFresh, beatHeartbeat, oldestPending, failedMessages, serverBackup, serverBackupProblem } = await loadSuperAdminOperationsView(); const time = (value: string | null) => (value ? `${new Date(value).toLocaleString()} (${ageLabel(new Date(value))})` : "none yet"); return (
+ {storageDeletions.misplaced + storageDeletions.stillPresent + storageDeletions.failing > 0 ? ( + +
    + {storageDeletions.misplaced > 0 ?
  • {storageDeletions.misplaced} file{storageDeletions.misplaced === 1 ? " belongs" : "s belong"} to a storage bucket that is no longer configured. They are kept and never deleted from the current bucket; restore the earlier storage settings or remove them there by hand.
  • : null} + {storageDeletions.stillPresent > 0 ?
  • {storageDeletions.stillPresent} deleted file{storageDeletions.stillPresent === 1 ? " was" : "s were"} still there when checked twice. Something keeps deleted files in the private bucket, such as an object lock or retention rule: remove it.
  • : null} + {storageDeletions.failing > 0 ?
  • {storageDeletions.failing} file{storageDeletions.failing === 1 ? "" : "s"} could not be deleted after several attempts. Check the storage credentials and bucket permissions.
  • : null} +
+
+ ) : null} {serverBackup ? (
@@ -44,7 +53,7 @@ export default async function SuperAdminOperationsPage() {
{serverBackupProblem ?

{serverBackupProblem.detail} The super admin is emailed once about it.

: null}

Newest full backup: {time(serverBackup.lastFullSuccessAt)}. Newest of any kind: {time(serverBackup.lastSuccessAt)}.

-

Last run: {time(serverBackup.lastAttemptAt)}, {serverBackup.lastStatus === "ok" ? `succeeded (${serverBackup.lastKind === "full" ? "database, configuration and uploads" : "database and configuration"}, ${(serverBackup.sizeBytes / 1_048_576).toFixed(1)} MB)` : "failed"}. Kept on the server for {serverBackup.keepDays} days.

+

Last run: {time(serverBackup.lastAttemptAt)}, {serverBackup.lastStatus === "ok" ? `succeeded (${serverBackup.lastKind === "full" ? "database, configuration and uploads" : "database and configuration"}, ${(serverBackup.sizeBytes / 1_048_576).toFixed(1)} MB)` : serverBackup.reason ? "refused because encryption is not working" : "failed"}. Kept on the server for {serverBackup.keepDays} days.

) : null} diff --git a/components/attendees/EditRsvpDialog.tsx b/components/attendees/EditRsvpDialog.tsx index 6d70179c..b24c8bbd 100644 --- a/components/attendees/EditRsvpDialog.tsx +++ b/components/attendees/EditRsvpDialog.tsx @@ -110,7 +110,7 @@ export function EditRsvpDialog(props: { diff --git a/components/dashboard/EventManageMore.tsx b/components/dashboard/EventManageMore.tsx index 68ebc81c..c14589b3 100644 --- a/components/dashboard/EventManageMore.tsx +++ b/components/dashboard/EventManageMore.tsx @@ -52,6 +52,7 @@ export function EventManageMore(props: { event: EventClientDto; }) { const { organisationSlug, event } = props; + const cancelled = event.status === "CANCELLED"; const router = useRouter(); const { showToast } = useToast(); const [pending, startTransition] = useTransition(); @@ -124,7 +125,7 @@ export function EventManageMore(props: { setCancelOpen(false); if (!res.ok) return showToast(res.error, "error"); const { refunded, failed, notified } = res.data!; - showToast(`Event cancelled. ${refunded} ${refunded === 1 ? "order" : "orders"} refunded, ${notified} free ${notified === 1 ? "guest" : "guests"} emailed.${failed ? ` ${failed} need settling by hand.` : ""}`, failed ? "warning" : "success"); + showToast(`${cancelled ? "Refund processing finished." : "Event cancelled."} ${refunded} ${refunded === 1 ? "order" : "orders"} refunded, ${notified} free ${notified === 1 ? "guest" : "guests"} emailed.${failed ? ` ${failed} need settling by hand.` : ""}`, failed ? "warning" : "success"); router.refresh(); }); }; @@ -221,22 +222,20 @@ export function EventManageMore(props: {
- {event.status !== "CANCELLED" ? ( -
-
-

Cancel event

-

- The event page goes offline and registration closes. Everyone who paid is refunded in full; everyone is emailed. -

-
- -
- ) : null} +
+
+

{cancelled ? "Finish cancellation refunds" : "Cancel event"}

+

+ {cancelled ? "Finish any refunds left after cancellation, even after the event has ended. Orders already processed are skipped." : "The event page goes offline and registration closes. Everyone who paid is refunded in full; everyone is emailed."} +

+
+ +
setCancelOpen(false)} onConfirm={doCancel} diff --git a/components/dashboard/org/OrgSections/insights.tsx b/components/dashboard/org/OrgSections/insights.tsx index 3337e9a6..532972c2 100644 --- a/components/dashboard/org/OrgSections/insights.tsx +++ b/components/dashboard/org/OrgSections/insights.tsx @@ -72,15 +72,16 @@ export function OrgRevenuePanel({ organisationSlug, revenue }: { organisationSlu ["Paid orders", String(summary.orders.paid)], ].map(([label, value]) => (
-
{label}{summary.testOnly ? " (test)" : ""}
+
{label}
{value}
))} - {summary.owedAtDeskMinor > 0 || summary.disputes.open > 0 ? ( + {summary.owedAtDeskMinor > 0 || summary.disputes.open > 0 || summary.test.payments > 0 ? (

{summary.owedAtDeskMinor > 0 ? `${money(summary.owedAtDeskMinor)} to collect at the desk. ` : ""} - {summary.disputes.open > 0 ? `${summary.disputes.open} open ${summary.disputes.open === 1 ? "chargeback" : "chargebacks"}.` : ""} + {summary.disputes.open > 0 ? `${summary.disputes.open} open ${summary.disputes.open === 1 ? "chargeback" : "chargebacks"}. ` : ""} + {summary.test.payments > 0 ? `${money(summary.test.collectedMinor - summary.test.refundedMinor)} in test payments, not counted above.` : ""}

) : null} {top.length > 0 ? ( diff --git a/components/event-dashboard/admission/ReviewQueue.tsx b/components/event-dashboard/admission/ReviewQueue.tsx index 25bcce49..5bb11277 100644 --- a/components/event-dashboard/admission/ReviewQueue.tsx +++ b/components/event-dashboard/admission/ReviewQueue.tsx @@ -60,7 +60,7 @@ export function ReviewQueue({ scope, items }: { scope: AdmissionScope; items: Re onSubmit={(event) => { event.preventDefault(); const numeric = Object.fromEntries(Object.entries(scores).map(([key, value]) => [key, Number(value)])); - run("submit", () => submitApplicationReview({ ...scope, assignmentId: open.assignmentId, scores: numeric, notes }), "Review submitted.", (result) => result.ok && setOpen(null)); + run("submit", () => submitApplicationReview({ ...scope, assignmentId: open.assignmentId, answersRevision: open.answersRevision, scores: numeric, notes }), "Review submitted.", (result) => result.ok && setOpen(null)); }} > {open.criteria.map((criterion) => ( diff --git a/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx b/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx index 0e8f2abc..a69e6e63 100644 --- a/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx +++ b/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx @@ -31,9 +31,9 @@ export function MoneyCard({ revenue, title = "Money", exportHref }: { revenue: R
-

{title}{revenue.testOnly ? " (test payments)" : ""}

+

{title}

- {revenue.testOnly ? "Every payment so far was a test, so no real money moved." : "Money taken for tickets, after refunds."} + Money taken for tickets, after refunds.{revenue.test.payments > 0 ? " Test payments moved no real money and are counted apart." : ""}

{exportHref ? Download orders (CSV) : null} @@ -51,6 +51,7 @@ export function MoneyCard({ revenue, title = "Money", exportHref }: { revenue: R revenue.orders.complimentary > 0 ? { label: `Complimentary (${revenue.orders.complimentary})`, value: money(revenue.complimentaryValueMinor) } : null, revenue.orders.requested > 0 ? { label: "Requests waiting", value: String(revenue.orders.requested) } : null, revenue.automaticRefundMinor > 0 ? { label: "Late or duplicate, refunded", value: money(revenue.automaticRefundMinor) } : null, + revenue.test.payments > 0 ? { label: `Test payments, not counted (${revenue.test.payments})`, value: money(revenue.test.collectedMinor - revenue.test.refundedMinor) } : null, ]} /> {revenue.disputes.open > 0 || revenue.disputes.lost > 0 ? (

diff --git a/components/event-dashboard/panels/FinancePanel.tsx b/components/event-dashboard/panels/FinancePanel.tsx index 701f33cf..9373cbd6 100644 --- a/components/event-dashboard/panels/FinancePanel.tsx +++ b/components/event-dashboard/panels/FinancePanel.tsx @@ -10,6 +10,8 @@ import { ClaimPayoutsList, FinanceSettingsForm, RefundRequestsList } from "@/com import { ReimbursementPolicies } from "@/components/event-dashboard/finance/ReimbursementPolicies"; import { NoPermission } from "./shared"; import { HelpLink } from "@/components/help/HelpLink"; +import { FailedRefundsList } from "@/components/payments/FailedRefundsList"; +import { RefundableOrdersList } from "@/components/payments/RefundableOrdersList"; /** * The event dashboard's Finance tab (docs/FINANCE.md): money in and out @@ -54,6 +56,11 @@ export async function FinancePanel({ ctx }: { ctx: EventDashboardContext; }) {

+ {summary.test ? ( + + {summary.test.payments} {summary.test.payments === 1 ? "payment was" : "payments were"} taken with test keys ({summary.test.amount} after refunds). No real money moved, so they are left out of income and net. + + ) : null} {summary.budget ? ( {summary.budget.committed} spent or approved; {summary.budget.over ? `over by ${summary.budget.left}` : `${summary.budget.left} left`}. @@ -71,6 +78,16 @@ export async function FinancePanel({ ctx }: { ctx: EventDashboardContext; }) { Refund requests} description="Buyers asking for money back outside your refund policy, from their order page." /> + {data.failedRefunds.length > 0 ? ( + + Failed refunds} description="Refunds the payment provider refused. Check your balance with the provider, then retry." /> + + + ) : null} + + Refundable orders} description="Cancelled or expired orders with money left on them. Confirmed orders are refunded from the guest list." /> + + Claim payments} description="Every payment stays on record. A mistake is reversed, never edited." /> diff --git a/components/payments/FailedRefundsList.tsx b/components/payments/FailedRefundsList.tsx new file mode 100644 index 00000000..cab7b453 --- /dev/null +++ b/components/payments/FailedRefundsList.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { retryFailedRefundAction } from "@/app/actions/payment-operations"; +import { Button } from "@/components/ui/button"; +import { Callout, Chip } from "@/components/ui/display"; +import { useSave } from "@/components/event-dashboard/finance/shared"; +import { formatDayTime } from "@/lib/format/datetime"; +import type { FailedRefundRow } from "@/lib/payments/operations/retryRefund"; + +/** + * Refunds the payment provider refused, by order, for owners and admins + * (docs/PAYMENTS.md). Listed by order rather than by guest, so a cancelled + * order whose tickets are gone can still be refunded. Retrying sends a new + * refund for the same amount; it needs a recent sign-in. + */ +export function FailedRefundsList({ target, refunds, timezone }: { target: { organisationSlug: string; eventId?: string; seriesId?: string; }; refunds: FailedRefundRow[]; timezone: string; }) { + const { pending, error, save } = useSave(); + if (refunds.length === 0) return

No failed refunds.

; + return ( +
+ {error ? {error} : null} +
    + {refunds.map((refund) => ( +
  • +
    +

    {refund.buyer} · order {refund.reference} · {refund.amount}

    +

    {refund.failedAt ? `Refused ${formatDayTime(refund.failedAt, timezone)}` : "Refused"}{refund.orderCancelled ? " · tickets cancelled" : ""}

    + {refund.reason ?

    {refund.reason}

    : null} +
    +
    + Failed + +
    +
  • + ))} +
+
+ ); +} diff --git a/components/payments/RefundableOrdersList.tsx b/components/payments/RefundableOrdersList.tsx new file mode 100644 index 00000000..49697428 --- /dev/null +++ b/components/payments/RefundableOrdersList.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { Undo2 } from "lucide-react"; +import { refundEndedOrderAction } from "@/app/actions/payment-operations"; +import { Button } from "@/components/ui/button"; +import { Callout, Chip } from "@/components/ui/display"; +import { Dialog, DialogBody, DialogContent, DialogHeader } from "@/components/ui/dialog"; +import { Field, Input, Textarea } from "@/components/ui/field"; +import { useSave } from "@/components/event-dashboard/finance/shared"; +import { formatDayTime } from "@/lib/format/datetime"; +import type { RefundableOrderRow } from "@/lib/payments/operations/cancelledRefund"; + +/** + * Ended orders and orders on cancelled targets with money left, for owners and + * admins (docs/PAYMENTS.md "Refundable orders"): a cancellation refunded in + * part, or a late payment's order. Refunded by order, so it works after the + * tickets are gone. Needs a recent sign-in. + */ +export function RefundableOrdersList({ target, orders, timezone }: { target: { organisationSlug: string; eventId?: string; seriesId?: string; }; orders: RefundableOrderRow[]; timezone: string; }) { + const [refunding, setRefunding] = useState(null); + if (orders.length === 0) return

No cancelled or expired orders, or orders on cancelled events or dates, have money left to refund.

; + return ( + <> +
    + {orders.map((order) => ( +
  • +
    +

    {order.buyer} · order {order.reference}

    +

    Ordered {formatDayTime(order.orderedAt, timezone)} · {order.left} left to refund

    +
    +
    + {order.status === "cancelled" ? "Cancelled" : order.status === "expired" ? "Expired" : "Cancelled event or date"} + +
    +
  • + ))} +
+ {refunding ? setRefunding(null)} /> : null} + + ); +} + +function RefundDialog({ target, order, onClose }: { target: { organisationSlug: string; eventId?: string; seriesId?: string; }; order: RefundableOrderRow; onClose: () => void; }) { + const [amount, setAmount] = useState(""); + const [reason, setReason] = useState(""); + const { pending, error, save } = useSave(); + return ( + { if (!next) onClose(); }}> + + + +
{ + event.preventDefault(); + save(() => refundEndedOrderAction({ ...target, orderId: order.id, amount, reason }), "Refund sent.", onClose); + }}> + {error ? {error} : null} +

This returns money only and keeps any checked-in tickets. For payments collected in person, return the money yourself before recording the refund. For a pass, enter the cancelled date's share, or leave blank to refund the whole remaining balance.

+ setAmount(event.target.value)} maxLength={20} /> +