Compare commits

...

20 Commits

Author SHA1 Message Date
Matt
5b99d6a530 refactor(ui): strip all dark: Tailwind classes (single-theme product)
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m17s
Mechanical sweep of 41 files via `perl -i -pe 's{\s+dark:[\w:/\[\]\.\-]+}{}g'`.
All dark: variants were paired with light-mode counterparts already; no
elements relied on a dark:-only style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:45:42 +02:00
Matt
6969b9c2bc chore(deps): drop next-themes; remove ThemeProvider + theme toggle UI 2026-05-22 18:43:25 +02:00
Matt
3bc9c11a51 merge: PR10 — applicant nationality stats card 2026-05-22 18:42:51 +02:00
Matt
8d4b62a602 feat(reports): applicant nationality breakdown card with scope filter (PR10)
- 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>
2026-05-22 18:38:52 +02:00
Matt
f64e68e751 merge: PR8 — multi-mentor per team + change-requests + inline previews
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>
2026-05-22 18:26:37 +02:00
Matt
48e48f058d feat(mentor-workspace): inline document preview matching applicant docs pattern
- 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>
2026-05-22 18:26:20 +02:00
Matt
ec92b03006 test(mentor): cover multi-mentor stacking + change-request procedures (PR8 Task 10)
- 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>
2026-05-22 17:20:01 +02:00
Matt
349671f37c merge: PR8 Task 8 — admin multi-mentor UI + change-request inbox 2026-05-22 17:13:02 +02:00
Matt
4f444a1baa merge: PR8 Task 7 — applicant mentor list + request-change dialog 2026-05-22 17:12:58 +02:00
Matt
d47db17027 merge: PR8 Task 9 — mentor co-mentor visibility 2026-05-22 17:12:54 +02:00
Matt
83e950bb67 feat(admin): multi-mentor stacking UI + change-request inbox (PR8 Task 8)
- /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>
2026-05-22 17:11:31 +02:00
Matt
ba115f71a0 feat(applicant): mentor list + request-change dialog (PR8 Task 7)
- /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
2026-05-22 17:09:06 +02:00
Matt
d440b5f274 feat(mentor): show co-mentors on workspace page (PR8 Task 9)
- 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>
2026-05-22 17:07:11 +02:00
Matt
ee47c0305f feat(mentor): add change-request procedures + admin email notification
- 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>
2026-05-22 16:59:23 +02:00
Matt
3a1eb149b6 feat(mentor-workspace): re-scope files from assignment to project for team-wide visibility
- 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.
2026-05-22 16:53:07 +02:00
Matt
a5ad11a1b5 feat(mentor): allow stacking mentors per team; send per-team assignment email
- 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>
2026-05-22 16:38:14 +02:00
Matt
66110598a0 refactor(schema-cascade): rename Project.mentorAssignment → mentorAssignments
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>
2026-05-22 16:37:37 +02:00
Matt
9152ebb399 feat(email): add sendMentorTeamAssignmentEmail for per-team mentor notifications
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.
2026-05-22 16:16:28 +02:00
Matt
a26e486ab5 chore(migration): include manual rollback.sql for PR8 multi-mentor
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>
2026-05-22 16:13:28 +02:00
Matt
e89dca24c3 feat(schema): multi-mentor per team + change-requests + per-assignment email field
- MentorAssignment: drop projectId @unique -> composite (projectId, mentorId)
- MentorAssignment: add notificationSentAt for idempotent per-team email
- MentorFile: add projectId (primary scope); mentorAssignmentId becomes nullable audit FK
- MentorChangeRequest: new model + status enum
- Migration hand-written with IF EXISTS guards (safe for docker-entrypoint retry)
2026-05-22 16:05:25 +02:00
74 changed files with 3391 additions and 993 deletions

11
package-lock.json generated
View File

@@ -61,7 +61,6 @@
"motion": "^11.15.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
@@ -12143,16 +12142,6 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -75,7 +75,6 @@
"motion": "^11.15.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",

View File

@@ -0,0 +1,78 @@
-- Hand-written migration for PR8 (multi-mentor per team).
--
-- All DDL guarded with IF EXISTS / IF NOT EXISTS so the docker-entrypoint
-- retry loop is safe to re-run. No regex (the 2026-05-07 prod incident was
-- caused by Prisma 6 generating regex-based DDL that Postgres rejected).
-- No BEGIN/COMMIT blocks — Prisma wraps the migration in a transaction.
-- Phase 1: MentorAssignment — drop unique, add composite, add notification field
ALTER TABLE "MentorAssignment" DROP CONSTRAINT IF EXISTS "MentorAssignment_projectId_key";
DROP INDEX IF EXISTS "MentorAssignment_projectId_key";
CREATE UNIQUE INDEX IF NOT EXISTS "MentorAssignment_projectId_mentorId_key"
ON "MentorAssignment"("projectId", "mentorId");
CREATE INDEX IF NOT EXISTS "MentorAssignment_projectId_idx"
ON "MentorAssignment"("projectId");
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "notificationSentAt" TIMESTAMP(3);
-- Phase 2: MentorFile — re-scope to project (two-phase backfill)
ALTER TABLE "MentorFile" ADD COLUMN IF NOT EXISTS "projectId" TEXT;
UPDATE "MentorFile" mf
SET "projectId" = ma."projectId"
FROM "MentorAssignment" ma
WHERE mf."mentorAssignmentId" = ma."id"
AND mf."projectId" IS NULL;
ALTER TABLE "MentorFile" ALTER COLUMN "projectId" SET NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "MentorFile_projectId_idx" ON "MentorFile"("projectId");
-- Phase 2b: Make MentorFile.mentorAssignmentId nullable + switch its FK to SetNull
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" DROP NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Phase 3: MentorChangeRequest table
-- Postgres < 14 doesn't support CREATE TYPE ... IF NOT EXISTS, so wrap in a
-- DO block that swallows duplicate_object errors (idempotent for re-runs).
DO $$ BEGIN
CREATE TYPE "MentorChangeRequestStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "MentorChangeRequest" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"targetAssignmentId" TEXT,
"requestedByUserId" TEXT,
"reason" TEXT NOT NULL,
"status" "MentorChangeRequestStatus" NOT NULL DEFAULT 'PENDING',
"resolvedByUserId" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolutionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MentorChangeRequest_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_projectId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_targetAssignmentId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_targetAssignmentId_fkey"
FOREIGN KEY ("targetAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_requestedByUserId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_requestedByUserId_fkey"
FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_resolvedByUserId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_resolvedByUserId_fkey"
FOREIGN KEY ("resolvedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_projectId_idx" ON "MentorChangeRequest"("projectId");
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_status_idx" ON "MentorChangeRequest"("status");
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_targetAssignmentId_idx" ON "MentorChangeRequest"("targetAssignmentId");

View File

@@ -0,0 +1,23 @@
-- PR8 rollback SQL (manual, only safe BEFORE any project has >1 mentor)
-- Reverses 20260522155652_multi_mentor_per_team
-- MentorChangeRequest: drop new table + enum
DROP TABLE IF EXISTS "MentorChangeRequest";
DROP TYPE IF EXISTS "MentorChangeRequestStatus";
-- MentorFile: drop projectId scope + restore mentorAssignmentId as required Cascade
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
DROP INDEX IF EXISTS "MentorFile_projectId_idx";
ALTER TABLE "MentorFile" DROP COLUMN IF EXISTS "projectId";
-- Restoring NOT NULL is safe only if no rows have NULL mentorAssignmentId (true unless multi-mentor assignments were dropped post-migration)
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" SET NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- MentorAssignment: restore projectId @unique + drop new fields
DROP INDEX IF EXISTS "MentorAssignment_projectId_mentorId_key";
DROP INDEX IF EXISTS "MentorAssignment_projectId_idx";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "notificationSentAt";
-- Re-adding UNIQUE will FAIL if any project has >1 mentor (intended safety signal)
ALTER TABLE "MentorAssignment" ADD CONSTRAINT "MentorAssignment_projectId_key" UNIQUE ("projectId");

View File

@@ -118,7 +118,6 @@ enum NotificationChannel {
NONE
}
enum PartnerVisibility {
ADMIN_ONLY
JURY_VISIBLE
@@ -133,7 +132,6 @@ enum PartnerType {
OTHER
}
// =============================================================================
// COMPETITION / ROUND ENGINE ENUMS
// =============================================================================
@@ -171,7 +169,6 @@ enum ProjectRoundStateValue {
WITHDRAWN
}
enum CapMode {
HARD
SOFT
@@ -328,8 +325,8 @@ model User {
inviteTokenExpiresAt DateTime?
// Password reset token
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Digest & availability preferences
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
@@ -363,9 +360,9 @@ model User {
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
// Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications
notifications InAppNotification[] @relation("UserNotifications")
@@ -413,20 +410,24 @@ model User {
sessions Session[]
// ── Competition/Round architecture relations ──
juryGroupMemberships JuryGroupMember[]
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
juryGroupMemberships JuryGroupMember[]
mentorFilesUploaded MentorFile[] @relation("MentorFileUploader")
mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter")
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
// AI Ranking
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
// Grand-finale logistics
finalistAttendances AttendingMember[]
finalistAttendances AttendingMember[]
// Mentor change requests
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
@@index([role])
@@index([status])
@@ -629,7 +630,9 @@ model Project {
assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[]
mentorAssignment MentorAssignment?
mentorAssignments MentorAssignment[]
mentorFiles MentorFile[]
mentorChangeRequests MentorChangeRequest[]
filteringResults FilteringResult[]
awardEligibilities AwardEligibility[]
awardVotes AwardVote[]
@@ -642,12 +645,12 @@ model Project {
cohortProjects CohortProject[]
// ── Competition/Round architecture relations ──
projectRoundStates ProjectRoundState[]
assignmentIntents AssignmentIntent[]
deliberationVotes DeliberationVote[]
deliberationResults DeliberationResult[]
submissionPromotions SubmissionPromotionEvent[]
notificationLogs NotificationLog[]
projectRoundStates ProjectRoundState[]
assignmentIntents AssignmentIntent[]
deliberationVotes DeliberationVote[]
deliberationResults DeliberationResult[]
submissionPromotions SubmissionPromotionEvent[]
notificationLogs NotificationLog[]
// Grand-finale logistics
waitlistEntry WaitlistEntry?
@@ -699,9 +702,9 @@ model ProjectFile {
// Document analysis (optional, populated by document-analyzer service)
textPreview String? @db.Text // First ~2000 chars of extracted text
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.0 confidence
analyzedAt DateTime? // When analysis last ran
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.0 confidence
analyzedAt DateTime? // When analysis last ran
// MinIO location
bucket String
@@ -714,7 +717,7 @@ model ProjectFile {
replacedById String? // FK to the newer file that replaced this one
// ── Competition/Round architecture fields ──
submissionWindowId String? // FK to SubmissionWindow
submissionWindowId String? // FK to SubmissionWindow
submissionFileRequirementId String? // FK to SubmissionFileRequirement
createdAt DateTime @default(now())
@@ -763,10 +766,10 @@ model Assignment {
juryGroupId String?
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
evaluation Evaluation?
conflictOfInterest ConflictOfInterest?
@@ -1026,12 +1029,12 @@ model NotificationEmailSetting {
// =============================================================================
model LearningResource {
id String @id @default(cuid())
programId String? // null = global resource
title String
description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure
accessJson Json? @db.JsonB // Fine-grained access rules
id String @id @default(cuid())
programId String? // null = global resource
title String
description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure
accessJson Json? @db.JsonB // Fine-grained access rules
// File storage (for uploaded resources)
fileName String?
@@ -1270,7 +1273,7 @@ model TeamMember {
model MentorAssignment {
id String @id @default(cuid())
projectId String @unique // One mentor per project
projectId String // Team can have multiple mentors; uniqueness enforced via composite below
mentorId String // User with MENTOR role or expertise
// Assignment tracking
@@ -1278,6 +1281,9 @@ model MentorAssignment {
assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
notificationSentAt DateTime?
// AI assignment metadata
aiConfidenceScore Float?
expertiseMatchScore Float?
@@ -1304,11 +1310,47 @@ model MentorAssignment {
milestoneCompletions MentorMilestoneCompletion[]
messages MentorMessage[]
files MentorFile[]
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
@@unique([projectId, mentorId])
@@index([projectId])
@@index([mentorId])
@@index([method])
}
// =============================================================================
// MENTOR CHANGE REQUESTS
// =============================================================================
enum MentorChangeRequestStatus {
PENDING
RESOLVED
DISMISSED
}
model MentorChangeRequest {
id String @id @default(cuid())
projectId String
targetAssignmentId String? // Optional: a specific co-mentor the request is about
requestedByUserId String?
reason String @db.Text
status MentorChangeRequestStatus @default(PENDING)
resolvedByUserId String?
resolvedAt DateTime?
resolutionNote String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
@@index([projectId])
@@index([status])
@@index([targetAssignmentId])
}
// =============================================================================
// FILTERING ROUND SYSTEM
// =============================================================================
@@ -1443,17 +1485,17 @@ enum AssignmentJobStatus {
// =============================================================================
enum RankingTriggerType {
MANUAL // Admin clicked "Run ranking"
AUTO // Auto-triggered by assignment completion
MANUAL // Admin clicked "Run ranking"
AUTO // Auto-triggered by assignment completion
RETROACTIVE // Retroactive scan on deployment
QUICK // Quick-rank mode (no preview)
QUICK // Quick-rank mode (no preview)
}
enum RankingMode {
PREVIEW // Parsed rules shown to admin (not yet applied)
PREVIEW // Parsed rules shown to admin (not yet applied)
CONFIRMED // Admin confirmed rules, ranking applied
QUICK // Quick-rank: parse + apply without preview
FORMULA // Formula-only: no LLM, pure math ranking
QUICK // Quick-rank: parse + apply without preview
FORMULA // Formula-only: no LLM, pure math ranking
}
enum RankingSnapshotStatus {
@@ -1470,7 +1512,7 @@ model RankingSnapshot {
roundId String
// Trigger metadata
triggeredById String? // null = auto-triggered
triggeredById String? // null = auto-triggered
triggerType RankingTriggerType @default(MANUAL)
// Criteria used
@@ -1599,7 +1641,7 @@ model SpecialAward {
evaluationRoundId String?
juryGroupId String?
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
shortlistSize Int @default(10)
// Eligibility job tracking
@@ -1621,10 +1663,10 @@ model SpecialAward {
votes AwardVote[]
// Competition/Round architecture relations
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
rounds Round[] @relation("AwardRounds")
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
rounds Round[] @relation("AwardRounds")
@@index([programId])
@@index([status])
@@ -1688,12 +1730,12 @@ model AwardJuror {
}
model AwardVote {
id String @id @default(cuid())
awardId String
userId String
projectId String
rank Int? // For RANKED mode
justification String? @db.Text
id String @id @default(cuid())
awardId String
userId String
projectId String
rank Int? // For RANKED mode
justification String? @db.Text
votedAt DateTime @default(now())
// Relations
@@ -1810,7 +1852,7 @@ model MentorMessage {
createdAt DateTime @default(now())
// ── Competition/Round architecture fields ──
workspaceId String? // FK to MentorAssignment (used as workspace)
workspaceId String? // FK to MentorAssignment (used as workspace)
senderRole MentorMessageRole?
// Relations
@@ -2146,9 +2188,9 @@ model Competition {
status CompetitionStatus @default(DRAFT)
// Competition-wide settings
categoryMode String @default("SHARED")
startupFinalistCount Int @default(3)
conceptFinalistCount Int @default(3)
categoryMode String @default("SHARED")
startupFinalistCount Int @default(3)
conceptFinalistCount Int @default(3)
// Notification preferences
notifyOnRoundAdvance Boolean @default(true)
@@ -2159,7 +2201,7 @@ model Competition {
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
rounds Round[]
juryGroups JuryGroup[]
submissionWindows SubmissionWindow[]
@@ -2204,10 +2246,10 @@ model Round {
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[]
visibleSubmissionWindows RoundSubmissionVisibility[]
assignmentIntents AssignmentIntent[]
@@ -2226,7 +2268,7 @@ model Round {
filteringResults FilteringResult[]
filteringJobs FilteringJob[]
assignmentJobs AssignmentJob[]
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
reminderLogs ReminderLog[]
evaluationSummaries EvaluationSummary[]
evaluationDiscussions EvaluationDiscussion[]
@@ -2272,7 +2314,7 @@ model ProjectRoundState {
// =============================================================================
model JuryGroup {
id String @id @default(cuid())
id String @id @default(cuid())
competitionId String
name String
slug String
@@ -2330,8 +2372,8 @@ model JuryGroupMember {
updatedAt DateTime @updatedAt
// Relations
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignmentIntents AssignmentIntent[]
deliberationVotes DeliberationVote[]
deliberationParticipations DeliberationParticipant[]
@@ -2369,7 +2411,7 @@ model SubmissionWindow {
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
fileRequirements SubmissionFileRequirement[]
projectFiles ProjectFile[]
rounds Round[]
@@ -2403,7 +2445,7 @@ model SubmissionFileRequirement {
}
model RoundSubmissionVisibility {
id String @id @default(cuid())
id String @id @default(cuid())
roundId String
submissionWindowId String
canView Boolean @default(true)
@@ -2448,8 +2490,9 @@ model AssignmentIntent {
// =============================================================================
model MentorFile {
id String @id @default(cuid())
mentorAssignmentId String
id String @id @default(cuid())
projectId String // Primary access scope: files belong to the team
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
uploadedByUserId String
fileName String
@@ -2468,13 +2511,15 @@ model MentorFile {
createdAt DateTime @default(now())
// Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
comments MentorFileComment[]
promotionEvents SubmissionPromotionEvent[]
@@index([projectId])
@@index([mentorAssignmentId])
@@index([uploadedByUserId])
}
@@ -2492,9 +2537,9 @@ model MentorFileComment {
updatedAt DateTime @updatedAt
// Relations
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
replies MentorFileComment[] @relation("CommentThread")
@@index([mentorFileId])
@@ -2503,14 +2548,14 @@ model MentorFileComment {
}
model SubmissionPromotionEvent {
id String @id @default(cuid())
id String @id @default(cuid())
projectId String
roundId String
slotKey String
sourceType SubmissionPromotionSource
sourceFileId String?
promotedById String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)

View File

@@ -335,20 +335,20 @@ function RoundsDndGrid({
function ConfidenceBadge({ confidence }: { confidence: number }) {
if (confidence > 0.8) {
return (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums">
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)
}
if (confidence >= 0.5) {
return (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums">
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)
}
return (
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums">
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)
@@ -897,8 +897,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
@@ -910,8 +910,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
<ListChecks className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
@@ -923,8 +923,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
@@ -936,8 +936,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
<Vote className="h-5 w-5 text-amber-600" />
</div>
</div>
</CardContent>
@@ -1612,7 +1612,7 @@ export default function AwardDetailPage({
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
@@ -1620,7 +1620,7 @@ export default function AwardDetailPage({
</div>
)}
{!award.competitionId && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Link this award to a competition first before creating rounds.
@@ -1750,16 +1750,16 @@ export default function AwardDetailPage({
return (
<TableRow
key={r.project.id}
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
className={isWinner ? 'bg-amber-50/80' : ''}
>
<TableCell>
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
i === 0
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
? 'bg-amber-100 text-amber-800'
: i === 1
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
? 'bg-slate-200 text-slate-700'
: i === 2
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
? 'bg-orange-100 text-orange-800'
: 'text-muted-foreground'
}`}>
{i + 1}

View File

@@ -1047,7 +1047,7 @@ export default function MemberDetailPage() {
/>
</div>
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>

View File

@@ -907,7 +907,7 @@ export default function MemberInvitePage() {
</div>
{!sendInvitation && (
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700">
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium">No invitations will be sent</p>

File diff suppressed because it is too large Load Diff

View File

@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
return (
<TableRow
key={row.project.id}
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
className={row.isComplete ? 'bg-green-50/50' : ''}
>
<TableCell>
<Link

View File

@@ -53,15 +53,15 @@ type TeamMemberEntry = {
}
const ROLE_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
LEAD: 'bg-red-100 text-red-700',
MEMBER: 'bg-teal-100 text-teal-700',
ADVISOR: 'bg-blue-100 text-blue-700',
}
const ROLE_AVATAR_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
LEAD: 'bg-red-100 text-red-700',
MEMBER: 'bg-teal-100 text-teal-700',
ADVISOR: 'bg-blue-100 text-blue-700',
}
const ROLE_LABELS: Record<string, string> = {

View File

@@ -679,7 +679,7 @@ export default function ProjectsPage() {
<Button
variant="outline"
onClick={() => setAiTagDialogOpen(true)}
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
className={taggingInProgress ? 'border-amber-400 bg-amber-50' : ''}
>
{taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
@@ -1716,15 +1716,15 @@ export default function ProjectsPage() {
<div className="space-y-6 py-4">
{/* Progress Indicator (when running) */}
{taggingInProgress && (
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
<p className="font-medium text-blue-900">
AI Tagging in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
<p className="text-sm text-blue-700">
{jobStatus?.status === 'PENDING'
? 'Initializing...'
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
<span className="text-blue-700">
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
</span>
{jobStatus && jobStatus.totalProjects > 0 && (
<span className="font-medium text-blue-900 dark:text-blue-100">
<span className="font-medium text-blue-900">
{taggingProgressPercent}%
</span>
)}
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
{taggingResult && !taggingInProgress && (
<div className={`p-4 rounded-lg border ${
taggingResult.failed > 0
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900'
? 'bg-amber-50 border-amber-200'
: taggingResult.processed > 0
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
? 'bg-green-50 border-green-200'
: 'bg-muted border-border'
}`}>
<div className="flex items-center gap-3 mb-3">
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
</div>
{taggingResult.errors.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
<p className="text-sm font-medium text-amber-700">
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
</p>
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
{taggingResult.errors.map((error, i) => (
<p key={i} className="text-amber-700 dark:text-amber-300">
<p key={i} className="text-amber-700">
{error}
</p>
))}

View File

@@ -52,6 +52,7 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
@@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) {
{ enabled: hasScope }
)
// Applicant nationality breakdown — always runs (scope optional;
// empty scope = global view across all programs).
const { data: nationalityStats, isLoading: nationalityLoading } =
trpc.analytics.getApplicantNationalities.useQuery(scopeInput)
const nationalityScopeLabel = scopeInput.roundId
? 'in this round'
: scopeInput.programId
? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition`
: 'across all programs'
if (isLoading || statsLoading) {
return (
<div className="space-y-6">
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
</AnimatedCard>
</div>
{/* Applicant Nationalities */}
<ApplicantNationalitiesCard
data={nationalityStats}
loading={nationalityLoading}
scopeLabel={nationalityScopeLabel}
/>
{/* Score Distribution (if any evaluations exist) */}
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
<Card>
@@ -500,6 +519,140 @@ function ReportsOverview({ scope }: { scope: string | null }) {
)
}
type NationalityStats = {
total: number
declared: number
notDeclared: number
byCountry: Array<{ country: string; count: number }>
}
function ApplicantNationalitiesCard({
data,
loading,
scopeLabel,
}: {
data: NationalityStats | undefined
loading: boolean
scopeLabel: string
}) {
const [showAll, setShowAll] = useState(false)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Globe className="h-4 w-4 text-violet-600" />
</div>
Applicant Nationalities
</CardTitle>
<CardDescription>
Self-declared nationality of team members on projects {scopeLabel}.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : !data || data.total === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No applicants in this scope.
</p>
</div>
) : data.declared === 0 ? (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No nationality data yet.
</p>
</div>
</>
) : (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Country</TableHead>
<TableHead className="text-right w-32">Applicants</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => {
const name = getCountryName(row.country)
const flag = getCountryFlag(row.country)
return (
<TableRow key={row.country}>
<TableCell className="font-medium">
<span className="inline-flex items-center gap-2">
{flag && <span aria-hidden>{flag}</span>}
<span>{name}</span>
{name !== row.country && (
<span className="text-xs text-muted-foreground tabular-nums">
{row.country}
</span>
)}
</span>
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary" className="tabular-nums">
{row.count}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{data.byCountry.length > 10 && (
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAll((v) => !v)}
className="gap-1 text-muted-foreground"
>
{showAll
? 'Show top 10'
: `Show all (${data.byCountry.length} countries)`}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) {
return (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Declared</p>
<p className="text-2xl font-bold tabular-nums">{declared}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Not declared</p>
<p className="text-2xl font-bold tabular-nums text-muted-foreground">
{notDeclared}
</p>
</div>
</div>
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}

View File

@@ -394,7 +394,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
</h1>
<div className="flex items-center gap-2 mt-1 flex-wrap">
@@ -404,8 +404,8 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -415,7 +415,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</div>
</div>
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40 dark:bg-amber-950/10">
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40">
<CardContent className="flex items-start gap-3 p-4">
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 text-sm">
@@ -431,7 +431,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</Card>
{isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1">
@@ -446,7 +446,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
)}
{hasCOI && !isReadOnly && (
<Card className="border-l-4 border-l-red-500 bg-red-50/40 dark:bg-red-950/10">
<Card className="border-l-4 border-l-red-500 bg-red-50/40">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1 text-sm">

View File

@@ -55,7 +55,7 @@ export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: P
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
Proxy Evaluations
</h1>
<p className="text-muted-foreground mt-1">
@@ -205,8 +205,8 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
className={cn(
'shrink-0',
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300',
? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200',
)}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}

View File

@@ -2074,39 +2074,39 @@ export default function RoundDetailPage() {
</p>
)}
{aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800">
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200">
<div className="relative">
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
</div>
<div>
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p>
<p className="text-xs text-violet-600 dark:text-violet-400">
<p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
<p className="text-xs text-violet-600">
Matching expertise, reviewing bios, and balancing workloads
</p>
</div>
</div>
)}
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800">
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
<p className="text-sm font-medium text-red-800">
AI generation failed
</p>
<p className="text-xs text-red-600 dark:text-red-400">
<p className="text-xs text-red-600">
{aiAssignmentMutation.error.message}
</p>
</div>
</div>
)}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800">
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
<p className="text-sm font-medium text-emerald-800">
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
</p>
<p className="text-xs text-emerald-600 dark:text-emerald-400">
<p className="text-xs text-emerald-600">
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
</p>
@@ -2588,9 +2588,9 @@ export default function RoundDetailPage() {
{/* Autosave error bar — only shows when save fails */}
{autosaveStatus === 'error' && (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
<div className="flex items-center gap-2 text-sm text-red-700">
<AlertTriangle className="h-4 w-4" />
<span>Auto-save failed</span>
</div>

View File

@@ -1,147 +1,230 @@
'use client'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import {
MessageSquare,
UserCircle,
FileText,
} from 'lucide-react'
export default function ApplicantMentorPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
},
})
if (dashLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to communicate with your mentor.
</p>
</CardContent>
</Card>
</div>
)
}
const mentor = dashboardData?.project?.mentorAssignment?.mentor
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Mentor Communication
</h1>
<p className="text-muted-foreground">
Chat with your assigned mentor
</p>
</div>
{/* Mentor info */}
{mentor ? (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium">{mentor.name || 'Mentor'}</p>
<p className="text-sm text-muted-foreground">{mentor.email}</p>
</div>
</div>
</CardContent>
</Card>
) : (
<Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8">
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-center">
No mentor has been assigned to your project yet.
You&apos;ll be notified when a mentor is assigned.
</p>
</CardContent>
</Card>
)}
{/* Chat */}
{mentor && (
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
<CardDescription>
Your conversation history with {mentor.name || 'your mentor'}
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId: projectId!, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
)}
{/* Files */}
{dashboardData?.project?.mentorAssignment?.id && (
<WorkspaceFilesPanel
mentorAssignmentId={dashboardData.project.mentorAssignment.id}
asApplicant
/>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { RequestChangeDialog } from './request-change-dialog'
import {
MessageSquare,
UserCircle,
FileText,
UserCog,
} from 'lucide-react'
export default function ApplicantMentorPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
},
})
const [isChangeOpen, setIsChangeOpen] = useState(false)
if (dashLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to communicate with your mentor.
</p>
</CardContent>
</Card>
</div>
)
}
const assignments = dashboardData?.project?.mentorAssignments ?? []
const hasMentors = assignments.length > 0
const primaryAssignment = assignments[0] ?? null
const primaryMentor = primaryAssignment?.mentor
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
const dialogMentors = assignments
.filter((a) => !!a.mentor)
.map((a) => ({
assignmentId: a.id,
name: a.mentor?.name || a.mentor?.email || 'Mentor',
}))
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Mentor Communication
</h1>
<p className="text-muted-foreground">
{assignments.length > 1
? 'Chat with your assigned mentor team'
: 'Chat with your assigned mentor'}
</p>
</div>
{/* Mentor list */}
{hasMentors ? (
<section className="space-y-3">
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
<div className="grid gap-3 md:grid-cols-2">
{assignments.map((assignment) => {
const mentor = assignment.mentor
if (!mentor) return null
const expertise = mentor.expertiseTags ?? []
return (
<Card key={assignment.id} className="bg-muted/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">
{mentor.name || 'Mentor'}
</p>
<p className="text-sm text-muted-foreground truncate">
{mentor.email}
</p>
{assignment.assignedAt && (
<p className="text-xs text-muted-foreground mt-1">
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
</p>
)}
</div>
</div>
{expertise.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{expertise.map((tag) => (
<Badge key={tag} variant="secondary" className="font-normal">
{tag}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
{/* Request change action */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
<p className="text-sm text-muted-foreground">
{hasPendingChangeRequest
? "You have a pending mentor change request — admins will follow up soon."
: 'Need a different match? Let the program admins know.'}
</p>
<Button
variant="outline"
onClick={() => setIsChangeOpen(true)}
disabled={hasPendingChangeRequest}
>
<UserCog className="mr-2 h-4 w-4" />
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
</Button>
</div>
</section>
) : (
<Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8">
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-center">
No mentor has been assigned to your project yet.
You&apos;ll be notified when a mentor is assigned.
</p>
</CardContent>
</Card>
)}
{/* Chat */}
{primaryMentor && (
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
<CardDescription>
{assignments.length > 1
? 'Your conversation history with your mentor team'
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId: projectId!, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
)}
{/* Files */}
{primaryAssignment?.id && projectId && (
<WorkspaceFilesPanel
projectId={projectId}
mentorAssignmentId={primaryAssignment.id}
asApplicant
/>
)}
{/* Request change dialog */}
{projectId && (
<RequestChangeDialog
projectId={projectId}
mentors={dialogMentors}
open={isChangeOpen}
onOpenChange={setIsChangeOpen}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const REASON_MIN = 10
const REASON_MAX = 2000
const TARGET_ANY = '__any__'
type MentorOption = {
assignmentId: string
name: string
}
type RequestChangeDialogProps = {
projectId: string
mentors: MentorOption[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function RequestChangeDialog({
projectId,
mentors,
open,
onOpenChange,
}: RequestChangeDialogProps) {
const [reason, setReason] = useState('')
const [target, setTarget] = useState<string>(TARGET_ANY)
const [touched, setTouched] = useState(false)
const utils = trpc.useUtils()
const requestChange = trpc.mentor.requestChange.useMutation({
onSuccess: async () => {
toast.success(
"Your request has been sent to the program admins. We'll review it and follow up.",
)
onOpenChange(false)
// Refresh dashboard so the disabled state for the button updates.
await utils.applicant.getMyDashboard.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Could not send your request. Please try again.')
},
})
// Reset form when the dialog is closed.
useEffect(() => {
if (!open) {
setReason('')
setTarget(TARGET_ANY)
setTouched(false)
}
}, [open])
const trimmedReason = reason.trim()
const reasonTooShort = trimmedReason.length < REASON_MIN
const reasonTooLong = trimmedReason.length > REASON_MAX
const reasonInvalid = reasonTooShort || reasonTooLong
const showReasonError = touched && reasonInvalid
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setTouched(true)
if (reasonInvalid) return
requestChange.mutate({
projectId,
targetAssignmentId: target === TARGET_ANY ? undefined : target,
reason: trimmedReason,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request a mentor change</DialogTitle>
<DialogDescription>
Share a few details so the program admins can follow up with you.
Your current mentor will not see this message.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{mentors.length > 0 && (
<div className="space-y-2">
<Label htmlFor="targetMentor">About a specific mentor</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger id="targetMentor">
<SelectValue placeholder="Any / general" />
</SelectTrigger>
<SelectContent>
<SelectItem value={TARGET_ANY}>Any / general</SelectItem>
{mentors.map((m) => (
<SelectItem key={m.assignmentId} value={m.assignmentId}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Use this if your request is about one of your co-mentors in particular.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="reason">
Why would you like a change?
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
onBlur={() => setTouched(true)}
placeholder="Tell us why you'd like a change. The admin team will follow up."
rows={6}
maxLength={REASON_MAX}
aria-invalid={showReasonError || undefined}
required
/>
<div className="flex items-center justify-between text-xs">
{showReasonError ? (
<p className="text-destructive">
{reasonTooShort
? `Please provide at least ${REASON_MIN} characters.`
: `Please keep your message under ${REASON_MAX} characters.`}
</p>
) : (
<p className="text-muted-foreground">
{REASON_MIN}{REASON_MAX} characters.
</p>
)}
<p className="text-muted-foreground tabular-nums">
{trimmedReason.length}/{REASON_MAX}
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={requestChange.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={requestChange.isPending}>
{requestChange.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send request
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -219,12 +219,12 @@ export default function ApplicantDashboardPage() {
key={round.id}
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
isUrgent
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20'
? 'border-amber-500/50 bg-amber-50'
: 'border-primary/20 bg-primary/5'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} />
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
<span className="font-medium text-sm truncate">{round.name}</span>
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}

View File

@@ -357,12 +357,12 @@ export default function ApplicantProjectPage() {
)}
</div>
{/* Mentor info */}
{project.mentorAssignment?.mentor && (
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
{project.mentorAssignments?.[0]?.mentor && (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
{project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})
</p>
</div>
)}

View File

@@ -427,10 +427,10 @@ function ProjectDetails({ project }: { project: ProjectData }) {
return (
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
{project.evaluationScore && (
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
<Star className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 px-3 py-2">
<Star className="h-4 w-4 text-blue-600 shrink-0" />
<div className="text-sm">
<span className="font-semibold text-blue-700 dark:text-blue-300">
<span className="font-semibold text-blue-700">
{project.evaluationScore.avg.toFixed(1)} / 10
</span>
<span className="text-muted-foreground ml-2">
@@ -518,7 +518,7 @@ function ProjectCard({
isExpanded && 'rotate-180'
)} />
<div className="min-w-0">
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
<h3 className="font-semibold text-sm group-hover:text-brand-blue transition-colors">
{project.title}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
@@ -587,7 +587,7 @@ function ChairPanel({
const isClosed = award.status === 'CLOSED'
return (
<Card className="border-amber-200 dark:border-amber-900">
<Card className="border-amber-200">
<CardHeader>
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5 text-amber-600" />

View File

@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
Back
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{round?.name || 'Round Details'}
</h1>
<p className="text-muted-foreground mt-1">

View File

@@ -460,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
<div className="rounded-xl bg-amber-50 p-3">
<Clock className="h-6 w-6 text-amber-600" />
</div>
<div>
@@ -495,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
@@ -526,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
@@ -534,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
<div className="rounded-xl bg-amber-50 p-3">
<ShieldAlert className="h-6 w-6 text-amber-600" />
</div>
<div>
@@ -573,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Button>
)}
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
</h1>
<div className="flex items-center gap-2 mt-1">
@@ -583,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -595,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div>
{isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1">

View File

@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}

View File

@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
My Assignments
</h1>
<p className="text-muted-foreground mt-1">

View File

@@ -262,7 +262,7 @@ async function JuryDashboardContent() {
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
<CardContent className="py-8 px-6">
<div className="flex flex-col items-center text-center mb-6">
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20">
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3">
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div>
<p className="text-lg font-semibold">No assignments yet</p>
@@ -273,13 +273,13 @@ async function JuryDashboardContent() {
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link
href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100">
<ClipboardList className="h-4 w-4 text-blue-600" />
</div>
<div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground">View evaluations</p>
</div>
</Link>
@@ -288,7 +288,7 @@ async function JuryDashboardContent() {
href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100">
<GitCompare className="h-4 w-4 text-brand-teal" />
</div>
<div className="text-left">
@@ -314,8 +314,8 @@ async function JuryDashboardContent() {
<div className="rounded-[7px] bg-background">
<CardHeader className="pb-2 pt-4 px-5">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40">
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<div className="rounded-lg bg-amber-100 p-1.5">
<Trophy className="h-4 w-4 text-amber-600" />
</div>
<CardTitle className="text-lg">Special Awards Voting Open</CardTitle>
</div>
@@ -333,27 +333,27 @@ async function JuryDashboardContent() {
className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
hasVoted
? 'border-green-200/60 bg-green-50/30 dark:border-green-800/40 dark:bg-green-950/10'
? 'border-green-200/60 bg-green-50/30'
: isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10'
? 'border-red-200 bg-red-50/50'
: 'border-amber-200/60 bg-amber-50/30'
)}
>
<div className="flex items-start justify-between">
<div>
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-400')}>{award.name}</h3>
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700' : 'text-amber-700')}>{award.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
{record.isChair && ' · You are the Chair'}
</p>
</div>
{hasVoted ? (
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-700">
<Badge className="bg-green-100 text-green-800 border-green-300">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
) : (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700">
<Badge className="bg-amber-100 text-amber-800 border-amber-300">
Vote Now
</Badge>
)}
@@ -452,8 +452,8 @@ async function JuryDashboardContent() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
<div className="rounded-lg bg-brand-blue/10 p-1.5">
<ClipboardList className="h-4 w-4 text-brand-blue" />
</div>
<CardTitle className="text-lg">My Assignments</CardTitle>
</div>
@@ -487,14 +487,14 @@ async function JuryDashboardContent() {
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
className="flex-1 min-w-0 group"
>
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{assignment.project.title}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground truncate">
{assignment.project.teamName}
</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 border-0">
{assignment.round.name}
</Badge>
</div>
@@ -506,7 +506,7 @@ async function JuryDashboardContent() {
Done
</Badge>
) : isDraft && isVotingOpen ? (
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse">
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 animate-pulse">
<Send className="mr-1 h-3 w-3" />
Ready to submit
</Badge>
@@ -571,7 +571,7 @@ async function JuryDashboardContent() {
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<Zap className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-lg">Quick Actions</CardTitle>
@@ -581,13 +581,13 @@ async function JuryDashboardContent() {
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link
href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100">
<ClipboardList className="h-5 w-5 text-blue-600" />
</div>
<div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
<p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
</div>
</Link>
@@ -596,7 +596,7 @@ async function JuryDashboardContent() {
href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100">
<GitCompare className="h-5 w-5 text-brand-teal" />
</div>
<div className="text-left">
@@ -620,8 +620,8 @@ async function JuryDashboardContent() {
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20">
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
<div className="rounded-lg bg-brand-blue/10 p-1.5">
<Waves className="h-4 w-4 text-brand-blue" />
</div>
<div>
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
@@ -650,13 +650,13 @@ async function JuryDashboardContent() {
className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
: 'border-border/60 bg-muted/20 dark:bg-muted/10'
? 'border-red-200 bg-red-50/50'
: 'border-border/60 bg-muted/20'
)}
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
<h3 className="font-semibold text-brand-blue">{round.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{program.name} &middot; {program.year}
</p>
@@ -716,7 +716,7 @@ async function JuryDashboardContent() {
<AnimatedCard index={8}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
<Clock className="h-6 w-6 text-brand-teal/70" />
</div>
<p className="font-semibold text-sm">No active voting stages</p>
@@ -734,7 +734,7 @@ async function JuryDashboardContent() {
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-lg">Round Summary</CardTitle>
@@ -750,7 +750,7 @@ async function JuryDashboardContent() {
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{round.name}</span>
<div className="flex items-baseline gap-1 shrink-0 ml-2">
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
<span className="font-bold tabular-nums text-brand-blue">{pct}%</span>
<span className="text-xs text-muted-foreground">({done}/{total})</span>
</div>
</div>
@@ -852,7 +852,7 @@ export default async function JuryDashboardPage() {
<div className="space-y-4">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{getGreeting()}, {session?.user?.name || 'Juror'}
</h1>
<p className="text-muted-foreground mt-0.5">

View File

@@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
},
})
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
// to keep tracking + chat working unchanged.
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
// Track view when project loads
const trackView = trpc.mentor.trackView.useMutation()
useEffect(() => {
if (project?.mentorAssignment?.id) {
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
if (primaryAssignment?.id) {
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.mentorAssignment?.id])
}, [primaryAssignment?.id])
if (isLoading) {
return <ProjectDetailSkeleton />
@@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
const mentorAssignment = project.mentorAssignment
const mentorAssignment = primaryAssignment
const mentorAssignmentId = mentorAssignment?.id
const programId = project.program?.id
const viewerIsAssignedMentor =
@@ -477,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={project.mentorAssignment?.mentor?.id || ''}
currentUserId={primaryAssignment?.mentor?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
@@ -592,7 +596,7 @@ function MilestonesSection({
<div
key={milestone.id}
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
isCompleted ? 'bg-green-50/50 border-green-200' : ''
}`}
>
<Checkbox

View File

@@ -1,21 +1,29 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
import { toast } from 'sonner'
export default function MentorWorkspaceDetailPage() {
const params = useParams()
const router = useRouter()
const { data: session } = useSession()
const projectId = params.projectId as string
// Get mentor assignment for this project
@@ -27,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
{ enabled: !!projectId }
)
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
// Gracefully tolerates stale tabs where the caller no longer has access
// (assignment dropped) — query just returns nothing in that case.
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
{ projectId },
{ enabled: !!projectId, retry: false }
)
const currentUserId = session?.user?.id
const coMentors = (projectMentors ?? []).filter(
a => a.mentor.id !== currentUserId
)
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
const visibleCoMentors = coMentorNames.slice(0, 3)
const hiddenCoMentors = coMentorNames.slice(3)
if (isLoading) {
return (
<div className="space-y-6">
@@ -70,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
{project.teamName && (
<p className="text-muted-foreground mt-1">{project.teamName}</p>
)}
{coMentors.length > 0 && (
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
You + {coMentors.length} co-mentor
{coMentors.length === 1 ? '' : 's'}:{' '}
<span className="text-foreground">
{visibleCoMentors.join(', ')}
</span>
{hiddenCoMentors.length > 0 && (
<>
{' '}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help underline decoration-dotted underline-offset-2">
+{hiddenCoMentors.length} more
</span>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
{hiddenCoMentors.join(', ')}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</span>
</div>
)}
</div>
</div>
@@ -104,7 +159,10 @@ export default function MentorWorkspaceDetailPage() {
<TabsContent value="files" className="mt-6">
{assignment ? (
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
<WorkspaceFilesPanel
projectId={projectId}
mentorAssignmentId={assignment.id}
/>
) : (
<Card>
<CardContent className="text-center py-8">
@@ -117,7 +175,7 @@ export default function MentorWorkspaceDetailPage() {
<TabsContent value="promotion" className="mt-6">
{assignment ? (
<FilePromotionPanel mentorAssignmentId={assignment.id} />
<FilePromotionPanel projectId={projectId} />
) : (
<Card>
<CardContent className="text-center py-8">

View File

@@ -280,7 +280,7 @@ function FinalistConfirmContent({ token }: { token: string }) {
Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean
Protection Challenge grand finale.
</p>
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3 dark:border-amber-700">
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3">
<p className="text-sm">
<strong>Confirm by {formatDeadline(deadline)}.</strong>
</p>

View File

@@ -218,35 +218,6 @@
--info: 194 25% 44%;
}
.dark {
--background: 220 15% 8%;
--foreground: 0 0% 98%;
--card: 220 15% 10%;
--card-foreground: 0 0% 98%;
--popover: 220 15% 10%;
--popover-foreground: 0 0% 98%;
--primary: 354 90% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 18%;
--secondary-foreground: 0 0% 98%;
--muted: 220 15% 18%;
--muted-foreground: 0 0% 64%;
--accent: 194 20% 18%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%;
--border: 220 15% 22%;
--input: 220 15% 22%;
--ring: 220 10% 50%;
}
}
@layer base {
@@ -345,13 +316,6 @@ div[class*="recharts-tooltip"] {
opacity: 1 !important;
}
.dark div[class*="tremor"][class*="tooltip"],
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
.dark div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border-color: hsl(var(--border)) !important;
}
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
.recharts-tooltip-wrapper svg.recharts-surface {
display: inline-block !important;

View File

@@ -2,7 +2,6 @@
import { useState } from 'react'
import { SessionProvider } from 'next-auth/react'
import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
@@ -78,12 +77,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
)
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<SessionProvider>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
</SessionProvider>
</ThemeProvider>
<SessionProvider>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
</SessionProvider>
)
}

View File

@@ -437,7 +437,7 @@ export function AssignmentPreviewSheet({
{mode === 'ai' && !aiResult && !isAIGenerating && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-8 gap-3">
<div className="h-12 w-12 rounded-full bg-violet-100 dark:bg-violet-950 flex items-center justify-center">
<div className="h-12 w-12 rounded-full bg-violet-100 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-violet-600" />
</div>
<div className="text-center space-y-1">
@@ -463,7 +463,7 @@ export function AssignmentPreviewSheet({
{isLoading ? (
<div className="space-y-3">
{mode === 'ai' && (
<Card className="border-violet-200 bg-violet-50/50 dark:bg-violet-950/20">
<Card className="border-violet-200 bg-violet-50/50">
<CardContent className="flex items-center gap-3 py-4">
<div className="relative">
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
@@ -567,13 +567,13 @@ export function AssignmentPreviewSheet({
{/* ── Warnings ── */}
{preview.warnings && preview.warnings.length > 0 && (
<Card className="border-amber-300 bg-amber-50/50 dark:bg-amber-950/20">
<Card className="border-amber-300 bg-amber-50/50">
<CardContent className="p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div className="space-y-1">
{preview.warnings.map((w: string, idx: number) => (
<p key={idx} className="text-xs text-amber-800 dark:text-amber-200">
<p key={idx} className="text-xs text-amber-800">
{w}
</p>
))}

View File

@@ -259,7 +259,7 @@ export function AwardShortlist({
}
</p>
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.

View File

@@ -328,13 +328,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<div className="space-y-6">
{/* Grace Period Banner */}
{summary.isGracePeriodActive && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
<Card className="border-amber-200 bg-amber-50">
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-amber-600" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
<p className="text-sm text-amber-600 dark:text-amber-400">
<p className="font-medium text-amber-800">Grace Period Active</p>
<p className="text-sm text-amber-600">
Applicants can still submit until{' '}
{summary.gracePeriodEndsAt
? new Date(summary.gracePeriodEndsAt).toLocaleString()
@@ -358,12 +358,12 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{/* Finalized Banner */}
{summary.isFinalized && (
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<Card className="border-green-200 bg-green-50">
<CardContent className="flex items-center gap-3 py-4">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
<p className="text-sm text-green-600 dark:text-green-400">
<p className="font-medium text-green-800">Round Finalized</p>
<p className="text-sm text-green-600">
Finalized on{' '}
{summary.finalizedAt
? new Date(summary.finalizedAt).toLocaleString()
@@ -376,13 +376,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{/* Needs Processing Banner */}
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
<Card className="border-blue-200 bg-blue-50">
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-blue-600" />
<div>
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
<p className="text-sm text-blue-600 dark:text-blue-400">
<p className="font-medium text-blue-800">Projects Need Processing</p>
<p className="text-sm text-blue-600">
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity.
</p>

View File

@@ -9,6 +9,7 @@ import {
ArrowRight,
Clock,
FileText,
Inbox,
MessageCircle,
Target,
UserCheck,
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
{ refetchInterval: 30_000 },
)
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
{ status: 'PENDING' },
{ refetchInterval: 30_000 },
)
if (statsLoading || poolLoading) {
return (
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
}
if (!stats || !pool) return null
const pendingCount = pendingChangeRequests?.length ?? 0
// If there's at least one pending request, deep-link directly into the
// first one's project (admins can resolve / view siblings from there).
// Otherwise the card stays static.
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
const changeRequestsHref = firstPendingProjectId
? `/admin/projects/${firstPendingProjectId}/mentor`
: null
const requestedPct = stats.totalProjects
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
: 0
@@ -110,7 +124,7 @@ export function MentoringRoundOverview({ roundId }: Props) {
<p className="text-muted-foreground mt-1 text-xs">
{assignedPct}% of round{' '}
{stats.awaitingAssignment > 0 && (
<span className="text-amber-700 dark:text-amber-400">
<span className="text-amber-700">
· {stats.awaitingAssignment} awaiting
</span>
)}
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
</CardContent>
</Card>
<Card
className={`md:col-span-2 xl:col-span-4 ${
pendingCount > 0 ? 'border-amber-300' : ''
}`}
>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Inbox
className={`h-5 w-5 ${
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
}`}
/>
<div>
<div className="text-sm font-medium">Pending change requests</div>
<div className="text-muted-foreground text-xs">
Team members asking admin to swap a mentor
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
{changeRequestsHref ? (
<Link
href={changeRequestsHref}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
>
Review
<ArrowRight className="ml-0.5 h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground text-xs">All clear</span>
)}
</div>
</CardContent>
</Card>
<Card className="md:col-span-2 xl:col-span-4">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Workspace activity</CardTitle>

View File

@@ -290,8 +290,8 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
<div className="space-y-4">
{/* Finalization hint for closed rounds */}
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm">
<span className="text-blue-700 dark:text-blue-300">
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm">
<span className="text-blue-700">
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
</span>
</div>

View File

@@ -699,7 +699,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
This may take a minute. You can continue working results will appear automatically.
</p>
</div>
<div className="h-2 w-48 rounded-full bg-blue-100 dark:bg-blue-900 overflow-hidden">
<div className="h-2 w-48 rounded-full bg-blue-100 overflow-hidden">
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
</div>
</>
@@ -962,18 +962,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{/* Ranking in-progress banner */}
{rankingInProgress && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
<Card className="border-blue-200 bg-blue-50">
<CardContent className="flex items-center gap-3 py-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" />
<Loader2 className="h-5 w-5 animate-spin text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900 dark:text-blue-200">
<p className="text-sm font-medium text-blue-900">
Ranking in progress&hellip;
</p>
<p className="text-xs text-blue-700 dark:text-blue-400">
<p className="text-xs text-blue-700">
This may take a minute. You can continue working results will appear automatically.
</p>
</div>
<div className="h-1.5 w-32 rounded-full bg-blue-200 dark:bg-blue-800 overflow-hidden flex-shrink-0">
<div className="h-1.5 w-32 rounded-full bg-blue-200 overflow-hidden flex-shrink-0">
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
</div>
</CardContent>
@@ -1097,7 +1097,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={isAdvancing
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400 dark:bg-emerald-950/20 dark:border-emerald-600'
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400'
: ''}
>
<SortableProjectRow
@@ -1120,7 +1120,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{isCutoffRow && (
<div className="flex items-center gap-2 py-1">
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap">
<span className="text-xs font-medium text-emerald-600 whitespace-nowrap">
Advancement cutoff {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`}
</span>
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />

View File

@@ -33,19 +33,19 @@ const severityConfig = {
critical: {
icon: AlertTriangle,
iconClass: 'text-red-600',
bgClass: 'bg-red-50 dark:bg-red-950/30',
bgClass: 'bg-red-50',
borderClass: 'border-l-red-500',
},
warning: {
icon: AlertCircle,
iconClass: 'text-amber-600',
bgClass: 'bg-amber-50 dark:bg-amber-950/30',
bgClass: 'bg-amber-50',
borderClass: 'border-l-amber-500',
},
info: {
icon: Info,
iconClass: 'text-blue-600',
bgClass: 'bg-blue-50 dark:bg-blue-950/30',
bgClass: 'bg-blue-50',
borderClass: 'border-l-blue-500',
},
}
@@ -54,8 +54,8 @@ export function SmartActions({ actions }: SmartActionsProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/40">
<Zap className="h-5 w-5 text-amber-600 dark:text-amber-400" />
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Zap className="h-5 w-5 text-amber-600" />
</div>
<CardTitle className="flex-1">Action Required</CardTitle>
{actions.length > 0 && (
@@ -65,8 +65,8 @@ export function SmartActions({ actions }: SmartActionsProps) {
<CardContent>
{actions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
</div>
<p className="mt-3 text-sm font-medium text-muted-foreground">
All caught up!

View File

@@ -207,7 +207,7 @@ export function EvaluationFormFields({
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default',
)}
@@ -222,7 +222,7 @@ export function EvaluationFormFields({
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
? 'border-red-500 bg-red-50 text-red-700'
: 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default',
)}

View File

@@ -83,10 +83,10 @@ export function EvaluationFormWithCOI({
<CardContent className="flex items-center gap-3 py-6">
<ShieldAlert className="h-6 w-6 text-amber-600 shrink-0" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">
<p className="font-medium text-amber-800">
Conflict of Interest Declared
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
<p className="text-sm text-amber-700 mt-1">
You declared a conflict of interest for this project. An admin will
review your declaration. You cannot evaluate this project while the
conflict is under review.

View File

@@ -62,7 +62,7 @@ export function JuryPreferencesBanner() {
if (isLoading || unconfirmed.length === 0) return null
return (
<Card className="border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20">
<Card className="border-amber-300 bg-amber-50/50">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-amber-600" />

View File

@@ -19,11 +19,10 @@ import {
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import {
LogOut, Menu, Moon, Settings, Sun, User, X,
LogOut, Menu, Settings, User, X,
ArrowRightLeft,
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
import { useRoleSwitcher, RoleSwitcherPill } from './role-switcher'
@@ -69,9 +68,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
})
const endImpersonation = trpc.user.endImpersonation.useMutation()
const logNavClick = trpc.learningResource.logNavClick.useMutation()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const handleSignOut = async () => {
if (isImpersonating) {
@@ -172,20 +168,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
</DropdownMenuContent>
</DropdownMenu>
)}
{mounted && (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)}
<RoleSwitcherPill currentBasePath={basePath} />
<NotificationBell />
<DropdownMenu>

View File

@@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react'
import { toast } from 'sonner'
interface FilePromotionPanelProps {
mentorAssignmentId: string
projectId: string
}
function formatFileSize(bytes: number): string {
@@ -28,14 +28,14 @@ function formatFileSize(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) {
export function FilePromotionPanel({ projectId }: FilePromotionPanelProps) {
const [selectedSlot, setSelectedSlot] = useState<string>('')
const utils = trpc.useUtils()
const { data: workspaceFiles = [], isLoading: filesLoading } =
trpc.mentor.workspaceGetFiles.useQuery(
{ mentorAssignmentId },
{ enabled: !!mentorAssignmentId },
{ projectId },
{ enabled: !!projectId },
)
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({

View File

@@ -12,10 +12,18 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react'
import { Eye, FileText, Upload, Download, Trash2, MessageSquare, X, Loader2 } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
interface Props {
/** Project the workspace belongs to — drives file list (project-scoped). */
projectId: string
/**
* One MentorAssignment id on this project — needed only to mint upload tokens
* (the token is signed against the assignment + project pair, but the file
* itself is project-scoped so co-mentors see it).
*/
mentorAssignmentId: string
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
asApplicant?: boolean
@@ -29,21 +37,21 @@ function formatSize(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) {
export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) {
const utils = trpc.useUtils()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [description, setDescription] = useState('')
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
{ mentorAssignmentId },
{ enabled: !!mentorAssignmentId }
{ projectId },
{ enabled: !!projectId }
)
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
onSuccess: () => {
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
utils.mentor.workspaceGetFiles.invalidate({ projectId })
setDescription('')
toast.success('File uploaded')
},
@@ -51,7 +59,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
onSuccess: () => {
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
utils.mentor.workspaceGetFiles.invalidate({ projectId })
toast.success('File deleted')
},
onError: (e) => toast.error(e.message),
@@ -83,10 +91,43 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
}
}
const [previewFileId, setPreviewFileId] = useState<string | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const canPreviewMime = (m: string, name: string) =>
m.startsWith('video/') || m === 'application/pdf' || m.startsWith('image/') || isOfficeFile(m, name)
const togglePreview = async (file: { id: string; mimeType: string; fileName: string }) => {
if (previewFileId === file.id) {
setPreviewFileId(null)
setPreviewUrl(null)
return
}
setPreviewFileId(file.id)
setPreviewUrl(null)
setPreviewLoading(true)
try {
const { url } = await downloadMutation.mutateAsync({ mentorFileId: file.id, disposition: 'inline' })
setPreviewUrl(url)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Preview failed')
setPreviewFileId(null)
} finally {
setPreviewLoading(false)
}
}
const handleDownload = async (mentorFileId: string) => {
try {
const { url } = await downloadMutation.mutateAsync({ mentorFileId })
window.open(url, '_blank')
const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' })
const a = document.createElement('a')
a.href = url
a.download = ''
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Download failed')
}
@@ -141,8 +182,12 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
)}
<ul className="divide-y">
{(files ?? []).map((f) => (
<li key={f.id} className="flex items-center gap-3 py-3">
{(files ?? []).map((f) => {
const isOpen = previewFileId === f.id
const previewable = canPreviewMime(f.mimeType, f.fileName)
return (
<li key={f.id} className="py-3 space-y-2">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{f.fileName}</div>
@@ -160,7 +205,24 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
)}
</div>
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
{previewable && (
<Button
variant="ghost"
size="icon"
onClick={() => togglePreview(f)}
title={isOpen ? 'Close preview' : 'Preview'}
aria-label={isOpen ? 'Close preview' : 'Preview file'}
>
{isOpen ? <X className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(f.id)}
title="Download"
aria-label="Download file"
>
<Download className="h-4 w-4" />
</Button>
<AlertDialog>
@@ -184,8 +246,22 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{isOpen && (
<div className="rounded-md border bg-muted/30 overflow-hidden">
{previewLoading || !previewUrl ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading preview
</div>
) : (
<FilePreview file={{ mimeType: f.mimeType, fileName: f.fileName }} url={previewUrl} />
)}
</div>
)}
</li>
))}
)
})}
</ul>
</CardContent>
</Card>

View File

@@ -9,10 +9,10 @@ import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils'
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' },
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true },
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' },
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100' },
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50', pulse: true },
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50' },
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50' },
}
export function LiveFinalPanel({ roundId }: { roundId: string }) {

View File

@@ -52,7 +52,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
{!collapsed && (
<CardContent className="space-y-4">
{/* Headline Stat */}
<div className="flex items-center gap-3 rounded-lg bg-rose-50 dark:bg-rose-950/20 p-4">
<div className="flex items-center gap-3 rounded-lg bg-rose-50 p-4">
<ArrowDown className="h-6 w-6 text-rose-500 shrink-0" />
<div>
<p className="text-lg font-semibold">
@@ -85,7 +85,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
</div>
<div className="relative h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 dark:bg-slate-600 transition-all"
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 transition-all"
style={{ width: `${prevPct}%` }}
/>
<div

View File

@@ -37,9 +37,9 @@ type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
function outcomeTextColor(outcome: string): string {
switch (outcome) {
case 'PASSED': return 'text-emerald-700 dark:text-emerald-400'
case 'FILTERED_OUT': return 'text-rose-700 dark:text-rose-400'
case 'FLAGGED': return 'text-amber-700 dark:text-amber-400'
case 'PASSED': return 'text-emerald-700'
case 'FILTERED_OUT': return 'text-rose-700'
case 'FLAGGED': return 'text-amber-700'
default: return 'text-primary'
}
}

View File

@@ -339,8 +339,8 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
)}
{storageProvider === 'local' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950">
<p className="text-sm text-amber-800 dark:text-amber-200">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800">
<strong>Warning:</strong> Local storage is not recommended for production deployments
with multiple servers, as files will only be accessible from the server that uploaded them.
</p>

View File

@@ -62,9 +62,9 @@ function getUrgency(totalMs: number): Urgency {
const urgencyStyles: Record<Urgency, string> = {
expired: 'text-muted-foreground bg-muted',
critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900',
warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900',
normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
critical: 'text-red-700 bg-red-50 border-red-200',
warning: 'text-amber-700 bg-amber-50 border-amber-200',
normal: 'text-green-700 bg-green-50 border-green-200',
}
export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) {

View File

@@ -19,18 +19,18 @@ import { useState } from 'react'
const statusConfig: Record<string, { bg: string; text: string; dot: string }> = {
DRAFT: {
bg: 'bg-amber-50 dark:bg-amber-950/50',
text: 'text-amber-700 dark:text-amber-400',
bg: 'bg-amber-50',
text: 'text-amber-700',
dot: 'bg-amber-500',
},
ACTIVE: {
bg: 'bg-emerald-50 dark:bg-emerald-950/50',
text: 'text-emerald-700 dark:text-emerald-400',
bg: 'bg-emerald-50',
text: 'text-emerald-700',
dot: 'bg-emerald-500',
},
ARCHIVED: {
bg: 'bg-slate-100 dark:bg-slate-800/50',
text: 'text-slate-600 dark:text-slate-400',
bg: 'bg-slate-100',
text: 'text-slate-600',
dot: 'bg-slate-400',
},
}
@@ -95,10 +95,10 @@ export function EditionSelector() {
{/* Text */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
<p className="truncate text-sm font-semibold text-slate-900">
{currentEdition ? currentEdition.year : 'Select'}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
<p className="truncate text-xs text-slate-500">
{currentEdition?.status === 'ACTIVE' ? 'Current Edition' : currentEdition?.status?.toLowerCase()}
</p>
</div>
@@ -136,7 +136,7 @@ export function EditionSelector() {
}}
className={cn(
'group/item flex items-center gap-3 rounded-lg px-2.5 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'hover:bg-slate-50 dark:hover:bg-slate-800/50'
isSelected ? 'bg-slate-100' : 'hover:bg-slate-50'
)}
>
{/* Year badge in dropdown */}
@@ -144,19 +144,19 @@ export function EditionSelector() {
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg font-bold text-sm transition-colors',
isSelected
? 'bg-brand-blue text-white'
: 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
: 'bg-slate-200 text-slate-600'
)}>
{String(edition.year).slice(-2)}
</div>
{/* Edition info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
<p className="truncate text-sm font-semibold text-slate-900">
{edition.year}
</p>
<div className="flex items-center gap-1.5">
<div className={cn('h-1.5 w-1.5 rounded-full', editionStatus.dot)} />
<span className="text-xs text-slate-500 dark:text-slate-400 capitalize">
<span className="text-xs text-slate-500 capitalize">
{edition.status.toLowerCase()}
</span>
</div>

View File

@@ -827,9 +827,9 @@ function RequirementChecklist({ roundId, files }: { roundId: string; files: Proj
className={cn(
'flex items-center gap-3 rounded-lg border p-2.5 text-sm',
isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
? 'border-green-200 bg-green-50'
: req.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
? 'border-red-200 bg-red-50'
: 'border-muted'
)}
>

View File

@@ -108,20 +108,20 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconBg: 'bg-slate-100',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
iconBg: 'bg-amber-100',
iconColor: 'text-amber-600',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
},
}
@@ -158,7 +158,7 @@ function NotificationItem({
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
!notification.isRead && 'bg-blue-50/50'
)}
onClick={onRead}
>

View File

@@ -263,9 +263,9 @@ export function RequirementUploadSlot({
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
? 'border-green-200 bg-green-50'
: requirement.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
? 'border-red-200 bg-red-50'
: 'border-muted'
// Build accept string for file input

View File

@@ -4,39 +4,39 @@ import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
// Round statuses
DRAFT: { variant: 'secondary' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200' },
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
ROUND_DRAFT: { variant: 'secondary' },
ROUND_ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
ROUND_ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
ROUND_CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
ROUND_ARCHIVED: { variant: 'secondary', className: 'bg-slate-400/10 text-slate-400 border-slate-200' },
// Project statuses
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300' },
REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' },
// Observer-derived statuses
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200' },
// Round state statuses
PENDING: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
PASSED: { variant: 'default', className: 'bg-green-500/10 text-green-700 border-green-200 dark:text-green-400' },
PENDING: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200' },
PASSED: { variant: 'default', className: 'bg-green-500/10 text-green-700 border-green-200' },
// User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200' },
INACTIVE: { variant: 'secondary' },
SUSPENDED: { variant: 'destructive' },
}

View File

@@ -9,11 +9,11 @@ const alertVariants = cva(
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
"border-destructive/50 text-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-600",
"border-green-500/50 text-green-700 [&>svg]:text-green-600",
warning:
"border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-600",
"border-yellow-500/50 text-yellow-700 [&>svg]:text-yellow-600",
},
},
defaultVariants: {

View File

@@ -15,10 +15,10 @@ const badgeVariants = cva(
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
'border-transparent bg-green-100 text-green-800',
warning:
'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
info: 'border-transparent bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
'border-transparent bg-amber-100 text-amber-800',
info: 'border-transparent bg-blue-100 text-blue-800',
},
},
defaultVariants: {

View File

@@ -2752,6 +2752,177 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
// =============================================================================
// Per-team mentor assignment (fires every time a mentor is added to a project)
// =============================================================================
function getMentorTeamAssignmentTemplate(
name: string,
projectTitle: string,
workspaceUrl: string,
): EmailTemplate {
const subject = `You've been assigned to a new MOPC project: "${projectTitle}"`
const greeting = name ? `Hi ${name},` : 'Hi there,'
const text = [
greeting,
'',
`You have been assigned as a mentor to the project "${projectTitle}".`,
'',
'You may have co-mentors on this team — you can collaborate together in the project workspace.',
'',
`Open the workspace: ${workspaceUrl}`,
'',
'The MOPC team',
].join('\n')
const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
<h1 style="margin:0;font-size:20px;font-weight:600;">New mentor assignment</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
<p>You have been assigned as a mentor to the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
<p style="margin-top:24px;">
<a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Project Workspace</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
You may have co-mentors on this team — you can collaborate together in the project workspace.
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
</div>
</div>
</body>
</html>
`.trim()
return { subject, text, html }
}
/**
* Send a per-team mentor assignment email. Fires every time a mentor is added
* to a specific project (distinct from the one-time onboarding email).
* Idempotency is enforced at the call site via MentorAssignment.notificationSentAt.
* Never throws — failures are caught and logged.
*/
export async function sendMentorTeamAssignmentEmail(
email: string,
name: string | null,
projectTitle: string,
projectId: string,
): Promise<void> {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const workspaceUrl = `${baseUrl.replace(/\/$/, '')}/mentor/workspace/${projectId}`
const template = getMentorTeamAssignmentTemplate(name || '', projectTitle, workspaceUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
} catch (error) {
console.error('[sendMentorTeamAssignmentEmail] failed', { email, projectId, error })
}
}
// =============================================================================
// Mentor change requests (PR 8) — admin notification when an applicant or admin
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
// =============================================================================
function getMentorChangeRequestTemplate(
projectTitle: string,
requesterName: string | null,
reason: string,
adminDashboardUrl: string,
): EmailTemplate {
const subject = `Mentor change request for "${projectTitle}"`
const requesterLabel = requesterName || 'a team member'
const text = [
'Hi MOPC admins,',
'',
`A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`,
'',
'Reason:',
`"${reason}"`,
'',
`Review the request: ${adminDashboardUrl}`,
'',
'The MOPC team',
].join('\n')
const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
<h1 style="margin:0;font-size:20px;font-weight:600;">Mentor change request</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">Hi MOPC admins,</p>
<p>A mentor change request has been opened by <strong>${escapeHtml(requesterLabel)}</strong> for the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
<blockquote style="margin:16px 0;padding:12px 16px;background:#f1f5f9;border-left:3px solid #557f8c;border-radius:4px;color:#0f172a;font-style:italic;white-space:pre-wrap;">${escapeHtml(reason)}</blockquote>
<p style="margin-top:24px;">
<a href="${adminDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Review Request</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
Mentors are not notified of change requests; only admins see this.
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
</div>
</div>
</body>
</html>
`.trim()
return { subject, text, html }
}
/**
* Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request
* has been opened for a project. Sends one email per recipient.
* Never throws — failures are caught and logged so the calling mutation
* (mentor.requestChange) never fails because of email infrastructure issues.
*/
export async function sendMentorChangeRequestEmail(
adminEmails: string[],
projectTitle: string,
requesterName: string | null,
reason: string,
adminDashboardUrl: string,
): Promise<void> {
try {
if (adminEmails.length === 0) {
console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping')
return
}
const template = getMentorChangeRequestTemplate(
projectTitle,
requesterName,
reason,
adminDashboardUrl,
)
await Promise.all(
adminEmails.map((email) =>
sendEmail({
to: email,
subject: template.subject,
text: template.text,
html: template.html,
}).catch((err) => {
console.error('[sendMentorChangeRequestEmail] send failed', { email, err })
}),
),
)
} catch (error) {
console.error('[sendMentorChangeRequestEmail] failed', { error })
}
}
function getFinalistConfirmationTemplate(
name: string,
projectTitle: string,

View File

@@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto'
export type MentorUploadPayload = {
mentorAssignmentId: string
/**
* Project the upload belongs to. Bound at token-issue time so the file's
* project scope can't be tampered with separately from the assignment id.
* Required (no legacy fallback) — tokens live <1h, so any in-flight tokens
* issued before this field was added expire on their own.
*/
projectId: string
uploaderUserId: string
fileName: string
mimeType: string
@@ -47,5 +54,8 @@ export function verifyMentorUploadToken(token: string): MentorUploadPayload {
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Invalid mentor upload token: expired')
}
if (typeof payload.projectId !== 'string' || payload.projectId.length === 0) {
throw new Error('Invalid mentor upload token: missing projectId')
}
return payload
}

View File

@@ -78,13 +78,17 @@ export async function getPresignedUrl(
objectKey: string,
method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900, // 15 minutes default
options?: { downloadFileName?: string }
options?: { downloadFileName?: string; inline?: boolean; contentType?: string }
): Promise<string> {
const publicClient = getPublicMinioClient()
if (method === 'GET') {
const respHeaders = options?.downloadFileName
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
: undefined
let respHeaders: Record<string, string> | undefined
if (options?.inline) {
respHeaders = { 'response-content-disposition': 'inline' }
if (options.contentType) respHeaders['response-content-type'] = options.contentType
} else if (options?.downloadFileName) {
respHeaders = { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
}
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
} else {
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)

View File

@@ -2403,4 +2403,67 @@ export const analyticsRouter = router({
prisma: ctx.prisma,
})
}),
/**
* Nationality breakdown for the applicants (team members) of projects in
* the selected scope. Counts UNIQUE users so a single applicant on
* multiple teams isn't double-counted.
*
* Scope:
* - roundId set → projects with a ProjectRoundState in that round
* - programId set → projects in that program
* - neither → all team members across all projects (global)
*/
getApplicantNationalities: adminProcedure
.input(
z
.object({
roundId: z.string().optional(),
programId: z.string().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const roundId = input?.roundId
const programId = input?.programId
const projectFilter = roundId
? { projectRoundStates: { some: { roundId } } }
: programId
? { programId }
: {}
// Pull all distinct team-member userIds + their nationality in one query.
// `distinct: ['userId']` collapses a user appearing on multiple teams in
// the same scope to a single row.
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { project: projectFilter },
select: { userId: true, user: { select: { nationality: true } } },
distinct: ['userId'],
})
const total = teamMembers.length
const declaredEntries = teamMembers.filter(
(tm) => tm.user?.nationality && tm.user.nationality.trim().length > 0
)
const declared = declaredEntries.length
const notDeclared = total - declared
const counts = new Map<string, number>()
for (const tm of declaredEntries) {
const code = (tm.user!.nationality as string).trim()
counts.set(code, (counts.get(code) ?? 0) + 1)
}
const byCountry = Array.from(counts.entries())
.map(([country, count]) => ({ country, count }))
.sort((a, b) => b.count - a.count || a.country.localeCompare(b.country))
return {
total,
declared,
notDeclared,
byCountry,
}
}),
})

View File

@@ -1176,7 +1176,7 @@ export const applicantRouter = router({
],
},
include: {
mentorAssignment: { select: { mentorId: true } },
mentorAssignments: { select: { mentorId: true } },
},
})
@@ -1187,7 +1187,10 @@ export const applicantRouter = router({
})
}
if (!project.mentorAssignment) {
// TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the
// first one for legacy parity.
const primaryMentorAssignment = project.mentorAssignments[0] ?? null
if (!primaryMentorAssignment) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No mentor assigned to this project',
@@ -1207,9 +1210,9 @@ export const applicantRouter = router({
},
})
// Notify the mentor
// Notify the (primary) mentor
await createNotification({
userId: project.mentorAssignment.mentorId,
userId: primaryMentorAssignment.mentorId,
type: 'MENTOR_MESSAGE',
title: 'New Message',
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
@@ -1313,12 +1316,13 @@ export const applicantRouter = router({
submittedBy: {
select: { id: true, name: true, email: true },
},
mentorAssignment: {
mentorAssignments: {
include: {
mentor: {
select: { id: true, name: true, email: true },
select: { id: true, name: true, email: true, expertiseTags: true },
},
},
orderBy: { assignedAt: 'asc' },
},
wonAwards: {
select: { id: true, name: true },
@@ -1489,6 +1493,17 @@ export const applicantRouter = router({
logoUrl = await provider.getDownloadUrl(project.logoKey)
}
// Does this user have an open mentor-change request for this project?
// (Used by the applicant mentor page to disable the "Request a change" button.)
const myPendingChangeRequest = await ctx.prisma.mentorChangeRequest.findFirst({
where: {
projectId: project.id,
requestedByUserId: ctx.user.id,
status: 'PENDING',
},
select: { id: true },
})
return {
project: {
...project,
@@ -1502,6 +1517,7 @@ export const applicantRouter = router({
hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound,
logoUrl,
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
}
}),
@@ -1523,7 +1539,7 @@ export const applicantRouter = router({
select: {
id: true,
programId: true,
mentorAssignment: { select: { id: true } },
mentorAssignments: { select: { id: true }, take: 1 },
},
})
@@ -1531,8 +1547,8 @@ export const applicantRouter = router({
return { hasMentor: false, hasEvaluationRounds: false }
}
// Check if mentor is assigned
const hasMentor = !!project.mentorAssignment
// Check if mentor is assigned (any active assignment counts)
const hasMentor = project.mentorAssignments.length > 0
// Check if feedback is available — first check admin settings, then fall back to per-round config
let hasEvaluationRounds = false
@@ -2689,8 +2705,12 @@ export const applicantRouter = router({
})
}
const assignment = await ctx.prisma.mentorAssignment.findUnique({
// TODO(PR8 Task 7): when multiple mentors are assigned, surface them all
// in the applicant message thread. For now we display the most recently
// assigned (non-dropped) mentor as the "primary".
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: { projectId: input.projectId },
orderBy: { assignedAt: 'desc' },
include: { mentor: { select: { id: true, name: true, email: true } } },
})

View File

@@ -772,7 +772,8 @@ export const finalistRouter = router({
select: {
id: true,
title: true,
mentorAssignment: {
mentorAssignments: {
where: { droppedAt: null, completionStatus: { not: 'completed' } },
select: {
id: true,
completionStatus: true,
@@ -796,10 +797,12 @@ export const finalistRouter = router({
data: { status: 'SUPERSEDED' },
})
// Cascade: drop active mentor assignment (skip if completed or already dropped)
const ma = confirmation.project.mentorAssignment
// Cascade: drop ALL active mentor assignments (skip dropped/completed
// those were filtered out by the include `where` above). With multi-mentor
// (PR8) we propagate the cascade to every active assignment.
const activeAssignments = confirmation.project.mentorAssignments
let cascadedMentorAssignment = false
if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') {
for (const ma of activeAssignments) {
await ctx.prisma.mentorAssignment.update({
where: { id: ma.id },
data: {
@@ -833,6 +836,7 @@ export const finalistRouter = router({
reason: input.reason,
projectId: confirmation.projectId,
cascadedMentorAssignment,
cascadedAssignmentCount: activeAssignments.length,
},
})
return { ok: true, cascadedMentorAssignment }

View File

@@ -1,7 +1,16 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
import {
MentorAssignmentMethod,
MentorChangeRequestStatus,
Prisma,
type PrismaClient,
} from '@prisma/client'
import {
sendMentorChangeRequestEmail,
sendMentorTeamAssignmentEmail,
} from '@/lib/email'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
@@ -66,6 +75,42 @@ async function assertWorkspaceAccess(
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
/**
* Project-scoped workspace access check (PR8 multi-mentor).
*
* Allowed when the user is either:
* 1) currently assigned as a mentor on this project (droppedAt = null), OR
* 2) a team member of the project.
*
* Also requires at least one active mentor assignment for the project with
* workspaceEnabled = true — meaning the project actually has a live workspace.
* Throws TRPCError on failure. Returns nothing on success.
*/
async function assertProjectWorkspaceAccess(
prisma: PrismaClient,
userId: string,
projectId: string,
): Promise<void> {
const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
where: { projectId, droppedAt: null, workspaceEnabled: true },
select: { id: true },
})
if (!liveMentorAssignment) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
}
const mentorOnProject = await prisma.mentorAssignment.findFirst({
where: { projectId, mentorId: userId, droppedAt: null },
select: { id: true },
})
if (mentorOnProject) return
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId, userId },
select: { id: true },
})
if (teamMembership) return
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
export const mentorRouter = router({
/**
* Get AI-suggested mentor matches for a project
@@ -82,18 +127,15 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
mentorAssignment: true,
mentorAssignments: true,
},
})
if (project.mentorAssignment) {
return {
currentMentor: project.mentorAssignment,
suggestions: [],
source: 'ai' as const,
message: 'Project already has a mentor assigned',
}
}
// With multi-mentor (PR8) the project can have several mentors. The
// suggestions endpoint is informational — return whatever AI suggests
// and let `mentor.assign` enforce per-pair uniqueness. We still surface
// an existing primary mentor in the payload so UIs can label it.
const primaryMentor = project.mentorAssignments[0] ?? null
// Detect AI configuration so the UI can label "AI matching unavailable"
// when we fall back to algorithmic ranking. An AI error mid-call still
@@ -140,7 +182,9 @@ export const mentorRouter = router({
})
return {
currentMentor: null,
// TODO(PR8 Task 8): return the full mentor list. Legacy field kept
// until the admin UI is updated.
currentMentor: primaryMentor,
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
source,
message: null,
@@ -219,52 +263,62 @@ export const mentorRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't have a mentor
// Verify project exists (multi-mentor: stacking is allowed; duplicate
// (projectId, mentorId) pairs are rejected by the unique constraint
// below).
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignment: true },
})
if (project.mentorAssignment) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
})
}
// Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
})
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
// Create assignment. P2002 on the composite (projectId, mentorId) unique
// constraint means this exact mentor is already on this team — surface a
// friendly error.
let assignment
try {
assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
})
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new TRPCError({
code: 'CONFLICT',
message: 'This mentor is already assigned to that project.',
})
}
throw err
}
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
@@ -279,6 +333,8 @@ export const mentorRouter = router({
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
method: input.method,
// PR8: per-team assignment (one row per mentor-project pair).
assignmentScope: 'per-team',
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -320,6 +376,27 @@ export const mentorRouter = router({
},
})
// Send per-team email notification once per assignment row. Idempotency
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
// it null. If the same mentor is later dropped and re-assigned (new row,
// fresh id), a new email is sent — intentional.
if (assignment.notificationSentAt == null && assignment.mentor.email) {
await sendMentorTeamAssignmentEmail(
assignment.mentor.email,
assignment.mentor.name,
assignment.project.title,
input.projectId,
)
try {
await ctx.prisma.mentorAssignment.update({
where: { id: assignment.id },
data: { notificationSentAt: new Date() },
})
} catch (e) {
console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
}
}
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
@@ -351,13 +428,16 @@ export const mentorRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't have a mentor
// Verify project exists and doesn't already have a mentor. Multi-mentor
// stacking is reserved for explicit admin assignment via `mentor.assign`;
// auto-assignment skips projects that already have at least one mentor
// to avoid double-AI-assignments.
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignment: true },
include: { mentorAssignments: { select: { id: true } } },
})
if (project.mentorAssignment) {
if (project.mentorAssignments.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
@@ -485,29 +565,51 @@ export const mentorRouter = router({
}),
/**
* Remove mentor assignment
* Remove mentor assignment.
*
* Multi-mentor (PR8): callers should pass `assignmentId` to target a
* specific co-mentor. Legacy callers passing only `projectId` get the
* most-recent assignment removed (kept for backward compatibility).
*/
unassign: adminProcedure
.input(z.object({ projectId: z.string() }))
.input(
z
.object({
assignmentId: z.string().optional(),
projectId: z.string().optional(),
})
.refine((v) => !!v.assignmentId || !!v.projectId, {
message: 'Either assignmentId or projectId is required',
}),
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUnique({
where: { projectId: input.projectId },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
const assignment = input.assignmentId
? await ctx.prisma.mentorAssignment.findUnique({
where: { id: input.assignmentId },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
: await ctx.prisma.mentorAssignment.findFirst({
where: { projectId: input.projectId! },
orderBy: { assignedAt: 'desc' },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
if (!assignment) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No mentor assignment found for this project',
message: 'No mentor assignment found',
})
}
// Delete assignment
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
where: { id: assignment.id },
})
// Audit outside transaction so failures don't roll back the unassignment
@@ -518,7 +620,7 @@ export const mentorRouter = router({
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectId: assignment.project.id,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,
@@ -546,7 +648,7 @@ export const mentorRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
programId: input.programId,
mentorAssignment: null,
mentorAssignments: { none: {} },
wantsMentorship: true,
},
select: { id: true },
@@ -716,7 +818,7 @@ export const mentorRouter = router({
where: {
roundId: input.roundId,
project: {
mentorAssignment: null,
mentorAssignments: { none: {} },
// Only assign mentors to projects whose team has confirmed they will
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
// confirmations and any project without a confirmation row at all.
@@ -834,7 +936,7 @@ export const mentorRouter = router({
where: {
roundId: input.roundId,
project: {
mentorAssignment: { isNot: null },
mentorAssignments: { some: {} },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
},
},
@@ -906,13 +1008,13 @@ export const mentorRouter = router({
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: { wantsMentorship: true, mentorAssignment: { isNot: null } },
project: { wantsMentorship: true, mentorAssignments: { some: {} } },
},
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: { mentorAssignment: { isNot: null } },
project: { mentorAssignments: { some: {} } },
},
}),
ctx.prisma.mentorMessage.count({
@@ -1107,7 +1209,11 @@ export const mentorRouter = router({
status: true,
oceanIssue: true,
competitionCategory: true,
mentorAssignment: {
mentorAssignments: {
// TODO(PR8 Task 8): surface all mentors in the activity view.
// For now keep the legacy single-mentor activity row by picking the
// latest-assigned, non-dropped assignment (or the most-recent overall).
orderBy: { assignedAt: 'desc' },
select: {
id: true,
method: true,
@@ -1157,7 +1263,10 @@ export const mentorRouter = router({
const rows = projects.map((p) => {
// Treat a dropped mentor assignment as if no mentor is assigned.
const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null
// TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most
// recent non-dropped assignment for the activity row.
const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null
const ma = firstActive
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
const lastFileAt = ma?.files[0]?.createdAt ?? null
const lastActivityAt = [lastMessageAt, lastFileAt]
@@ -1235,6 +1344,50 @@ export const mentorRouter = router({
return assignments
}),
/**
* List all active mentors assigned to a project (PR8 multi-mentor).
*
* Returns one row per active MentorAssignment (droppedAt = null) with the
* mentor's id + name. Used by the mentor workspace page to display the
* co-mentor team so each mentor knows who else they're working with.
*
* Authorization: caller must be an active mentor on the project (or an
* admin via mentorProcedure). Non-assigned mentors get FORBIDDEN.
*/
getProjectMentors: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
droppedAt: null,
},
select: { id: true },
})
if (!ownAssignment) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
}
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { projectId: input.projectId, droppedAt: null },
select: {
id: true,
mentor: { select: { id: true, name: true } },
},
orderBy: { assignedAt: 'asc' },
})
return assignments
}),
/**
* Get detailed project info for a mentor's assigned project
*/
@@ -1279,7 +1432,7 @@ export const mentorRouter = router({
files: {
orderBy: { createdAt: 'desc' },
},
mentorAssignment: {
mentorAssignments: {
include: {
mentor: {
select: { id: true, name: true, email: true },
@@ -2080,6 +2233,7 @@ export const mentorRouter = router({
const exp = Math.floor(Date.now() / 1000) + 3600
const uploadToken = signMentorUploadToken({
mentorAssignmentId: assignment.id,
projectId: assignment.projectId,
uploaderUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,
@@ -2136,45 +2290,55 @@ export const mentorRouter = router({
}),
/**
* List files in a workspace. Authorized for the assigned mentor or any
* project team member.
* List files in a project's mentor workspace. Authorized for any mentor
* currently assigned to the project, or any team member of the project.
*
* Project-scoped (PR8): all co-mentors share one file list, and files
* survive even when an originating assignment is later dropped.
*/
workspaceGetFiles: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
return workspaceGetFilesService(input.projectId, ctx.prisma)
}),
/**
* Issue a short-lived presigned GET URL to download a workspace file.
*/
workspaceGetFileDownloadUrl: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.input(z.object({
mentorFileId: z.string(),
disposition: z.enum(['inline', 'attachment']).default('attachment'),
}))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
{ downloadFileName: file.fileName })
input.disposition === 'inline'
? { inline: true, contentType: file.mimeType }
: { downloadFileName: file.fileName })
return { url }
}),
/**
* Delete a workspace file (uploader or assigned mentor only).
* Delete a workspace file. Authorized for the uploader, any mentor
* currently assigned to the file's project, or any team member of the
* file's project. Final auth check lives in the service.
*/
workspaceDeleteFile: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { mentorAssignmentId: true },
select: { projectId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
try {
await workspaceDeleteFileService(
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
@@ -2204,12 +2368,12 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { mentorAssignmentId: true },
select: { projectId: true },
})
if (!file) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
}
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
return workspaceAddFileComment(
{
mentorFileId: input.mentorFileId,
@@ -2414,4 +2578,243 @@ export const mentorRouter = router({
})),
}
}),
// ===========================================================================
// Mentor change requests (PR8)
//
// Applicants (team members) or admins can open a PENDING change request for
// a project — optionally targeting a specific co-mentor assignment. Admins
// are notified by email; mentors are intentionally NOT notified, even after
// resolution (per design decision in the PR8 plan).
// ===========================================================================
/**
* Open a new mentor change request. Allowed for:
* • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
* • a team member of the target project.
*
* Rejects with CONFLICT if the same user already has an open (PENDING) request
* for the same project. The raw `reason` is intentionally NOT included in
* audit logs — only its length — for privacy. Email delivery to admins is
* best-effort and never throws.
*/
requestChange: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
targetAssignmentId: z.string().min(1).optional(),
reason: z.string().min(10).max(2000),
}),
)
.mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
// Authorization: admin OR team member of the project
if (!isAdmin) {
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId: ctx.user.id },
select: { id: true },
})
if (!teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this project',
})
}
}
// Load project (also confirms it exists) and validate optional target
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, title: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
if (input.targetAssignmentId) {
const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
where: { id: input.targetAssignmentId },
select: { id: true, projectId: true },
})
if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target assignment does not belong to this project',
})
}
}
// One open request per (user, project)
const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
where: {
projectId: input.projectId,
requestedByUserId: ctx.user.id,
status: MentorChangeRequestStatus.PENDING,
},
select: { id: true },
})
if (existingOpen) {
throw new TRPCError({
code: 'CONFLICT',
message: 'You already have an open mentor change request for this project.',
})
}
const created = await ctx.prisma.mentorChangeRequest.create({
data: {
projectId: input.projectId,
targetAssignmentId: input.targetAssignmentId ?? null,
requestedByUserId: ctx.user.id,
reason: input.reason,
status: MentorChangeRequestStatus.PENDING,
},
select: { id: true, status: true, createdAt: true },
})
// Notify admins (best-effort, never throw)
try {
const admins = await ctx.prisma.user.findMany({
where: {
OR: [
{ roles: { has: 'SUPER_ADMIN' } },
{ roles: { has: 'PROGRAM_ADMIN' } },
],
status: 'ACTIVE',
},
select: { email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
await sendMentorChangeRequestEmail(
admins.map((a) => a.email),
project.title,
ctx.user.name ?? null,
input.reason,
adminDashboardUrl,
)
} catch (err) {
// Defense-in-depth: the helper already has its own try/catch
console.error('[mentor.requestChange] notify admins failed:', err)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_CHANGE_REQUEST_CREATE',
entityType: 'MentorChangeRequest',
entityId: created.id,
detailsJson: {
projectId: input.projectId,
targetAssignmentId: input.targetAssignmentId ?? null,
reasonLength: input.reason.length,
},
})
return created
}),
/**
* Admin inbox — list MentorChangeRequest rows, optionally filtered by status
* and/or project. PENDING rows are surfaced first; within each status group
* rows are ordered by createdAt desc. No pagination (low-volume admin view).
*/
listChangeRequests: adminProcedure
.input(
z
.object({
status: z.nativeEnum(MentorChangeRequestStatus).optional(),
projectId: z.string().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const where: Prisma.MentorChangeRequestWhereInput = {}
if (input?.status) where.status = input.status
if (input?.projectId) where.projectId = input.projectId
const rows = await ctx.prisma.mentorChangeRequest.findMany({
where,
include: {
project: { select: { id: true, title: true } },
targetAssignment: {
select: {
id: true,
mentor: { select: { id: true, name: true, email: true } },
},
},
requestedBy: { select: { id: true, name: true, email: true } },
resolvedBy: { select: { id: true, name: true, email: true } },
},
// PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
})
// Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
// is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
// appears first regardless of enum string ordering.
const statusRank: Record<MentorChangeRequestStatus, number> = {
[MentorChangeRequestStatus.PENDING]: 0,
[MentorChangeRequestStatus.RESOLVED]: 1,
[MentorChangeRequestStatus.DISMISSED]: 2,
}
return rows.sort((a, b) => {
const sa = statusRank[a.status] - statusRank[b.status]
if (sa !== 0) return sa
return b.createdAt.getTime() - a.createdAt.getTime()
})
}),
/**
* Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
* is rejected. No email or notification is sent to the requester or mentors
* (per PR8 design decision — mentors are never informed of change requests).
*/
resolveChangeRequest: adminProcedure
.input(
z.object({
id: z.string().min(1),
status: z.enum(['RESOLVED', 'DISMISSED']),
resolutionNote: z.string().max(2000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.mentorChangeRequest.findUnique({
where: { id: input.id },
select: { id: true, status: true, projectId: true },
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
}
if (existing.status !== MentorChangeRequestStatus.PENDING) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Request already resolved',
})
}
const updated = await ctx.prisma.mentorChangeRequest.update({
where: { id: existing.id },
data: {
status: input.status as MentorChangeRequestStatus,
resolvedByUserId: ctx.user.id,
resolvedAt: new Date(),
resolutionNote: input.resolutionNote ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
entityType: 'MentorChangeRequest',
entityId: existing.id,
detailsJson: {
status: input.status,
projectId: existing.projectId,
},
})
return updated
}),
})

View File

@@ -188,7 +188,7 @@ export const projectRouter = router({
orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
}
if (userHasRole(ctx.user, 'MENTOR')) {
orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } })
orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } })
}
if (userHasRole(ctx.user, 'APPLICANT')) {
orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
@@ -511,7 +511,7 @@ export const projectRouter = router({
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
mentorAssignments: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
@@ -585,14 +585,18 @@ export const projectRouter = router({
}))
)
const mentorWithAvatar = project.mentorAssignment
// TODO(PR8 Task 8): surface all mentors. For now we keep the legacy
// single-mentor shape and just pick the first non-dropped assignment
// so the admin UI keeps rendering without changes.
const primaryAssignment = project.mentorAssignments[0] ?? null
const mentorWithAvatar = primaryAssignment
? {
...project.mentorAssignment,
...primaryAssignment,
mentor: {
...project.mentorAssignment.mentor,
...primaryAssignment.mentor,
avatarUrl: await getUserAvatarUrl(
project.mentorAssignment.mentor.profileImageKey,
project.mentorAssignment.mentor.profileImageProvider
primaryAssignment.mentor.profileImageKey,
primaryAssignment.mentor.profileImageProvider
),
},
}
@@ -1311,7 +1315,7 @@ export const projectRouter = router({
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
mentorAssignments: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
@@ -1448,18 +1452,21 @@ export const projectRouter = router({
}
})
),
projectRaw.mentorAssignment
? (async () => ({
...projectRaw.mentorAssignment!,
mentor: {
...projectRaw.mentorAssignment!.mentor,
avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider
),
},
}))()
: Promise.resolve(null),
// TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first.
(async () => {
const primaryMa = projectRaw.mentorAssignments[0] ?? null
if (!primaryMa) return null
return {
...primaryMa,
mentor: {
...primaryMa.mentor,
avatarUrl: await getUserAvatarUrl(
primaryMa.mentor.profileImageKey,
primaryMa.mentor.profileImageProvider
),
},
}
})(),
])
return {

View File

@@ -236,7 +236,7 @@ export const roundRouter = router({
where: {
roundId: input.roundId,
project: {
mentorAssignment: null,
mentorAssignments: { none: {} },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
},
},

View File

@@ -152,6 +152,11 @@ export async function markRead(
/**
* Record a file upload in a workspace.
*
* `workspaceId` is the originating MentorAssignment id (kept on the row as an
* audit-trail FK). We derive the project id from that assignment so the file
* is bound to the project — meaning any co-mentor on the project can see/use
* it, and the row survives if this particular assignment is later dropped.
*/
export async function uploadFile(
params: {
@@ -180,6 +185,7 @@ export async function uploadFile(
return prisma.mentorFile.create({
data: {
projectId: assignment.projectId,
mentorAssignmentId: params.workspaceId,
uploadedByUserId: params.uploadedByUserId,
fileName: params.fileName,
@@ -238,9 +244,6 @@ export async function promoteFile(
try {
const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId },
include: {
mentorAssignment: { select: { projectId: true } },
},
})
if (!file) {
@@ -265,7 +268,7 @@ export async function promoteFile(
// Create promotion event
await tx.submissionPromotionEvent.create({
data: {
projectId: file.mentorAssignment.projectId,
projectId: file.projectId,
roundId: params.roundId,
slotKey: params.slotKey,
sourceType: 'MENTOR_FILE',
@@ -281,7 +284,7 @@ export async function promoteFile(
entityId: params.mentorFileId,
actorId: params.promotedById,
detailsJson: {
projectId: file.mentorAssignment.projectId,
projectId: file.projectId,
roundId: params.roundId,
slotKey: params.slotKey,
fileName: file.fileName,
@@ -297,7 +300,7 @@ export async function promoteFile(
entityType: 'MentorFile',
entityId: params.mentorFileId,
detailsJson: {
projectId: file.mentorAssignment.projectId,
projectId: file.projectId,
slotKey: params.slotKey,
},
})
@@ -314,14 +317,17 @@ export async function promoteFile(
}
/**
* List files for a workspace, newest first, with comment counts and uploader.
* List files for a project, newest first, with comment counts and uploader.
* Project-scoped: every mentor assigned to the project (and every team member)
* sees the same file list, even if some files were uploaded under a now-dropped
* assignment.
*/
export async function getFiles(
workspaceId: string,
projectId: string,
prisma: PrismaClient,
) {
return prisma.mentorFile.findMany({
where: { mentorAssignmentId: workspaceId },
where: { projectId },
orderBy: { createdAt: 'desc' },
include: {
uploadedBy: { select: { id: true, name: true, email: true } },
@@ -331,8 +337,10 @@ export async function getFiles(
}
/**
* Delete a file. Caller must be either the uploader OR the assigned mentor.
* Removes the MinIO object and the DB row + cascade-deletes comments.
* Delete a file. Caller must be either the uploader, OR any mentor currently
* assigned (not dropped) to the file's project, OR a team member of the
* file's project. Removes the MinIO object and the DB row + cascade-deletes
* comments.
*/
export async function deleteFile(
params: { mentorFileId: string; userId: string },
@@ -341,13 +349,30 @@ export async function deleteFile(
): Promise<void> {
const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId },
include: { mentorAssignment: { select: { mentorId: true } } },
})
if (!file) throw new Error('File not found')
const isUploader = file.uploadedByUserId === params.userId
const isMentor = file.mentorAssignment.mentorId === params.userId
if (!isUploader && !isMentor) {
throw new Error('Only the uploader or the assigned mentor can delete this file')
let isAuthorized = isUploader
if (!isAuthorized) {
const mentorAssignment = await prisma.mentorAssignment.findFirst({
where: { projectId: file.projectId, mentorId: params.userId, droppedAt: null },
select: { id: true },
})
if (mentorAssignment) {
isAuthorized = true
}
}
if (!isAuthorized) {
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId: file.projectId, userId: params.userId },
select: { id: true },
})
if (teamMembership) {
isAuthorized = true
}
}
if (!isAuthorized) {
throw new Error('Only the uploader, an assigned mentor, or a team member can delete this file')
}
try {
await removeStorageObject(file.bucket, file.objectKey)

View File

@@ -670,7 +670,7 @@ export async function getMentorSuggestionsForProject(
projectTags: {
include: { tag: true },
},
mentorAssignment: true,
mentorAssignments: true,
},
})
@@ -714,7 +714,7 @@ export async function getMentorSuggestionsForProject(
for (const mentor of mentors) {
// Skip if already assigned to this project
if (project.mentorAssignment?.mentorId === mentor.id) {
if (project.mentorAssignments.some((ma) => ma.mentorId === mentor.id)) {
continue
}

View File

@@ -1,7 +1,6 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',

View File

@@ -0,0 +1,138 @@
/**
* PR8 — MentorFile schema invariant check
*
* The actual data migration (backfill of MentorFile.projectId from the
* originating MentorAssignment.projectId) was verified against the May 7
* production database dump in Task 2 of PR8. This file is a complementary
* schema-invariant check that runs against the current dev DB:
*
* 1. MentorFile.projectId is now a required column (Prisma validation fails
* when omitted).
* 2. Files are scoped to the project, not to a single MentorAssignment —
* deleting the originating assignment leaves the file in place with
* mentorAssignmentId set to NULL (FK SetNull) and projectId unchanged.
* This is what enables team-wide file visibility across co-mentors.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
describe('MentorFile scope invariants (PR8 schema)', () => {
const programIds: string[] = []
const userIds: string[] = []
const mentorFileIds: string[] = []
afterAll(async () => {
if (mentorFileIds.length > 0) {
await prisma.mentorFile.deleteMany({ where: { id: { in: mentorFileIds } } })
}
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.mentorFile.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('MentorFile.projectId matches MentorAssignment.projectId when created via the workspace path', async () => {
const program = await createTestProgram({ name: `mfscope-match-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Scope Match' })
const mentor = await createTestUser('MENTOR')
userIds.push(mentor.id)
const assignment = await prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
workspaceEnabled: true,
},
})
const file = await prisma.mentorFile.create({
data: {
projectId: project.id,
mentorAssignmentId: assignment.id,
uploadedByUserId: mentor.id,
fileName: 'invariant.pdf',
mimeType: 'application/pdf',
size: 1024,
bucket: 'mopc-files',
objectKey: `Scope_Match/mentorship/${Date.now()}-invariant.pdf`,
},
})
mentorFileIds.push(file.id)
expect(file.projectId).toBe(assignment.projectId)
})
it('creating a MentorFile without a projectId is rejected by Prisma', async () => {
const program = await createTestProgram({ name: `mfscope-noproj-${uid()}` })
programIds.push(program.id)
const mentor = await createTestUser('MENTOR')
userIds.push(mentor.id)
// `projectId` is required in the schema — Prisma should reject this.
// Cast away the type for the deliberate omission.
await expect(
prisma.mentorFile.create({
data: {
uploadedByUserId: mentor.id,
fileName: 'no-project.pdf',
mimeType: 'application/pdf',
size: 10,
bucket: 'mopc-files',
objectKey: 'orphan/no-project.pdf',
} as unknown as Parameters<typeof prisma.mentorFile.create>[0]['data'],
}),
).rejects.toThrow()
})
it('dropping the originating MentorAssignment leaves the MentorFile in place (SetNull)', async () => {
const program = await createTestProgram({ name: `mfscope-setnull-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'SetNull Project' })
const mentor = await createTestUser('MENTOR')
userIds.push(mentor.id)
const assignment = await prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
workspaceEnabled: true,
},
})
const file = await prisma.mentorFile.create({
data: {
projectId: project.id,
mentorAssignmentId: assignment.id,
uploadedByUserId: mentor.id,
fileName: 'survives-drop.pdf',
mimeType: 'application/pdf',
size: 2048,
bucket: 'mopc-files',
objectKey: `SetNull_Project/mentorship/${Date.now()}-survives.pdf`,
},
})
mentorFileIds.push(file.id)
await prisma.mentorAssignment.delete({ where: { id: assignment.id } })
const after = await prisma.mentorFile.findUnique({ where: { id: file.id } })
expect(after).not.toBeNull()
expect(after?.mentorAssignmentId).toBeNull()
expect(after?.projectId).toBe(project.id)
})
})

View File

@@ -213,12 +213,12 @@ describe('mentor.autoAssignBulkForRound', () => {
expect(result.assigned).toBe(1)
const requestedAssigned = await prisma.mentorAssignment.findUnique({
const requestedAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projWithRequest.id },
})
expect(requestedAssigned).not.toBeNull()
const skippedNotAssigned = await prisma.mentorAssignment.findUnique({
const skippedNotAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projWithoutRequest.id },
})
expect(skippedNotAssigned).toBeNull()
@@ -291,7 +291,7 @@ describe('mentor.autoAssignBulkForRound', () => {
expect(result.assigned).toBe(1)
expect(result.skipped).toBe(1)
const stillExisting = await prisma.mentorAssignment.findUnique({
const stillExisting = await prisma.mentorAssignment.findFirst({
where: { projectId: projAlreadyAssigned.id },
})
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
@@ -377,17 +377,17 @@ describe('mentor.autoAssignBulkForRound', () => {
expect(result.assigned).toBe(1)
const confirmedAssigned = await prisma.mentorAssignment.findUnique({
const confirmedAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projConfirmed.id },
})
expect(confirmedAssigned).not.toBeNull()
const pendingAssigned = await prisma.mentorAssignment.findUnique({
const pendingAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projPending.id },
})
expect(pendingAssigned).toBeNull()
const noConfAssigned = await prisma.mentorAssignment.findUnique({
const noConfAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projNoConfirmation.id },
})
expect(noConfAssigned).toBeNull()

View File

@@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => {
})
await prisma.mentorFile.create({
data: {
projectId: projReqAssigned.id,
mentorAssignmentId: a1.id,
uploadedByUserId: mentor.id,
fileName: 'plan.pdf',

View File

@@ -6,6 +6,7 @@ import {
const samplePayload: MentorUploadPayload = {
mentorAssignmentId: 'ma-123',
projectId: 'proj-789',
uploaderUserId: 'user-456',
fileName: 'doc.pdf',
mimeType: 'application/pdf',

View File

@@ -8,6 +8,7 @@ import { signMentorUploadToken } from '../../src/lib/mentor-upload-token'
describe('mentor.workspace files end-to-end', () => {
let programId: string
let projectId: string
let mentor: { id: string; email: string; role: 'MENTOR' }
let outsider: { id: string; email: string; role: 'JURY_MEMBER' }
let assignmentId: string
@@ -18,6 +19,7 @@ describe('mentor.workspace files end-to-end', () => {
const program = await createTestProgram({ name: `mentor-files-${uid()}` })
programId = program.id
const project = await createTestProject(programId, { title: 'Test Project' })
projectId = project.id
const m = await createTestUser('MENTOR')
userIds.push(m.id)
@@ -79,6 +81,7 @@ describe('mentor.workspace files end-to-end', () => {
it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => {
const forged = signMentorUploadToken({
mentorAssignmentId: assignmentId,
projectId,
uploaderUserId: 'someone-else',
fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf',
@@ -94,7 +97,7 @@ describe('mentor.workspace files end-to-end', () => {
mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50,
})
await caller.workspaceUploadFile({ uploadToken: a.uploadToken })
const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
const files = await caller.workspaceGetFiles({ projectId })
expect(files.length).toBeGreaterThanOrEqual(2)
expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual(
new Date(files[1].createdAt).getTime(),
@@ -104,7 +107,7 @@ describe('mentor.workspace files end-to-end', () => {
it('refuses workspaceGetFiles to outsiders', async () => {
const caller = createCaller(mentorRouter, outsider)
await expect(
caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
caller.workspaceGetFiles({ projectId })
).rejects.toThrow(/FORBIDDEN|not a member/i)
})

View File

@@ -0,0 +1,509 @@
/**
* PR8 — Multi-mentor stacking + change-request procedures
*
* Covers the API surface added by PR8 Tasks 4 + 6:
* - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId),
* idempotent per-row email notification (via MentorAssignment.notificationSentAt),
* re-assignment after drop creates a new row and re-fires the email.
* - mentor.requestChange: auth (team-member or admin), validation, single open
* request per (user, project), target-assignment cross-project guard.
* - mentor.listChangeRequests: admin-only, PENDING-first ordering.
* - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'
async function createUserWithRoles(
primaryRole: UserRole,
rolesArray: UserRole[],
overrides: { name?: string; expertiseTags?: string[] } = {},
) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: overrides.name ?? `Test ${primaryRole}`,
role: primaryRole,
roles: rolesArray,
status: 'ACTIVE',
expertiseTags: overrides.expertiseTags ?? [],
},
})
}
describe('mentor.assign — stacking + per-team email idempotency', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('stacks two different mentors on the same project (both rows active)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Stacking Project' })
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
userIds.push(m1.id, m2.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
expect(a1.id).not.toBe(a2.id)
expect(a1.mentorId).toBe(m1.id)
expect(a2.mentorId).toBe(m2.id)
const rows = await prisma.mentorAssignment.findMany({
where: { projectId: project.id },
orderBy: { assignedAt: 'asc' },
})
expect(rows).toHaveLength(2)
expect(rows.every((r) => r.droppedAt === null)).toBe(true)
})
it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Dup Project' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
await expect(
caller.assign({ projectId: project.id, mentorId: mentor.id }),
).rejects.toThrow(/already assigned/i)
})
it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-email-${uid()}` })
programIds.push(program.id)
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id })
const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id })
// assign() returns the row before the post-write stamp; re-read for the
// current value.
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
expect(row1?.notificationSentAt).not.toBeNull()
expect(row2?.notificationSentAt).not.toBeNull()
// Each row carries its own timestamp — they're independent.
expect(row1?.id).not.toBe(row2?.id)
})
it('stamps notificationSentAt independently for each co-mentor on the same project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
userIds.push(m1.id, m2.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
expect(row1?.notificationSentAt).not.toBeNull()
expect(row2?.notificationSentAt).not.toBeNull()
})
it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
const stamp1 = (
await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
)?.notificationSentAt
expect(stamp1).not.toBeNull()
// Hard-delete the first row (simulates a "fully dropped → repository clean"
// state — the unique constraint also blocks any re-assign while the row
// exists, so the row must go away).
await prisma.mentorAssignment.delete({ where: { id: a1.id } })
// Re-assign: new row, new notificationSentAt stamp.
const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
const stamp2 = (
await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
)?.notificationSentAt
expect(a2.id).not.toBe(a1.id)
expect(stamp2).not.toBeNull()
})
})
describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } })
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.teamMember.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
/**
* Builds a project with a LEAD team member (applicant), an unrelated
* non-team-member (applicant), and an admin. Returns the IDs.
*/
async function setupProjectWithTeam(label: string) {
const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` })
userIds.push(admin.id)
const program = await createTestProgram({ name: `${label}-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: `Project ${label}` })
const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
name: `Team ${label}`,
})
const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
name: `Outsider ${label}`,
})
userIds.push(teamMember.id, outsider.id)
await prisma.teamMember.create({
data: {
projectId: project.id,
userId: teamMember.id,
role: 'LEAD',
},
})
return { admin, program, project, teamMember, outsider }
}
it('team member can open a change request (PENDING)', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-teamok')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const created = await caller.requestChange({
projectId: project.id,
reason: 'We would like a mentor with deeper marine biology experience.',
})
expect(created.status).toBe('PENDING')
const persisted = await prisma.mentorChangeRequest.findUnique({
where: { id: created.id },
})
expect(persisted?.requestedByUserId).toBe(teamMember.id)
expect(persisted?.projectId).toBe(project.id)
})
it('non-team-member non-admin is rejected with FORBIDDEN', async () => {
const { project, outsider } = await setupProjectWithTeam('rc-outsider')
const caller = createCaller(mentorRouter, {
id: outsider.id,
email: outsider.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({
projectId: project.id,
reason: 'I have no business asking for this.',
}),
).rejects.toThrow(/FORBIDDEN|not a member/i)
})
it('admin (no team membership) can open a change request', async () => {
const { admin, project } = await setupProjectWithTeam('rc-admin')
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const created = await caller.requestChange({
projectId: project.id,
reason: 'Admin-initiated mentor swap due to internal escalation.',
})
expect(created.status).toBe('PENDING')
})
it('reason < 10 chars is rejected (Zod validation)', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-short')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({ projectId: project.id, reason: 'too short' }),
).rejects.toThrow()
})
it('opening a second request while the first is still PENDING throws CONFLICT', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-conflict')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await caller.requestChange({
projectId: project.id,
reason: 'First request — still pending please review.',
})
await expect(
caller.requestChange({
projectId: project.id,
reason: 'Second request while first is open.',
}),
).rejects.toThrow(/already.*open|CONFLICT/i)
})
it('after the first request is resolved, the same user can open a new one', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const first = await teamCaller.requestChange({
projectId: project.id,
reason: 'First request — please address my concerns.',
})
await adminCaller.resolveChangeRequest({
id: first.id,
status: 'RESOLVED',
resolutionNote: 'Mentor swapped.',
})
const second = await teamCaller.requestChange({
projectId: project.id,
reason: 'Second request — new concern after resolution.',
})
expect(second.status).toBe('PENDING')
expect(second.id).not.toBe(first.id)
})
it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => {
const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj')
// Make a second project + mentor assignment NOT on the requester's project.
const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` })
programIds.push(otherProgram.id)
const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const foreignAssignment = await prisma.mentorAssignment.create({
data: {
projectId: otherProject.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: admin.id,
},
})
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({
projectId: projectA.id,
targetAssignmentId: foreignAssignment.id,
reason: 'Trying to point at a foreign assignment row.',
}),
).rejects.toThrow(/does not belong|BAD_REQUEST/i)
})
it('listChangeRequests is FORBIDDEN for applicant', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await teamCaller.requestChange({
projectId: project.id,
reason: 'A real request, but list should still be admin-only.',
})
await expect(teamCaller.listChangeRequests({})).rejects.toThrow()
})
it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Create two requests: open one, resolve it; open a second one (still PENDING).
const resolvedReq = await teamCaller.requestChange({
projectId: project.id,
reason: 'Will be resolved before the second request opens.',
})
await adminCaller.resolveChangeRequest({
id: resolvedReq.id,
status: 'RESOLVED',
})
const pendingReq = await teamCaller.requestChange({
projectId: project.id,
reason: 'Still pending — should be listed first.',
})
const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{
id: string
status: string
}>
const ids = rows.map((r) => r.id)
// PENDING must come before RESOLVED in the listing.
expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id))
expect(rows[0].status).toBe('PENDING')
})
it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await teamCaller.requestChange({
projectId: project.id,
reason: 'Please resolve this request.',
})
const result = await adminCaller.resolveChangeRequest({
id: req.id,
status: 'RESOLVED',
resolutionNote: 'Replacement mentor assigned.',
})
expect(result.status).toBe('RESOLVED')
expect(result.resolvedByUserId).toBe(admin.id)
expect(result.resolvedAt).not.toBeNull()
expect(result.resolutionNote).toBe('Replacement mentor assigned.')
})
it('resolveChangeRequest by non-admin is FORBIDDEN', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await adminCaller.requestChange({
projectId: project.id,
reason: 'Admin opens, applicant should not resolve.',
})
await expect(
teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }),
).rejects.toThrow()
})
it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await teamCaller.requestChange({
projectId: project.id,
reason: 'Will resolve, then try to resolve again.',
})
await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' })
await expect(
adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }),
).rejects.toThrow(/already resolved|BAD_REQUEST/i)
})
})