merge: PR8 — multi-mentor per team + change-requests + inline previews
Schema: MentorAssignment becomes M:N (composite unique on (projectId, mentorId)). MentorFile re-scopes to projectId (team-wide); mentorAssignmentId becomes a nullable audit FK with SetNull. New MentorChangeRequest model + status enum. Behavior: - mentor.assign stacks mentors per team; per-team assignment email fires once per row (idempotent via notificationSentAt). - mentor.requestChange / listChangeRequests / resolveChangeRequest provide the change-request inbox; mentors are NOT notified, only admins. - Workspace files re-scoped to project so all co-mentors and team members share one file list and chat. - New inline FilePreview support in the mentor workspace. - mentor.getProjectMentors surfaces co-mentors on the mentor workspace. Migration: hand-written, idempotent guards, two-phase backfill on MentorFile.projectId. Verified against May 7 prod dump with rollback.sql. PRE-DEPLOY: pull a fresh prod DB dump and re-run the dry-run before applying the migration to prod (the May 7 snapshot may not include mentors added since by another admin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 })
|
||||
}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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 dark:border-amber-700' : ''
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
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