Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
7ab7aa1
fix(ci): pin every external action to a verified commit SHA
Shaan-Shoukath Oct 29, 2026
c666ee4
fix(docker): install the Prisma CLI and operator clients with npm ci …
Shaan-Shoukath Oct 29, 2026
639623c
fix(api): cap request bodies on client errors, RSVP and check-in station
Shaan-Shoukath Oct 29, 2026
7e07579
fix(rsvp): return the public registration count only when the event s…
Shaan-Shoukath Oct 29, 2026
be5262c
fix(db): write trigger timestamps in UTC and document the admission b…
Shaan-Shoukath Oct 29, 2026
0cdffbb
fix(exports): keep every multi-select choice in the attendee CSV
Shaan-Shoukath Oct 29, 2026
97c7f79
fix(volunteers): stream every sign-up in the hours CSV and audit the …
Shaan-Shoukath Oct 29, 2026
c781680
fix(teams): team undo rechecks the deleter's registration access
Shaan-Shoukath Oct 29, 2026
5b22a48
fix(feedback): feedback follows the event page's access rule
Shaan-Shoukath Oct 29, 2026
002c731
fix(auth): sign-up spends its limit first and answers every attempt a…
Shaan-Shoukath Oct 29, 2026
94ca83b
fix(auth): MFA-aware org settings, password-only super admin, proof b…
Shaan-Shoukath Oct 29, 2026
ad63b05
fix(mobile): judge crew tickets by the crew claims switch
Shaan-Shoukath Oct 29, 2026
c5e8aa7
fix(mobile): keep unknown idempotent outcomes stale, claim id fallbac…
Shaan-Shoukath Oct 29, 2026
1c8e541
fix(net): share the webhook address classifier with SMTP, chat and ops
Shaan-Shoukath Oct 29, 2026
220c9a0
fix(chat): pin Mattermost delivery with the shared lookup that honour…
Shaan-Shoukath Oct 29, 2026
a0db3d4
fix(admission): refuse stale reviews, freeze the review target, filte…
Shaan-Shoukath Oct 29, 2026
a05d46a
fix(ops): pin alert webhook delivery to the vetted addresses
Shaan-Shoukath Oct 29, 2026
bc5034d
fix(mentor-desk): serialise office-hour slot creation and reschedulin…
Shaan-Shoukath Oct 29, 2026
c57aeae
fix(judging): freeze entrant projects once results are published
Shaan-Shoukath Oct 29, 2026
0dc15a6
fix(payments): payment-first refund writes, provider refund reconcili…
Shaan-Shoukath Oct 29, 2026
ec0e3e4
fix(payments): owners and admins can retry a failed refund once
Shaan-Shoukath Oct 29, 2026
e9f35a4
fix(payments): refund captures for cancelled targets, no promotion on…
Shaan-Shoukath Oct 29, 2026
63090f5
fix(payments): one lock order for date cancellation, hold extension, …
Shaan-Shoukath Oct 29, 2026
e6af2fe
test(rsvp): narrow registration results in the public count suite
Shaan-Shoukath Oct 29, 2026
d1b6779
Merge fix/audit-auth-mfa (MFA-aware org settings, password-only super…
Shaan-Shoukath Oct 29, 2026
5c50149
Merge fix/audit-admission-mentor (stale review refusal, frozen review…
Shaan-Shoukath Oct 29, 2026
e45d432
Merge dev into fix/audit-mobile-chat (D7: reconcile A and E mobile co…
Shaan-Shoukath Oct 29, 2026
a42b2c7
fix(vps): run the deploy runner from a root-written unit and never ex…
Shaan-Shoukath Oct 29, 2026
11ab4d4
fix(vps): refuse plaintext backups while encryption is requested and …
Shaan-Shoukath Oct 29, 2026
92823d9
Merge fix/audit-mobile-chat (mobile idempotency unknown outcomes, rev…
Shaan-Shoukath Oct 29, 2026
138225d
test(payments): cancelled targets, lock-order races, refund retry and…
Shaan-Shoukath Oct 29, 2026
416e367
docs(payments): lock order, cancelled-target refunds, failed refund r…
Shaan-Shoukath Oct 29, 2026
1971735
fix(attendance): name the No's time the release is guarded by
Shaan-Shoukath Oct 29, 2026
cd7a8f9
Merge fix/audit-attendee-flows (bounded request bodies, public regist…
Shaan-Shoukath Oct 29, 2026
16fcd9d
test(teams): cancelled events promote no waitlisted unit
Shaan-Shoukath Oct 29, 2026
b0a7bca
fix(check-in): claim offline check-ins by mutation id and replay thei…
Shaan-Shoukath Oct 29, 2026
a6ef6b9
Merge dev into fix/audit-payments
Shaan-Shoukath Oct 29, 2026
c7ab809
fix(judging): late entry fees keep the project unsubmitted once resul…
Shaan-Shoukath Oct 29, 2026
ce27d05
Merge fix/audit-payments (cancelled-event auto refunds, promotion gua…
Shaan-Shoukath Oct 29, 2026
926cf79
fix(storage): durable deletion queue with pre-registered uploads for …
Shaan-Shoukath Oct 29, 2026
c1f59e7
test(vps): allow the scripted shell runs more time on slow hosts
Shaan-Shoukath Oct 29, 2026
a48c8b3
Merge dev into fix/audit-ops-data
Shaan-Shoukath Oct 29, 2026
5f1ffae
chore(audit): regenerate the guide index and frontend catalog, list t…
Shaan-Shoukath Oct 29, 2026
3256e71
Merge fix/audit-ops-data (runner root safety, encrypted-only backups,…
Shaan-Shoukath Oct 29, 2026
200aa64
fix(auth): validate the normalised redirect so dot segments cannot re…
Shaan-Shoukath Oct 29, 2026
88269d2
fix(broadcasts): resolve placeholders from own properties only
Shaan-Shoukath Oct 29, 2026
8caf47b
fix(admission): no offers or acceptances on cancelled events
Shaan-Shoukath Oct 29, 2026
fe4c1dc
fix(auth): treat a missing or malformed device id as an unknown devic…
Shaan-Shoukath Oct 29, 2026
f988818
fix(net): one absolute deadline for Mattermost and alert webhook post…
Shaan-Shoukath Oct 29, 2026
4beef3d
fix(judging): pin one round per scoring request and re-check it under…
Shaan-Shoukath Oct 29, 2026
f134d64
fix(storage): delete every version and delete marker of queued keys, …
Shaan-Shoukath Oct 29, 2026
a816d2a
fix(claims): withdraw only within the authorised organisation and eve…
Shaan-Shoukath Oct 29, 2026
33677d1
fix(mobile): list application-review workspaces under GET /me reviewing
Shaan-Shoukath Oct 29, 2026
8d52009
fix(vps): refuse releases missing applied migrations, stop writers on…
Shaan-Shoukath Oct 29, 2026
5ceee03
fix(payments): read hold eligibility under the locks; recheck ticket …
Shaan-Shoukath Oct 29, 2026
cf2011b
fix(payments): organisation fence for order and payment paths; deleti…
Shaan-Shoukath Oct 29, 2026
5705cb4
fix(payments): keep refunds pending on failed lookups; refund money l…
Shaan-Shoukath Oct 29, 2026
a9bce42
fix(registration): approve paid solo team places once paid; one invit…
Shaan-Shoukath Oct 29, 2026
a7ba90d
docs(payments): organisation fence, lookup vs create, refundable orde…
Shaan-Shoukath Oct 29, 2026
da49458
fix(mobile): lock the user before mobile session writes (sign-out, MF…
Shaan-Shoukath Oct 29, 2026
1db1276
fix(check-in): custom-list undo locks the order and registration befo…
Shaan-Shoukath Oct 29, 2026
63c667d
fix(crew): edit and removal lock Event, RSVP, then crew row; barrier …
Shaan-Shoukath Oct 29, 2026
17ed0e6
fix(mentor-desk): Event before grant, help claims under the Event loc…
Shaan-Shoukath Oct 29, 2026
ce52bbf
fix(registration): re-check invite-only under the reservation lock, l…
Shaan-Shoukath Oct 29, 2026
7068d3a
fix(rsvp): retire the previous holder's links on transfer and email c…
Shaan-Shoukath Oct 29, 2026
74f7afc
fix(reports): authorise and rate limit report downloads before loadin…
Shaan-Shoukath Oct 29, 2026
d711d26
fix(checkin): audit offline roster downloads with the row count only
Shaan-Shoukath Oct 29, 2026
3ad6006
docs(registration): holder changes, invite-only seats, report limits,…
Shaan-Shoukath Oct 29, 2026
102e811
Merge fix/final-payments (fresh hold time under locks, ticket-type an…
Shaan-Shoukath Oct 29, 2026
10ff2ab
test(locks): person-lock re-check and retry
Shaan-Shoukath Oct 29, 2026
d235f2f
fix(admission): mark status events sent in the transaction that queue…
Shaan-Shoukath Oct 29, 2026
57d13f3
Merge dev into fix/final-programmes
Shaan-Shoukath Oct 29, 2026
a130878
fix(admission): reopen an ended hold only while its promo code still …
Shaan-Shoukath Oct 29, 2026
137cf94
Merge dev into fix/final-registration
Shaan-Shoukath Oct 29, 2026
cb6735f
fix(webhooks): build application webhook payloads inside the drain tr…
Shaan-Shoukath Oct 29, 2026
02a017e
fix(registration): recheck ticket authority under the lock, help requ…
Shaan-Shoukath Oct 29, 2026
4620700
fix(locks): invite acceptance and crew reordering lock in order; iden…
Shaan-Shoukath Oct 29, 2026
01faf7e
Merge fix/final-registration (invite-only free registration, holder-c…
Shaan-Shoukath Oct 29, 2026
6ccd206
Merge fix/final-programmes (no admission offers on cancelled events, …
Shaan-Shoukath Oct 29, 2026
72eed7e
Merge dev into fix/final-locks
Shaan-Shoukath Oct 29, 2026
58159e4
fix(ops): hold the install lock through deploys, alert on unknown dev…
Shaan-Shoukath Oct 29, 2026
074d552
docs(storage): describe which deletions are queued; replaced covers a…
Shaan-Shoukath Oct 29, 2026
a4a93a0
Merge fix/final-auth-ops (normalised sign-in redirects, unknown-devic…
Shaan-Shoukath Oct 29, 2026
d755144
fix(mentor-desk): grant changes, crew tickets and identity links in o…
Shaan-Shoukath Oct 29, 2026
20ba451
fix(organisations): retry organisation deletion when a deadlock abort…
Shaan-Shoukath Oct 29, 2026
9d793e4
fix(organisations): retry deletion only on structured deadlock and se…
Shaan-Shoukath Oct 29, 2026
48671b9
Merge fix/final-locks (lock-order fixes for office hours, grants, mob…
Shaan-Shoukath Oct 29, 2026
84d0182
chore(audit): regenerate the guide index and frontend catalog after r…
Shaan-Shoukath Oct 29, 2026
501da83
fix(payments): cancelled targets with money left block organisation d…
Shaan-Shoukath Oct 29, 2026
4174cfb
fix(holders): queued links go only to the current holder; claims leav…
Shaan-Shoukath Oct 29, 2026
4a51fc4
fix(payments): only cancelled dates count on a cancelled series; full…
Shaan-Shoukath Oct 29, 2026
8683732
fix(claims): payouts, reversals, notices and withdrawals lock the eve…
Shaan-Shoukath Oct 29, 2026
04e6da5
Merge fix/final-r3-holders (queued capability links only to the curre…
Shaan-Shoukath Oct 29, 2026
6b6bb4e
fix(payments): events and series holding money can't be deleted; retr…
Shaan-Shoukath Oct 29, 2026
62a2118
Merge dev into fix/final-r3-payments
Shaan-Shoukath Oct 29, 2026
58aa4bd
fix(payments): manual refunds on cancelled targets only where the aut…
Shaan-Shoukath Oct 29, 2026
b276f6e
fix(payments): finish refunds for already cancelled dates and events …
Shaan-Shoukath Oct 29, 2026
c118737
Merge fix/final-r3-payments (cancelled targets holding money block or…
Shaan-Shoukath Oct 29, 2026
f99b0ae
chore(audit): regenerate the guide index after round 3
Shaan-Shoukath Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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/
Expand All @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/yuyu-upstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 14 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/actions/admission-applicant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function loadApplicationReview(input: unknown): Promise<ActionResul

export async function submitApplicationReview(input: unknown): Promise<ActionResult> {
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<ActionResult<{ replacedBy: boolean }>> {
Expand Down
1 change: 1 addition & 0 deletions app/actions/admission/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 5 additions & 7 deletions app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand All @@ -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({
Expand Down
77 changes: 43 additions & 34 deletions app/actions/checkin/offline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<ActionResult<{ syncedIds: string[]; alreadyCheckedInIds: string[]; failed: { rsvpId: string; error: string; }[]; }>> {
Expand All @@ -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[] = [];
Expand All @@ -111,29 +116,33 @@ 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);
if (checkedInAt.getTime() < earliest || checkedInAt.getTime() > latest) {
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") {
Expand Down
Loading
Loading