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:
Matt
2026-05-22 18:26:37 +02:00
28 changed files with 2958 additions and 706 deletions

View File

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

View File

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

View File

@@ -118,7 +118,6 @@ enum NotificationChannel {
NONE NONE
} }
enum PartnerVisibility { enum PartnerVisibility {
ADMIN_ONLY ADMIN_ONLY
JURY_VISIBLE JURY_VISIBLE
@@ -133,7 +132,6 @@ enum PartnerType {
OTHER OTHER
} }
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
// ============================================================================= // =============================================================================
@@ -171,7 +169,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum CapMode { enum CapMode {
HARD HARD
SOFT SOFT
@@ -428,6 +425,10 @@ model User {
// Grand-finale logistics // Grand-finale logistics
finalistAttendances AttendingMember[] finalistAttendances AttendingMember[]
// Mentor change requests
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
@@index([role]) @@index([role])
@@index([status]) @@index([status])
} }
@@ -629,7 +630,9 @@ model Project {
assignments Assignment[] assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[] teamMembers TeamMember[]
mentorAssignment MentorAssignment? mentorAssignments MentorAssignment[]
mentorFiles MentorFile[]
mentorChangeRequests MentorChangeRequest[]
filteringResults FilteringResult[] filteringResults FilteringResult[]
awardEligibilities AwardEligibility[] awardEligibilities AwardEligibility[]
awardVotes AwardVote[] awardVotes AwardVote[]
@@ -1270,7 +1273,7 @@ model TeamMember {
model MentorAssignment { model MentorAssignment {
id String @id @default(cuid()) 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 mentorId String // User with MENTOR role or expertise
// Assignment tracking // Assignment tracking
@@ -1278,6 +1281,9 @@ model MentorAssignment {
assignedAt DateTime @default(now()) assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned assignedBy String? // Admin who assigned
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
notificationSentAt DateTime?
// AI assignment metadata // AI assignment metadata
aiConfidenceScore Float? aiConfidenceScore Float?
expertiseMatchScore Float? expertiseMatchScore Float?
@@ -1304,11 +1310,47 @@ model MentorAssignment {
milestoneCompletions MentorMilestoneCompletion[] milestoneCompletions MentorMilestoneCompletion[]
messages MentorMessage[] messages MentorMessage[]
files MentorFile[] files MentorFile[]
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
@@unique([projectId, mentorId])
@@index([projectId])
@@index([mentorId]) @@index([mentorId])
@@index([method]) @@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 // FILTERING ROUND SYSTEM
// ============================================================================= // =============================================================================
@@ -2449,7 +2491,8 @@ model AssignmentIntent {
model MentorFile { model MentorFile {
id String @id @default(cuid()) id String @id @default(cuid())
mentorAssignmentId String projectId String // Primary access scope: files belong to the team
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
uploadedByUserId String uploadedByUserId String
fileName String fileName String
@@ -2468,13 +2511,15 @@ model MentorFile {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) 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]) uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id]) promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull) promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
comments MentorFileComment[] comments MentorFileComment[]
promotionEvents SubmissionPromotionEvent[] promotionEvents SubmissionPromotionEvent[]
@@index([projectId])
@@index([mentorAssignmentId]) @@index([mentorAssignmentId])
@@index([uploadedByUserId]) @@index([uploadedByUserId])
} }

View File

@@ -18,6 +18,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
Table, Table,
@@ -27,15 +29,35 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
Bot, Bot,
Check, Check,
Inbox,
Loader2, Loader2,
Search, Search,
Sparkles, Sparkles,
Users, Users,
UserPlus,
} from 'lucide-react' } from 'lucide-react'
import { getInitials, formatEnumLabel } from '@/lib/utils' import { getInitials, formatEnumLabel } from '@/lib/utils'
@@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null) const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
const [unassignTarget, setUnassignTarget] = useState<{
assignmentId: string
mentorName: string
} | null>(null)
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId }) const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
const { data: candidatesData, isLoading: candidatesLoading } = // Already-assigned mentors (full list). Project.get spreads the underlying
trpc.mentor.getCandidates.useQuery( // `mentorAssignments` relation so we can read it directly.
{ projectId }, const assignedMentorAssignments = useMemo(() => {
{ enabled: !!project && !project.mentorAssignment }, if (!project) return []
// The Prisma relation is included via `...project` spread; type comes
// through the tRPC client.
type Assignment = NonNullable<typeof project>['mentorAssignments'][number]
return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter(
(a) => !a.droppedAt,
) )
}, [project])
const assignedMentorIds = useMemo(
() => new Set(assignedMentorAssignments.map((a) => a.mentorId)),
[assignedMentorAssignments],
)
const { data: candidatesData, isLoading: candidatesLoading } =
trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project })
const { const {
data: suggestionsData, data: suggestionsData,
@@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
refetch: refetchSuggestions, refetch: refetchSuggestions,
} = trpc.mentor.getSuggestions.useQuery( } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 }, { projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }, { enabled: !!project },
) )
const assignMutation = trpc.mentor.assign.useMutation({ const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Mentor assigned') toast.success('Mentor added')
utils.project.get.invalidate({ id: projectId }) utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId })
@@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
utils.project.get.invalidate({ id: projectId }) utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId })
setUnassignTarget(null)
},
onError: (err) => {
toast.error(err.message)
setUnassignTarget(null)
}, },
onError: (err) => toast.error(err.message),
}) })
const filteredCandidates = useMemo(() => { const filteredCandidates = useMemo(() => {
if (!candidatesData) return [] if (!candidatesData) return []
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
if (!q) return candidatesData.candidates if (!q) return base
return candidatesData.candidates.filter((c) => { return base.filter((c) => {
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? ''] const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
return hay.includes(q) return hay.includes(q)
}) })
}, [candidatesData, search]) }, [candidatesData, search, assignedMentorIds])
const filteredSuggestions = useMemo(() => {
if (!suggestionsData) return []
return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId))
}, [suggestionsData, assignedMentorIds])
if (projectLoading) return <MentorAssignmentSkeleton /> if (projectLoading) return <MentorAssignmentSkeleton />
if (!project) { if (!project) {
@@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
) )
} }
const hasMentor = !!project.mentorAssignment
const teamSize = project.teamMembers?.length ?? 0 const teamSize = project.teamMembers?.length ?? 0
const aiSource = suggestionsData?.source ?? 'ai' const aiSource = suggestionsData?.source ?? 'ai'
@@ -206,80 +254,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Pending Change Requests ─── */}
<PendingChangeRequestsPanel projectId={projectId} />
{/* ─── Currently Assigned ─── */} {/* ─── Currently Assigned ─── */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Currently Assigned</CardTitle> <CardTitle className="text-lg">Currently Assigned</CardTitle>
<CardDescription>
{assignedMentorAssignments.length === 0
? 'No mentors assigned yet'
: `${assignedMentorAssignments.length} mentor${
assignedMentorAssignments.length === 1 ? '' : 's'
} on this team`}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{hasMentor ? ( {assignedMentorAssignments.length === 0 ? (
<div className="flex items-center justify-between"> <div className="rounded-md border border-dashed py-8 text-center">
<div className="flex items-center gap-4"> <Users className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
No mentors assigned yet add one below.
</p>
</div>
) : (
<ul className="divide-y">
{assignedMentorAssignments.map((a) => {
const m = a.mentor
const tags = m.expertiseTags ?? []
return (
<li
key={a.id}
className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0"
>
<div className="flex flex-1 items-start gap-4">
<Avatar className="h-12 w-12"> <Avatar className="h-12 w-12">
<AvatarFallback> <AvatarFallback>
{getInitials( {getInitials(m.name || m.email)}
project.mentorAssignment!.mentor.name ||
project.mentorAssignment!.mentor.email,
)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div className="min-w-0 flex-1">
<Link <Link
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`} href={`/admin/mentors/${m.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{project.mentorAssignment!.mentor.name || 'Unnamed'} {m.name || 'Unnamed'}
</Link> </Link>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">{m.email}</p>
{project.mentorAssignment!.mentor.email} {tags.length > 0 && (
</p>
{project.mentorAssignment!.mentor.expertiseTags &&
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{project.mentorAssignment!.mentor.expertiseTags {tags.slice(0, 5).map((tag: string) => (
.slice(0, 5)
.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs"> <Badge key={tag} variant="secondary" className="text-xs">
{tag} {tag}
</Badge> </Badge>
))} ))}
{tags.length > 5 && (
<Badge variant="outline" className="text-xs">
+{tags.length - 5}
</Badge>
)}
</div> </div>
)} )}
<p className="text-muted-foreground mt-2 text-xs">
Assigned{' '}
{new Date(a.assignedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.mentorAssignment!.method.replace(/_/g, ' ')} {a.method.replace(/_/g, ' ')}
</Badge> </Badge>
<Button <Button
variant="destructive" variant="outline"
size="sm" size="sm"
onClick={() => unassignMutation.mutate({ projectId })} onClick={() =>
setUnassignTarget({
assignmentId: a.id,
mentorName: m.name || m.email,
})
}
disabled={unassignMutation.isPending} disabled={unassignMutation.isPending}
> >
{unassignMutation.isPending ? ( Unassign
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Unassign'
)}
</Button> </Button>
</div> </div>
</div> </li>
) : ( )
<p className="text-muted-foreground text-sm"> })}
No mentor assigned yet pick one below. </ul>
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Pick a Mentor ─── */} {/* ─── Add a Mentor ─── */}
{!hasMentor && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Pick a Mentor</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Add a Mentor
</CardTitle>
<CardDescription> <CardDescription>
Browse all eligible mentors or use AI to surface the best fits. Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -311,7 +391,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</div> </div>
) : filteredCandidates.length === 0 ? ( ) : filteredCandidates.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No matching mentors. Try a different search. {assignedMentorIds.size > 0 && search.trim() === ''
? 'All eligible mentors are already assigned.'
: 'No matching mentors. Try a different search.'}
</div> </div>
) : ( ) : (
<div className="overflow-hidden rounded-md border"> <div className="overflow-hidden rounded-md border">
@@ -376,7 +458,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<Check className="mr-1 h-3.5 w-3.5" /> Assign <Check className="mr-1 h-3.5 w-3.5" /> Add
</> </>
)} )}
</Button> </Button>
@@ -422,13 +504,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Skeleton key={i} className="h-24 w-full" /> <Skeleton key={i} className="h-24 w-full" />
))} ))}
</div> </div>
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? ( ) : filteredSuggestions.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm"> <p className="text-muted-foreground py-8 text-center text-sm">
No suggestions available. {assignedMentorIds.size > 0
? 'All top suggestions are already assigned.'
: 'No suggestions available.'}
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{suggestionsData.suggestions.map((s, i) => ( {filteredSuggestions.map((s, i) => (
<div <div
key={s.mentorId} key={s.mentorId}
className="flex items-start justify-between rounded-md border p-4" className="flex items-start justify-between rounded-md border p-4"
@@ -503,7 +587,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<Check className="mr-1 h-3.5 w-3.5" /> Assign <Check className="mr-1 h-3.5 w-3.5" /> Add
</> </>
)} )}
</Button> </Button>
@@ -515,8 +599,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Unassign confirm ─── */}
<AlertDialog
open={!!unassignTarget}
onOpenChange={(open) => {
if (!open) setUnassignTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unassign mentor?</AlertDialogTitle>
<AlertDialogDescription>
{unassignTarget
? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.`
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={unassignMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
if (!unassignTarget) return
unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId })
}}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Unassign'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Pending Change Requests panel
// ─────────────────────────────────────────────────────────────────────────────
function PendingChangeRequestsPanel({ projectId }: { projectId: string }) {
const utils = trpc.useUtils()
const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({
projectId,
status: 'PENDING',
})
const [resolveTarget, setResolveTarget] = useState<{
id: string
status: 'RESOLVED' | 'DISMISSED'
requesterName: string
} | null>(null)
const [resolutionNote, setResolutionNote] = useState('')
const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({
onSuccess: (_, variables) => {
toast.success(
`Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`,
)
utils.mentor.listChangeRequests.invalidate()
setResolveTarget(null)
setResolutionNote('')
},
onError: (err) => toast.error(err.message),
})
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Inbox className="h-5 w-5" />
Pending change requests
</CardTitle>
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (!requests || requests.length === 0) {
return null
}
return (
<>
<Card className="border-amber-300 dark:border-amber-700">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Inbox className="h-5 w-5 text-amber-600" />
Pending change requests
<Badge variant="secondary" className="ml-1">
{requests.length}
</Badge>
</CardTitle>
<CardDescription>
Team members or mentors have asked admin to change a mentor on this team.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{requests.map((r) => (
<ChangeRequestRow
key={r.id}
request={r}
onResolve={(status) =>
setResolveTarget({
id: r.id,
status,
requesterName:
r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown',
})
}
/>
))}
</ul>
</CardContent>
</Card>
<Dialog
open={!!resolveTarget}
onOpenChange={(open) => {
if (!open) {
setResolveTarget(null)
setResolutionNote('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{resolveTarget?.status === 'RESOLVED'
? 'Mark request resolved'
: 'Dismiss request'}
</DialogTitle>
<DialogDescription>
{resolveTarget?.status === 'RESOLVED'
? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.`
: `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="resolution-note">Resolution note (optional)</Label>
<Textarea
id="resolution-note"
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="e.g. Replaced Jane with John based on expertise mismatch."
rows={4}
maxLength={2000}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setResolveTarget(null)
setResolutionNote('')
}}
disabled={resolveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => {
if (!resolveTarget) return
resolveMutation.mutate({
id: resolveTarget.id,
status: resolveTarget.status,
resolutionNote: resolutionNote.trim() || undefined,
})
}}
disabled={resolveMutation.isPending}
>
{resolveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : resolveTarget?.status === 'RESOLVED' ? (
'Mark Resolved'
) : (
'Dismiss'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
type ChangeRequestRowProps = {
request: {
id: string
reason: string
createdAt: Date
requestedBy: { id: string; name: string | null; email: string } | null
targetAssignment: {
id: string
mentor: { id: string; name: string | null; email: string }
} | null
}
onResolve: (status: 'RESOLVED' | 'DISMISSED') => void
}
function ChangeRequestRow({ request, onResolve }: ChangeRequestRowProps) {
const [expanded, setExpanded] = useState(false)
const reasonIsLong = request.reason.length > 240
return (
<li className="rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="font-medium">
{request.requestedBy?.name ?? request.requestedBy?.email ?? 'Unknown'}
</span>
{request.requestedBy?.email && request.requestedBy.name && (
<span className="text-muted-foreground text-xs">
{request.requestedBy.email}
</span>
)}
<span className="text-muted-foreground text-xs">
·{' '}
{new Date(request.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
{request.targetAssignment && (
<div className="text-muted-foreground text-xs">
About:{' '}
<span className="font-medium">
{request.targetAssignment.mentor.name ||
request.targetAssignment.mentor.email}
</span>
</div>
)}
<p
className={
expanded || !reasonIsLong
? 'text-sm whitespace-pre-wrap'
: 'text-sm whitespace-pre-wrap line-clamp-4'
}
>
{request.reason}
</p>
{reasonIsLong && (
<button
type="button"
className="text-primary text-xs hover:underline"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Show more'}
</button>
)} )}
</div> </div>
<div className="flex shrink-0 flex-col gap-2">
<Button size="sm" onClick={() => onResolve('RESOLVED')}>
Mark Resolved
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve('DISMISSED')}
>
Dismiss
</Button>
</div>
</div>
</li>
) )
} }

View File

@@ -1,6 +1,8 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -9,13 +11,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat' import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { RequestChangeDialog } from './request-change-dialog'
import { import {
MessageSquare, MessageSquare,
UserCircle, UserCircle,
FileText, FileText,
UserCog,
} from 'lucide-react' } from 'lucide-react'
export default function ApplicantMentorPage() { export default function ApplicantMentorPage() {
@@ -41,6 +47,8 @@ export default function ApplicantMentorPage() {
}, },
}) })
const [isChangeOpen, setIsChangeOpen] = useState(false)
if (dashLoading) { if (dashLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -72,7 +80,20 @@ export default function ApplicantMentorPage() {
) )
} }
const mentor = dashboardData?.project?.mentorAssignment?.mentor 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -83,23 +104,72 @@ export default function ApplicantMentorPage() {
Mentor Communication Mentor Communication
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Chat with your assigned mentor {assignments.length > 1
? 'Chat with your assigned mentor team'
: 'Chat with your assigned mentor'}
</p> </p>
</div> </div>
{/* Mentor info */} {/* Mentor list */}
{mentor ? ( {hasMentors ? (
<Card className="bg-muted/50"> <section className="space-y-3">
<CardContent className="p-4"> <h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
<div className="flex items-center gap-3"> <div className="grid gap-3 md:grid-cols-2">
<UserCircle className="h-10 w-10 text-muted-foreground" /> {assignments.map((assignment) => {
<div> const mentor = assignment.mentor
<p className="font-medium">{mentor.name || 'Mentor'}</p> if (!mentor) return null
<p className="text-sm text-muted-foreground">{mentor.email}</p> 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>
</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> </CardContent>
</Card> </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"> <Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8"> <CardContent className="flex flex-col items-center justify-center py-8">
@@ -113,12 +183,14 @@ export default function ApplicantMentorPage() {
)} )}
{/* Chat */} {/* Chat */}
{mentor && ( {primaryMentor && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Messages</CardTitle> <CardTitle>Messages</CardTitle>
<CardDescription> <CardDescription>
Your conversation history with {mentor.name || 'your mentor'} {assignments.length > 1
? 'Your conversation history with your mentor team'
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -136,12 +208,23 @@ export default function ApplicantMentorPage() {
)} )}
{/* Files */} {/* Files */}
{dashboardData?.project?.mentorAssignment?.id && ( {primaryAssignment?.id && projectId && (
<WorkspaceFilesPanel <WorkspaceFilesPanel
mentorAssignmentId={dashboardData.project.mentorAssignment.id} projectId={projectId}
mentorAssignmentId={primaryAssignment.id}
asApplicant asApplicant
/> />
)} )}
{/* Request change dialog */}
{projectId && (
<RequestChangeDialog
projectId={projectId}
mentors={dialogMentors}
open={isChangeOpen}
onOpenChange={setIsChangeOpen}
/>
)}
</div> </div>
) )
} }

View File

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

View File

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

View File

@@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
}, },
}) })
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
// to keep tracking + chat working unchanged.
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
// Track view when project loads // Track view when project loads
const trackView = trpc.mentor.trackView.useMutation() const trackView = trpc.mentor.trackView.useMutation()
useEffect(() => { useEffect(() => {
if (project?.mentorAssignment?.id) { if (primaryAssignment?.id) {
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id }) trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.mentorAssignment?.id]) }, [primaryAssignment?.id])
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
@@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD') const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((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 mentorAssignmentId = mentorAssignment?.id
const programId = project.program?.id const programId = project.program?.id
const viewerIsAssignedMentor = const viewerIsAssignedMentor =
@@ -477,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardContent> <CardContent>
<MentorChat <MentorChat
messages={mentorMessages || []} messages={mentorMessages || []}
currentUserId={project.mentorAssignment?.mentor?.id || ''} currentUserId={primaryAssignment?.mentor?.id || ''}
onSendMessage={async (message) => { onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message }) await sendMessage.mutateAsync({ projectId, message })
}} }}

View File

@@ -1,21 +1,29 @@
'use client' 'use client'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' 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 { WorkspaceChat } from '@/components/mentor/workspace-chat'
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel' import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-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' import { toast } from 'sonner'
export default function MentorWorkspaceDetailPage() { export default function MentorWorkspaceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const { data: session } = useSession()
const projectId = params.projectId as string const projectId = params.projectId as string
// Get mentor assignment for this project // Get mentor assignment for this project
@@ -27,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
{ enabled: !!projectId } { 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) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -70,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
{project.teamName && ( {project.teamName && (
<p className="text-muted-foreground mt-1">{project.teamName}</p> <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>
</div> </div>
@@ -104,7 +159,10 @@ export default function MentorWorkspaceDetailPage() {
<TabsContent value="files" className="mt-6"> <TabsContent value="files" className="mt-6">
{assignment ? ( {assignment ? (
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} /> <WorkspaceFilesPanel
projectId={projectId}
mentorAssignmentId={assignment.id}
/>
) : ( ) : (
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
@@ -117,7 +175,7 @@ export default function MentorWorkspaceDetailPage() {
<TabsContent value="promotion" className="mt-6"> <TabsContent value="promotion" className="mt-6">
{assignment ? ( {assignment ? (
<FilePromotionPanel mentorAssignmentId={assignment.id} /> <FilePromotionPanel projectId={projectId} />
) : ( ) : (
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">

View File

@@ -9,6 +9,7 @@ import {
ArrowRight, ArrowRight,
Clock, Clock,
FileText, FileText,
Inbox,
MessageCircle, MessageCircle,
Target, Target,
UserCheck, UserCheck,
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({}) const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
{ status: 'PENDING' },
{ refetchInterval: 30_000 },
)
if (statsLoading || poolLoading) { if (statsLoading || poolLoading) {
return ( return (
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
} }
if (!stats || !pool) return null 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 const requestedPct = stats.totalProjects
? Math.round((stats.requestedCount / stats.totalProjects) * 100) ? Math.round((stats.requestedCount / stats.totalProjects) * 100)
: 0 : 0
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
</CardContent> </CardContent>
</Card> </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"> <Card className="md:col-span-2 xl:col-span-4">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">Workspace activity</CardTitle> <CardTitle className="text-sm">Workspace activity</CardTitle>

View File

@@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
interface FilePromotionPanelProps { interface FilePromotionPanelProps {
mentorAssignmentId: string projectId: string
} }
function formatFileSize(bytes: number): 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] 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 [selectedSlot, setSelectedSlot] = useState<string>('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: workspaceFiles = [], isLoading: filesLoading } = const { data: workspaceFiles = [], isLoading: filesLoading } =
trpc.mentor.workspaceGetFiles.useQuery( trpc.mentor.workspaceGetFiles.useQuery(
{ mentorAssignmentId }, { projectId },
{ enabled: !!mentorAssignmentId }, { enabled: !!projectId },
) )
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({ const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({

View File

@@ -12,10 +12,18 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } 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 { formatDistanceToNow } from 'date-fns'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
interface Props { 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 mentorAssignmentId: string
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */ /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
asApplicant?: boolean asApplicant?: boolean
@@ -29,21 +37,21 @@ function formatSize(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] 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 utils = trpc.useUtils()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery( const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
{ mentorAssignmentId }, { projectId },
{ enabled: !!mentorAssignmentId } { enabled: !!projectId }
) )
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation() const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({ const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
onSuccess: () => { onSuccess: () => {
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) utils.mentor.workspaceGetFiles.invalidate({ projectId })
setDescription('') setDescription('')
toast.success('File uploaded') toast.success('File uploaded')
}, },
@@ -51,7 +59,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation() const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({ const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
onSuccess: () => { onSuccess: () => {
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) utils.mentor.workspaceGetFiles.invalidate({ projectId })
toast.success('File deleted') toast.success('File deleted')
}, },
onError: (e) => toast.error(e.message), 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) => { const handleDownload = async (mentorFileId: string) => {
try { try {
const { url } = await downloadMutation.mutateAsync({ mentorFileId }) const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' })
window.open(url, '_blank') const a = document.createElement('a')
a.href = url
a.download = ''
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Download failed') toast.error(err instanceof Error ? err.message : 'Download failed')
} }
@@ -141,8 +182,12 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
)} )}
<ul className="divide-y"> <ul className="divide-y">
{(files ?? []).map((f) => ( {(files ?? []).map((f) => {
<li key={f.id} className="flex items-center gap-3 py-3"> 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" /> <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate">{f.fileName}</div> <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 className="text-xs text-muted-foreground mt-1">{f.description}</div>
)} )}
</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" /> <Download className="h-4 w-4" />
</Button> </Button>
<AlertDialog> <AlertDialog>
@@ -184,8 +246,22 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> </li>
))} )
})}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -2752,6 +2752,177 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) 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( function getFinalistConfirmationTemplate(
name: string, name: string,
projectTitle: string, projectTitle: string,

View File

@@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto'
export type MentorUploadPayload = { export type MentorUploadPayload = {
mentorAssignmentId: string 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 uploaderUserId: string
fileName: string fileName: string
mimeType: 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)) { if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Invalid mentor upload token: expired') 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 return payload
} }

View File

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

View File

@@ -1176,7 +1176,7 @@ export const applicantRouter = router({
], ],
}, },
include: { 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({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'No mentor assigned to this project', message: 'No mentor assigned to this project',
@@ -1207,9 +1210,9 @@ export const applicantRouter = router({
}, },
}) })
// Notify the mentor // Notify the (primary) mentor
await createNotification({ await createNotification({
userId: project.mentorAssignment.mentorId, userId: primaryMentorAssignment.mentorId,
type: 'MENTOR_MESSAGE', type: 'MENTOR_MESSAGE',
title: 'New Message', title: 'New Message',
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`, message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
@@ -1313,12 +1316,13 @@ export const applicantRouter = router({
submittedBy: { submittedBy: {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true, expertiseTags: true },
}, },
}, },
orderBy: { assignedAt: 'asc' },
}, },
wonAwards: { wonAwards: {
select: { id: true, name: true }, select: { id: true, name: true },
@@ -1489,6 +1493,17 @@ export const applicantRouter = router({
logoUrl = await provider.getDownloadUrl(project.logoKey) 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 { return {
project: { project: {
...project, ...project,
@@ -1502,6 +1517,7 @@ export const applicantRouter = router({
hasPassedIntake: !!passedIntake, hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound, isIntakeOpen: !!activeIntakeRound,
logoUrl, logoUrl,
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
} }
}), }),
@@ -1523,7 +1539,7 @@ export const applicantRouter = router({
select: { select: {
id: true, id: true,
programId: 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 } return { hasMentor: false, hasEvaluationRounds: false }
} }
// Check if mentor is assigned // Check if mentor is assigned (any active assignment counts)
const hasMentor = !!project.mentorAssignment const hasMentor = project.mentorAssignments.length > 0
// Check if feedback is available — first check admin settings, then fall back to per-round config // Check if feedback is available — first check admin settings, then fall back to per-round config
let hasEvaluationRounds = false 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 }, where: { projectId: input.projectId },
orderBy: { assignedAt: 'desc' },
include: { mentor: { select: { id: true, name: true, email: true } } }, include: { mentor: { select: { id: true, name: true, email: true } } },
}) })

View File

@@ -772,7 +772,8 @@ export const finalistRouter = router({
select: { select: {
id: true, id: true,
title: true, title: true,
mentorAssignment: { mentorAssignments: {
where: { droppedAt: null, completionStatus: { not: 'completed' } },
select: { select: {
id: true, id: true,
completionStatus: true, completionStatus: true,
@@ -796,10 +797,12 @@ export const finalistRouter = router({
data: { status: 'SUPERSEDED' }, data: { status: 'SUPERSEDED' },
}) })
// Cascade: drop active mentor assignment (skip if completed or already dropped) // Cascade: drop ALL active mentor assignments (skip dropped/completed
const ma = confirmation.project.mentorAssignment // 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 let cascadedMentorAssignment = false
if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') { for (const ma of activeAssignments) {
await ctx.prisma.mentorAssignment.update({ await ctx.prisma.mentorAssignment.update({
where: { id: ma.id }, where: { id: ma.id },
data: { data: {
@@ -833,6 +836,7 @@ export const finalistRouter = router({
reason: input.reason, reason: input.reason,
projectId: confirmation.projectId, projectId: confirmation.projectId,
cascadedMentorAssignment, cascadedMentorAssignment,
cascadedAssignmentCount: activeAssignments.length,
}, },
}) })
return { ok: true, cascadedMentorAssignment } return { ok: true, cascadedMentorAssignment }

View File

@@ -1,7 +1,16 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc' 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 { import {
getAIMentorSuggestions, getAIMentorSuggestions,
getRoundRobinMentor, getRoundRobinMentor,
@@ -66,6 +75,42 @@ async function assertWorkspaceAccess(
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' }) 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({ export const mentorRouter = router({
/** /**
* Get AI-suggested mentor matches for a project * Get AI-suggested mentor matches for a project
@@ -82,18 +127,15 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({ const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId }, where: { id: input.projectId },
include: { include: {
mentorAssignment: true, mentorAssignments: true,
}, },
}) })
if (project.mentorAssignment) { // With multi-mentor (PR8) the project can have several mentors. The
return { // suggestions endpoint is informational — return whatever AI suggests
currentMentor: project.mentorAssignment, // and let `mentor.assign` enforce per-pair uniqueness. We still surface
suggestions: [], // an existing primary mentor in the payload so UIs can label it.
source: 'ai' as const, const primaryMentor = project.mentorAssignments[0] ?? null
message: 'Project already has a mentor assigned',
}
}
// Detect AI configuration so the UI can label "AI matching unavailable" // Detect AI configuration so the UI can label "AI matching unavailable"
// when we fall back to algorithmic ranking. An AI error mid-call still // when we fall back to algorithmic ranking. An AI error mid-call still
@@ -140,7 +182,9 @@ export const mentorRouter = router({
}) })
return { 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), suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
source, source,
message: null, message: null,
@@ -219,26 +263,24 @@ export const mentorRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .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({ const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId }, 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 // Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({ const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId }, where: { id: input.mentorId },
}) })
// Create assignment // Create assignment. P2002 on the composite (projectId, mentorId) unique
const assignment = await ctx.prisma.mentorAssignment.create({ // constraint means this exact mentor is already on this team — surface a
// friendly error.
let assignment
try {
assignment = await ctx.prisma.mentorAssignment.create({
data: { data: {
projectId: input.projectId, projectId: input.projectId,
mentorId: input.mentorId, mentorId: input.mentorId,
@@ -265,6 +307,18 @@ export const mentorRouter = router({
}, },
}, },
}) })
} 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 // Audit outside transaction so failures don't roll back the assignment
await logAudit({ await logAudit({
@@ -279,6 +333,8 @@ export const mentorRouter = router({
mentorId: input.mentorId, mentorId: input.mentorId,
mentorName: assignment.mentor.name, mentorName: assignment.mentor.name,
method: input.method, method: input.method,
// PR8: per-team assignment (one row per mentor-project pair).
assignmentScope: 'per-team',
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, 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 // Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try { try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({ const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
@@ -351,13 +428,16 @@ export const mentorRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .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({ const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId }, where: { id: input.projectId },
include: { mentorAssignment: true }, include: { mentorAssignments: { select: { id: true } } },
}) })
if (project.mentorAssignment) { if (project.mentorAssignments.length > 0) {
throw new TRPCError({ throw new TRPCError({
code: 'CONFLICT', code: 'CONFLICT',
message: 'Project already has a mentor assigned', message: 'Project already has a mentor assigned',
@@ -485,13 +565,35 @@ 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 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 }) => { .mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUnique({ const assignment = input.assignmentId
where: { projectId: input.projectId }, ? 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: { include: {
mentor: { select: { id: true, name: true } }, mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } }, project: { select: { id: true, title: true } },
@@ -501,13 +603,13 @@ export const mentorRouter = router({
if (!assignment) { if (!assignment) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'No mentor assignment found for this project', message: 'No mentor assignment found',
}) })
} }
// Delete assignment // Delete assignment
await ctx.prisma.mentorAssignment.delete({ await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId }, where: { id: assignment.id },
}) })
// Audit outside transaction so failures don't roll back the unassignment // Audit outside transaction so failures don't roll back the unassignment
@@ -518,7 +620,7 @@ export const mentorRouter = router({
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
entityId: assignment.id, entityId: assignment.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: assignment.project.id,
projectTitle: assignment.project.title, projectTitle: assignment.project.title,
mentorId: assignment.mentor.id, mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name, mentorName: assignment.mentor.name,
@@ -546,7 +648,7 @@ export const mentorRouter = router({
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
programId: input.programId, programId: input.programId,
mentorAssignment: null, mentorAssignments: { none: {} },
wantsMentorship: true, wantsMentorship: true,
}, },
select: { id: true }, select: { id: true },
@@ -716,7 +818,7 @@ export const mentorRouter = router({
where: { where: {
roundId: input.roundId, roundId: input.roundId,
project: { project: {
mentorAssignment: null, mentorAssignments: { none: {} },
// Only assign mentors to projects whose team has confirmed they will // Only assign mentors to projects whose team has confirmed they will
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED // attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
// confirmations and any project without a confirmation row at all. // confirmations and any project without a confirmation row at all.
@@ -834,7 +936,7 @@ export const mentorRouter = router({
where: { where: {
roundId: input.roundId, roundId: input.roundId,
project: { project: {
mentorAssignment: { isNot: null }, mentorAssignments: { some: {} },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
}, },
}, },
@@ -906,13 +1008,13 @@ export const mentorRouter = router({
ctx.prisma.projectRoundState.count({ ctx.prisma.projectRoundState.count({
where: { where: {
roundId: input.roundId, roundId: input.roundId,
project: { wantsMentorship: true, mentorAssignment: { isNot: null } }, project: { wantsMentorship: true, mentorAssignments: { some: {} } },
}, },
}), }),
ctx.prisma.projectRoundState.count({ ctx.prisma.projectRoundState.count({
where: { where: {
roundId: input.roundId, roundId: input.roundId,
project: { mentorAssignment: { isNot: null } }, project: { mentorAssignments: { some: {} } },
}, },
}), }),
ctx.prisma.mentorMessage.count({ ctx.prisma.mentorMessage.count({
@@ -1107,7 +1209,11 @@ export const mentorRouter = router({
status: true, status: true,
oceanIssue: true, oceanIssue: true,
competitionCategory: 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: { select: {
id: true, id: true,
method: true, method: true,
@@ -1157,7 +1263,10 @@ export const mentorRouter = router({
const rows = projects.map((p) => { const rows = projects.map((p) => {
// Treat a dropped mentor assignment as if no mentor is assigned. // 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 lastMessageAt = ma?.messages[0]?.createdAt ?? null
const lastFileAt = ma?.files[0]?.createdAt ?? null const lastFileAt = ma?.files[0]?.createdAt ?? null
const lastActivityAt = [lastMessageAt, lastFileAt] const lastActivityAt = [lastMessageAt, lastFileAt]
@@ -1235,6 +1344,50 @@ export const mentorRouter = router({
return assignments 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 * Get detailed project info for a mentor's assigned project
*/ */
@@ -1279,7 +1432,7 @@ export const mentorRouter = router({
files: { files: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
@@ -2080,6 +2233,7 @@ export const mentorRouter = router({
const exp = Math.floor(Date.now() / 1000) + 3600 const exp = Math.floor(Date.now() / 1000) + 3600
const uploadToken = signMentorUploadToken({ const uploadToken = signMentorUploadToken({
mentorAssignmentId: assignment.id, mentorAssignmentId: assignment.id,
projectId: assignment.projectId,
uploaderUserId: ctx.user.id, uploaderUserId: ctx.user.id,
fileName: input.fileName, fileName: input.fileName,
mimeType: input.mimeType, mimeType: input.mimeType,
@@ -2136,45 +2290,55 @@ export const mentorRouter = router({
}), }),
/** /**
* List files in a workspace. Authorized for the assigned mentor or any * List files in a project's mentor workspace. Authorized for any mentor
* project team member. * 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 workspaceGetFiles: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId) await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma) return workspaceGetFilesService(input.projectId, ctx.prisma)
}), }),
/** /**
* Issue a short-lived presigned GET URL to download a workspace file. * Issue a short-lived presigned GET URL to download a workspace file.
*/ */
workspaceGetFileDownloadUrl: protectedProcedure 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 }) => { .mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({ const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId }, 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' }) 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, 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 } 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 workspaceDeleteFile: protectedProcedure
.input(z.object({ mentorFileId: z.string() })) .input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({ const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId }, where: { id: input.mentorFileId },
select: { mentorAssignmentId: true }, select: { projectId: true },
}) })
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) 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 { try {
await workspaceDeleteFileService( await workspaceDeleteFileService(
{ mentorFileId: input.mentorFileId, userId: ctx.user.id }, { mentorFileId: input.mentorFileId, userId: ctx.user.id },
@@ -2204,12 +2368,12 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({ const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId }, where: { id: input.mentorFileId },
select: { mentorAssignmentId: true }, select: { projectId: true },
}) })
if (!file) { if (!file) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) 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( return workspaceAddFileComment(
{ {
mentorFileId: input.mentorFileId, mentorFileId: input.mentorFileId,
@@ -2414,4 +2578,243 @@ export const mentorRouter = router({
})), })),
} }
}), }),
// ===========================================================================
// Mentor change requests (PR8)
//
// Applicants (team members) or admins can open a PENDING change request for
// a project — optionally targeting a specific co-mentor assignment. Admins
// are notified by email; mentors are intentionally NOT notified, even after
// resolution (per design decision in the PR8 plan).
// ===========================================================================
/**
* Open a new mentor change request. Allowed for:
* • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
* • a team member of the target project.
*
* Rejects with CONFLICT if the same user already has an open (PENDING) request
* for the same project. The raw `reason` is intentionally NOT included in
* audit logs — only its length — for privacy. Email delivery to admins is
* best-effort and never throws.
*/
requestChange: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
targetAssignmentId: z.string().min(1).optional(),
reason: z.string().min(10).max(2000),
}),
)
.mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
// Authorization: admin OR team member of the project
if (!isAdmin) {
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId: ctx.user.id },
select: { id: true },
})
if (!teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this project',
})
}
}
// Load project (also confirms it exists) and validate optional target
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, title: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
if (input.targetAssignmentId) {
const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
where: { id: input.targetAssignmentId },
select: { id: true, projectId: true },
})
if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target assignment does not belong to this project',
})
}
}
// One open request per (user, project)
const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
where: {
projectId: input.projectId,
requestedByUserId: ctx.user.id,
status: MentorChangeRequestStatus.PENDING,
},
select: { id: true },
})
if (existingOpen) {
throw new TRPCError({
code: 'CONFLICT',
message: 'You already have an open mentor change request for this project.',
})
}
const created = await ctx.prisma.mentorChangeRequest.create({
data: {
projectId: input.projectId,
targetAssignmentId: input.targetAssignmentId ?? null,
requestedByUserId: ctx.user.id,
reason: input.reason,
status: MentorChangeRequestStatus.PENDING,
},
select: { id: true, status: true, createdAt: true },
})
// Notify admins (best-effort, never throw)
try {
const admins = await ctx.prisma.user.findMany({
where: {
OR: [
{ roles: { has: 'SUPER_ADMIN' } },
{ roles: { has: 'PROGRAM_ADMIN' } },
],
status: 'ACTIVE',
},
select: { email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
await sendMentorChangeRequestEmail(
admins.map((a) => a.email),
project.title,
ctx.user.name ?? null,
input.reason,
adminDashboardUrl,
)
} catch (err) {
// Defense-in-depth: the helper already has its own try/catch
console.error('[mentor.requestChange] notify admins failed:', err)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_CHANGE_REQUEST_CREATE',
entityType: 'MentorChangeRequest',
entityId: created.id,
detailsJson: {
projectId: input.projectId,
targetAssignmentId: input.targetAssignmentId ?? null,
reasonLength: input.reason.length,
},
})
return created
}),
/**
* Admin inbox — list MentorChangeRequest rows, optionally filtered by status
* and/or project. PENDING rows are surfaced first; within each status group
* rows are ordered by createdAt desc. No pagination (low-volume admin view).
*/
listChangeRequests: adminProcedure
.input(
z
.object({
status: z.nativeEnum(MentorChangeRequestStatus).optional(),
projectId: z.string().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const where: Prisma.MentorChangeRequestWhereInput = {}
if (input?.status) where.status = input.status
if (input?.projectId) where.projectId = input.projectId
const rows = await ctx.prisma.mentorChangeRequest.findMany({
where,
include: {
project: { select: { id: true, title: true } },
targetAssignment: {
select: {
id: true,
mentor: { select: { id: true, name: true, email: true } },
},
},
requestedBy: { select: { id: true, name: true, email: true } },
resolvedBy: { select: { id: true, name: true, email: true } },
},
// PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
})
// Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
// is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
// appears first regardless of enum string ordering.
const statusRank: Record<MentorChangeRequestStatus, number> = {
[MentorChangeRequestStatus.PENDING]: 0,
[MentorChangeRequestStatus.RESOLVED]: 1,
[MentorChangeRequestStatus.DISMISSED]: 2,
}
return rows.sort((a, b) => {
const sa = statusRank[a.status] - statusRank[b.status]
if (sa !== 0) return sa
return b.createdAt.getTime() - a.createdAt.getTime()
})
}),
/**
* Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
* is rejected. No email or notification is sent to the requester or mentors
* (per PR8 design decision — mentors are never informed of change requests).
*/
resolveChangeRequest: adminProcedure
.input(
z.object({
id: z.string().min(1),
status: z.enum(['RESOLVED', 'DISMISSED']),
resolutionNote: z.string().max(2000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.mentorChangeRequest.findUnique({
where: { id: input.id },
select: { id: true, status: true, projectId: true },
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
}
if (existing.status !== MentorChangeRequestStatus.PENDING) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Request already resolved',
})
}
const updated = await ctx.prisma.mentorChangeRequest.update({
where: { id: existing.id },
data: {
status: input.status as MentorChangeRequestStatus,
resolvedByUserId: ctx.user.id,
resolvedAt: new Date(),
resolutionNote: input.resolutionNote ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
entityType: 'MentorChangeRequest',
entityId: existing.id,
detailsJson: {
status: input.status,
projectId: existing.projectId,
},
})
return updated
}),
}) })

View File

@@ -188,7 +188,7 @@ export const projectRouter = router({
orClauses.push({ assignments: { some: { userId: ctx.user.id } } }) orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
} }
if (userHasRole(ctx.user, 'MENTOR')) { 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')) { if (userHasRole(ctx.user, 'APPLICANT')) {
orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } }) orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
@@ -511,7 +511,7 @@ export const projectRouter = router({
}, },
orderBy: { joinedAt: 'asc' }, orderBy: { joinedAt: 'asc' },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, 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: { mentor: {
...project.mentorAssignment.mentor, ...primaryAssignment.mentor,
avatarUrl: await getUserAvatarUrl( avatarUrl: await getUserAvatarUrl(
project.mentorAssignment.mentor.profileImageKey, primaryAssignment.mentor.profileImageKey,
project.mentorAssignment.mentor.profileImageProvider primaryAssignment.mentor.profileImageProvider
), ),
}, },
} }
@@ -1311,7 +1315,7 @@ export const projectRouter = router({
}, },
orderBy: { joinedAt: 'asc' }, orderBy: { joinedAt: 'asc' },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
@@ -1448,18 +1452,21 @@ export const projectRouter = router({
} }
}) })
), ),
projectRaw.mentorAssignment // TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first.
? (async () => ({ (async () => {
...projectRaw.mentorAssignment!, const primaryMa = projectRaw.mentorAssignments[0] ?? null
if (!primaryMa) return null
return {
...primaryMa,
mentor: { mentor: {
...projectRaw.mentorAssignment!.mentor, ...primaryMa.mentor,
avatarUrl: await getUserAvatarUrl( avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey, primaryMa.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider primaryMa.mentor.profileImageProvider
), ),
}, },
}))() }
: Promise.resolve(null), })(),
]) ])
return { return {

View File

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

View File

@@ -152,6 +152,11 @@ export async function markRead(
/** /**
* Record a file upload in a workspace. * 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( export async function uploadFile(
params: { params: {
@@ -180,6 +185,7 @@ export async function uploadFile(
return prisma.mentorFile.create({ return prisma.mentorFile.create({
data: { data: {
projectId: assignment.projectId,
mentorAssignmentId: params.workspaceId, mentorAssignmentId: params.workspaceId,
uploadedByUserId: params.uploadedByUserId, uploadedByUserId: params.uploadedByUserId,
fileName: params.fileName, fileName: params.fileName,
@@ -238,9 +244,6 @@ export async function promoteFile(
try { try {
const file = await prisma.mentorFile.findUnique({ const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId }, where: { id: params.mentorFileId },
include: {
mentorAssignment: { select: { projectId: true } },
},
}) })
if (!file) { if (!file) {
@@ -265,7 +268,7 @@ export async function promoteFile(
// Create promotion event // Create promotion event
await tx.submissionPromotionEvent.create({ await tx.submissionPromotionEvent.create({
data: { data: {
projectId: file.mentorAssignment.projectId, projectId: file.projectId,
roundId: params.roundId, roundId: params.roundId,
slotKey: params.slotKey, slotKey: params.slotKey,
sourceType: 'MENTOR_FILE', sourceType: 'MENTOR_FILE',
@@ -281,7 +284,7 @@ export async function promoteFile(
entityId: params.mentorFileId, entityId: params.mentorFileId,
actorId: params.promotedById, actorId: params.promotedById,
detailsJson: { detailsJson: {
projectId: file.mentorAssignment.projectId, projectId: file.projectId,
roundId: params.roundId, roundId: params.roundId,
slotKey: params.slotKey, slotKey: params.slotKey,
fileName: file.fileName, fileName: file.fileName,
@@ -297,7 +300,7 @@ export async function promoteFile(
entityType: 'MentorFile', entityType: 'MentorFile',
entityId: params.mentorFileId, entityId: params.mentorFileId,
detailsJson: { detailsJson: {
projectId: file.mentorAssignment.projectId, projectId: file.projectId,
slotKey: params.slotKey, 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( export async function getFiles(
workspaceId: string, projectId: string,
prisma: PrismaClient, prisma: PrismaClient,
) { ) {
return prisma.mentorFile.findMany({ return prisma.mentorFile.findMany({
where: { mentorAssignmentId: workspaceId }, where: { projectId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
uploadedBy: { select: { id: true, name: true, email: true } }, 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. * Delete a file. Caller must be either the uploader, OR any mentor currently
* Removes the MinIO object and the DB row + cascade-deletes comments. * 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( export async function deleteFile(
params: { mentorFileId: string; userId: string }, params: { mentorFileId: string; userId: string },
@@ -341,13 +349,30 @@ export async function deleteFile(
): Promise<void> { ): Promise<void> {
const file = await prisma.mentorFile.findUnique({ const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId }, where: { id: params.mentorFileId },
include: { mentorAssignment: { select: { mentorId: true } } },
}) })
if (!file) throw new Error('File not found') if (!file) throw new Error('File not found')
const isUploader = file.uploadedByUserId === params.userId const isUploader = file.uploadedByUserId === params.userId
const isMentor = file.mentorAssignment.mentorId === params.userId let isAuthorized = isUploader
if (!isUploader && !isMentor) { if (!isAuthorized) {
throw new Error('Only the uploader or the assigned mentor can delete this file') 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 { try {
await removeStorageObject(file.bucket, file.objectKey) await removeStorageObject(file.bucket, file.objectKey)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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