Compare commits
20 Commits
3bcbf72ad6
...
5b99d6a530
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b99d6a530 | ||
|
|
6969b9c2bc | ||
|
|
3bc9c11a51 | ||
|
|
8d4b62a602 | ||
|
|
f64e68e751 | ||
|
|
48e48f058d | ||
|
|
ec92b03006 | ||
|
|
349671f37c | ||
|
|
4f444a1baa | ||
|
|
d47db17027 | ||
|
|
83e950bb67 | ||
|
|
ba115f71a0 | ||
|
|
d440b5f274 | ||
|
|
ee47c0305f | ||
|
|
3a1eb149b6 | ||
|
|
a5ad11a1b5 | ||
|
|
66110598a0 | ||
|
|
9152ebb399 | ||
|
|
a26e486ab5 | ||
|
|
e89dca24c3 |
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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.0–1.0 confidence
|
||||
analyzedAt DateTime? // When analysis last ran
|
||||
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||
langConfidence Float? // 0.0–1.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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal file
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} · {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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "Process" to auto-assign outcomes based on round type and project activity.
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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…
|
||||
</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" />
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
171
src/lib/email.ts
171
src/lib/email.ts
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 } } },
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -236,7 +236,7 @@ export const roundRouter = router({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: {
|
||||
mentorAssignment: null,
|
||||
mentorAssignments: { none: {} },
|
||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}',
|
||||
|
||||
138
tests/integration/mentor-file-scope.test.ts
Normal file
138
tests/integration/mentor-file-scope.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => {
|
||||
})
|
||||
await prisma.mentorFile.create({
|
||||
data: {
|
||||
projectId: projReqAssigned.id,
|
||||
mentorAssignmentId: a1.id,
|
||||
uploadedByUserId: mentor.id,
|
||||
fileName: 'plan.pdf',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
const samplePayload: MentorUploadPayload = {
|
||||
mentorAssignmentId: 'ma-123',
|
||||
projectId: 'proj-789',
|
||||
uploaderUserId: 'user-456',
|
||||
fileName: 'doc.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
509
tests/unit/multi-mentor-assignment.test.ts
Normal file
509
tests/unit/multi-mentor-assignment.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user