merge: PR8 — multi-mentor per team + change-requests + inline previews
Schema: MentorAssignment becomes M:N (composite unique on (projectId, mentorId)). MentorFile re-scopes to projectId (team-wide); mentorAssignmentId becomes a nullable audit FK with SetNull. New MentorChangeRequest model + status enum. Behavior: - mentor.assign stacks mentors per team; per-team assignment email fires once per row (idempotent via notificationSentAt). - mentor.requestChange / listChangeRequests / resolveChangeRequest provide the change-request inbox; mentors are NOT notified, only admins. - Workspace files re-scoped to project so all co-mentors and team members share one file list and chat. - New inline FilePreview support in the mentor workspace. - mentor.getProjectMentors surfaces co-mentors on the mentor workspace. Migration: hand-written, idempotent guards, two-phase backfill on MentorFile.projectId. Verified against May 7 prod dump with rollback.sql. PRE-DEPLOY: pull a fresh prod DB dump and re-run the dry-run before applying the migration to prod (the May 7 snapshot may not include mentors added since by another admin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
-- Hand-written migration for PR8 (multi-mentor per team).
|
||||
--
|
||||
-- All DDL guarded with IF EXISTS / IF NOT EXISTS so the docker-entrypoint
|
||||
-- retry loop is safe to re-run. No regex (the 2026-05-07 prod incident was
|
||||
-- caused by Prisma 6 generating regex-based DDL that Postgres rejected).
|
||||
-- No BEGIN/COMMIT blocks — Prisma wraps the migration in a transaction.
|
||||
|
||||
-- Phase 1: MentorAssignment — drop unique, add composite, add notification field
|
||||
ALTER TABLE "MentorAssignment" DROP CONSTRAINT IF EXISTS "MentorAssignment_projectId_key";
|
||||
DROP INDEX IF EXISTS "MentorAssignment_projectId_key";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MentorAssignment_projectId_mentorId_key"
|
||||
ON "MentorAssignment"("projectId", "mentorId");
|
||||
CREATE INDEX IF NOT EXISTS "MentorAssignment_projectId_idx"
|
||||
ON "MentorAssignment"("projectId");
|
||||
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "notificationSentAt" TIMESTAMP(3);
|
||||
|
||||
-- Phase 2: MentorFile — re-scope to project (two-phase backfill)
|
||||
ALTER TABLE "MentorFile" ADD COLUMN IF NOT EXISTS "projectId" TEXT;
|
||||
UPDATE "MentorFile" mf
|
||||
SET "projectId" = ma."projectId"
|
||||
FROM "MentorAssignment" ma
|
||||
WHERE mf."mentorAssignmentId" = ma."id"
|
||||
AND mf."projectId" IS NULL;
|
||||
ALTER TABLE "MentorFile" ALTER COLUMN "projectId" SET NOT NULL;
|
||||
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
|
||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_projectId_fkey"
|
||||
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS "MentorFile_projectId_idx" ON "MentorFile"("projectId");
|
||||
|
||||
-- Phase 2b: Make MentorFile.mentorAssignmentId nullable + switch its FK to SetNull
|
||||
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" DROP NOT NULL;
|
||||
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
|
||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
|
||||
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Phase 3: MentorChangeRequest table
|
||||
-- Postgres < 14 doesn't support CREATE TYPE ... IF NOT EXISTS, so wrap in a
|
||||
-- DO block that swallows duplicate_object errors (idempotent for re-runs).
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "MentorChangeRequestStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "MentorChangeRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"targetAssignmentId" TEXT,
|
||||
"requestedByUserId" TEXT,
|
||||
"reason" TEXT NOT NULL,
|
||||
"status" "MentorChangeRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"resolvedByUserId" TEXT,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"resolutionNote" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "MentorChangeRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_projectId_fkey";
|
||||
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_projectId_fkey"
|
||||
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_targetAssignmentId_fkey";
|
||||
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_targetAssignmentId_fkey"
|
||||
FOREIGN KEY ("targetAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_requestedByUserId_fkey";
|
||||
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_requestedByUserId_fkey"
|
||||
FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_resolvedByUserId_fkey";
|
||||
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_resolvedByUserId_fkey"
|
||||
FOREIGN KEY ("resolvedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_projectId_idx" ON "MentorChangeRequest"("projectId");
|
||||
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_status_idx" ON "MentorChangeRequest"("status");
|
||||
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_targetAssignmentId_idx" ON "MentorChangeRequest"("targetAssignmentId");
|
||||
@@ -0,0 +1,23 @@
|
||||
-- PR8 rollback SQL (manual, only safe BEFORE any project has >1 mentor)
|
||||
-- Reverses 20260522155652_multi_mentor_per_team
|
||||
|
||||
-- MentorChangeRequest: drop new table + enum
|
||||
DROP TABLE IF EXISTS "MentorChangeRequest";
|
||||
DROP TYPE IF EXISTS "MentorChangeRequestStatus";
|
||||
|
||||
-- MentorFile: drop projectId scope + restore mentorAssignmentId as required Cascade
|
||||
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
|
||||
DROP INDEX IF EXISTS "MentorFile_projectId_idx";
|
||||
ALTER TABLE "MentorFile" DROP COLUMN IF EXISTS "projectId";
|
||||
-- Restoring NOT NULL is safe only if no rows have NULL mentorAssignmentId (true unless multi-mentor assignments were dropped post-migration)
|
||||
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" SET NOT NULL;
|
||||
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
|
||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
|
||||
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- MentorAssignment: restore projectId @unique + drop new fields
|
||||
DROP INDEX IF EXISTS "MentorAssignment_projectId_mentorId_key";
|
||||
DROP INDEX IF EXISTS "MentorAssignment_projectId_idx";
|
||||
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "notificationSentAt";
|
||||
-- Re-adding UNIQUE will FAIL if any project has >1 mentor (intended safety signal)
|
||||
ALTER TABLE "MentorAssignment" ADD CONSTRAINT "MentorAssignment_projectId_key" UNIQUE ("projectId");
|
||||
@@ -118,7 +118,6 @@ enum NotificationChannel {
|
||||
NONE
|
||||
}
|
||||
|
||||
|
||||
enum PartnerVisibility {
|
||||
ADMIN_ONLY
|
||||
JURY_VISIBLE
|
||||
@@ -133,7 +132,6 @@ enum PartnerType {
|
||||
OTHER
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// COMPETITION / ROUND ENGINE ENUMS
|
||||
// =============================================================================
|
||||
@@ -171,7 +169,6 @@ enum ProjectRoundStateValue {
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
|
||||
enum CapMode {
|
||||
HARD
|
||||
SOFT
|
||||
@@ -428,6 +425,10 @@ model User {
|
||||
// Grand-finale logistics
|
||||
finalistAttendances AttendingMember[]
|
||||
|
||||
// Mentor change requests
|
||||
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
|
||||
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
|
||||
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -629,7 +630,9 @@ model Project {
|
||||
assignments Assignment[]
|
||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||
teamMembers TeamMember[]
|
||||
mentorAssignment MentorAssignment?
|
||||
mentorAssignments MentorAssignment[]
|
||||
mentorFiles MentorFile[]
|
||||
mentorChangeRequests MentorChangeRequest[]
|
||||
filteringResults FilteringResult[]
|
||||
awardEligibilities AwardEligibility[]
|
||||
awardVotes AwardVote[]
|
||||
@@ -1270,7 +1273,7 @@ model TeamMember {
|
||||
|
||||
model MentorAssignment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique // One mentor per project
|
||||
projectId String // Team can have multiple mentors; uniqueness enforced via composite below
|
||||
mentorId String // User with MENTOR role or expertise
|
||||
|
||||
// Assignment tracking
|
||||
@@ -1278,6 +1281,9 @@ model MentorAssignment {
|
||||
assignedAt DateTime @default(now())
|
||||
assignedBy String? // Admin who assigned
|
||||
|
||||
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
|
||||
notificationSentAt DateTime?
|
||||
|
||||
// AI assignment metadata
|
||||
aiConfidenceScore Float?
|
||||
expertiseMatchScore Float?
|
||||
@@ -1304,11 +1310,47 @@ model MentorAssignment {
|
||||
milestoneCompletions MentorMilestoneCompletion[]
|
||||
messages MentorMessage[]
|
||||
files MentorFile[]
|
||||
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
|
||||
|
||||
@@unique([projectId, mentorId])
|
||||
@@index([projectId])
|
||||
@@index([mentorId])
|
||||
@@index([method])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MENTOR CHANGE REQUESTS
|
||||
// =============================================================================
|
||||
|
||||
enum MentorChangeRequestStatus {
|
||||
PENDING
|
||||
RESOLVED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
model MentorChangeRequest {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
targetAssignmentId String? // Optional: a specific co-mentor the request is about
|
||||
requestedByUserId String?
|
||||
reason String @db.Text
|
||||
status MentorChangeRequestStatus @default(PENDING)
|
||||
resolvedByUserId String?
|
||||
resolvedAt DateTime?
|
||||
resolutionNote String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
|
||||
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
|
||||
|
||||
@@index([projectId])
|
||||
@@index([status])
|
||||
@@index([targetAssignmentId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILTERING ROUND SYSTEM
|
||||
// =============================================================================
|
||||
@@ -2449,7 +2491,8 @@ model AssignmentIntent {
|
||||
|
||||
model MentorFile {
|
||||
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
|
||||
|
||||
fileName String
|
||||
@@ -2468,13 +2511,15 @@ model MentorFile {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// 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])
|
||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||
comments MentorFileComment[]
|
||||
promotionEvents SubmissionPromotionEvent[]
|
||||
|
||||
@@index([projectId])
|
||||
@@index([mentorAssignmentId])
|
||||
@@index([uploadedByUserId])
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
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 {
|
||||
Table,
|
||||
@@ -27,15 +29,35 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} 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 {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Check,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
@@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [search, setSearch] = useState('')
|
||||
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: candidatesData, isLoading: candidatesLoading } =
|
||||
trpc.mentor.getCandidates.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!project && !project.mentorAssignment },
|
||||
// Already-assigned mentors (full list). Project.get spreads the underlying
|
||||
// `mentorAssignments` relation so we can read it directly.
|
||||
const assignedMentorAssignments = useMemo(() => {
|
||||
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 {
|
||||
data: suggestionsData,
|
||||
@@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
refetch: refetchSuggestions,
|
||||
} = trpc.mentor.getSuggestions.useQuery(
|
||||
{ projectId, limit: 5 },
|
||||
{ enabled: !!project && !project.mentorAssignment },
|
||||
{ enabled: !!project },
|
||||
)
|
||||
|
||||
const assignMutation = trpc.mentor.assign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor assigned')
|
||||
toast.success('Mentor added')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
@@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.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(() => {
|
||||
if (!candidatesData) return []
|
||||
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return candidatesData.candidates
|
||||
return candidatesData.candidates.filter((c) => {
|
||||
if (!q) return base
|
||||
return base.filter((c) => {
|
||||
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
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 (!project) {
|
||||
@@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
const teamSize = project.teamMembers?.length ?? 0
|
||||
const aiSource = suggestionsData?.source ?? 'ai'
|
||||
|
||||
@@ -206,80 +254,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Pending Change Requests ─── */}
|
||||
<PendingChangeRequestsPanel projectId={projectId} />
|
||||
|
||||
{/* ─── Currently Assigned ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardContent>
|
||||
{hasMentor ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{assignedMentorAssignments.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed py-8 text-center">
|
||||
<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">
|
||||
<AvatarFallback>
|
||||
{getInitials(
|
||||
project.mentorAssignment!.mentor.name ||
|
||||
project.mentorAssignment!.mentor.email,
|
||||
)}
|
||||
{getInitials(m.name || m.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
|
||||
href={`/admin/mentors/${m.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{project.mentorAssignment!.mentor.name || 'Unnamed'}
|
||||
{m.name || 'Unnamed'}
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{project.mentorAssignment!.mentor.email}
|
||||
</p>
|
||||
{project.mentorAssignment!.mentor.expertiseTags &&
|
||||
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
||||
<p className="text-muted-foreground text-sm">{m.email}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{project.mentorAssignment!.mentor.expertiseTags
|
||||
.slice(0, 5)
|
||||
.map((tag: string) => (
|
||||
{tags.slice(0, 5).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{tags.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</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 className="flex flex-col items-end gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
||||
{a.method.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unassignMutation.mutate({ projectId })}
|
||||
onClick={() =>
|
||||
setUnassignTarget({
|
||||
assignmentId: a.id,
|
||||
mentorName: m.name || m.email,
|
||||
})
|
||||
}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Unassign'
|
||||
)}
|
||||
Unassign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No mentor assigned yet — pick one below.
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Pick a Mentor ─── */}
|
||||
{!hasMentor && (
|
||||
{/* ─── Add a Mentor ─── */}
|
||||
<Card>
|
||||
<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>
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -311,7 +391,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
) : filteredCandidates.length === 0 ? (
|
||||
<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 className="overflow-hidden rounded-md border">
|
||||
@@ -376,7 +458,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<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>
|
||||
@@ -422,13 +504,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
|
||||
) : filteredSuggestions.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{suggestionsData.suggestions.map((s, i) => (
|
||||
{filteredSuggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.mentorId}
|
||||
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" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -515,8 +599,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { format } from 'date-fns'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -9,13 +11,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||
import { RequestChangeDialog } from './request-change-dialog'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCircle,
|
||||
FileText,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantMentorPage() {
|
||||
@@ -41,6 +47,8 @@ export default function ApplicantMentorPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||
|
||||
if (dashLoading) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="space-y-6">
|
||||
@@ -83,23 +104,72 @@ export default function ApplicantMentorPage() {
|
||||
Mentor Communication
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{mentor ? (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
||||
{/* Mentor list */}
|
||||
{hasMentors ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{assignments.map((assignment) => {
|
||||
const mentor = assignment.mentor
|
||||
if (!mentor) return null
|
||||
const expertise = mentor.expertiseTags ?? []
|
||||
return (
|
||||
<Card key={assignment.id} className="bg-muted/50">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">
|
||||
{mentor.name || 'Mentor'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{mentor.email}
|
||||
</p>
|
||||
{assignment.assignedAt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expertise.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{expertise.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="font-normal">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Request change action */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasPendingChangeRequest
|
||||
? "You have a pending mentor change request — admins will follow up soon."
|
||||
: 'Need a different match? Let the program admins know.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsChangeOpen(true)}
|
||||
disabled={hasPendingChangeRequest}
|
||||
>
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
@@ -113,12 +183,14 @@ export default function ApplicantMentorPage() {
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
{mentor && (
|
||||
{primaryMentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -136,12 +208,23 @@ export default function ApplicantMentorPage() {
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{dashboardData?.project?.mentorAssignment?.id && (
|
||||
{primaryAssignment?.id && projectId && (
|
||||
<WorkspaceFilesPanel
|
||||
mentorAssignmentId={dashboardData.project.mentorAssignment.id}
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={primaryAssignment.id}
|
||||
asApplicant
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Request change dialog */}
|
||||
{projectId && (
|
||||
<RequestChangeDialog
|
||||
projectId={projectId}
|
||||
mentors={dialogMentors}
|
||||
open={isChangeOpen}
|
||||
onOpenChange={setIsChangeOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal file
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const REASON_MIN = 10
|
||||
const REASON_MAX = 2000
|
||||
const TARGET_ANY = '__any__'
|
||||
|
||||
type MentorOption = {
|
||||
assignmentId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type RequestChangeDialogProps = {
|
||||
projectId: string
|
||||
mentors: MentorOption[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function RequestChangeDialog({
|
||||
projectId,
|
||||
mentors,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RequestChangeDialogProps) {
|
||||
const [reason, setReason] = useState('')
|
||||
const [target, setTarget] = useState<string>(TARGET_ANY)
|
||||
const [touched, setTouched] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const requestChange = trpc.mentor.requestChange.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success(
|
||||
"Your request has been sent to the program admins. We'll review it and follow up.",
|
||||
)
|
||||
onOpenChange(false)
|
||||
// Refresh dashboard so the disabled state for the button updates.
|
||||
await utils.applicant.getMyDashboard.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Could not send your request. Please try again.')
|
||||
},
|
||||
})
|
||||
|
||||
// Reset form when the dialog is closed.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setReason('')
|
||||
setTarget(TARGET_ANY)
|
||||
setTouched(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const trimmedReason = reason.trim()
|
||||
const reasonTooShort = trimmedReason.length < REASON_MIN
|
||||
const reasonTooLong = trimmedReason.length > REASON_MAX
|
||||
const reasonInvalid = reasonTooShort || reasonTooLong
|
||||
const showReasonError = touched && reasonInvalid
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setTouched(true)
|
||||
if (reasonInvalid) return
|
||||
|
||||
requestChange.mutate({
|
||||
projectId,
|
||||
targetAssignmentId: target === TARGET_ANY ? undefined : target,
|
||||
reason: trimmedReason,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Request a mentor change</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share a few details so the program admins can follow up with you.
|
||||
Your current mentor will not see this message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mentors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetMentor">About a specific mentor</Label>
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger id="targetMentor">
|
||||
<SelectValue placeholder="Any / general" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={TARGET_ANY}>Any / general</SelectItem>
|
||||
{mentors.map((m) => (
|
||||
<SelectItem key={m.assignmentId} value={m.assignmentId}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Use this if your request is about one of your co-mentors in particular.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">
|
||||
Why would you like a change?
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
onBlur={() => setTouched(true)}
|
||||
placeholder="Tell us why you'd like a change. The admin team will follow up."
|
||||
rows={6}
|
||||
maxLength={REASON_MAX}
|
||||
aria-invalid={showReasonError || undefined}
|
||||
required
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
{showReasonError ? (
|
||||
<p className="text-destructive">
|
||||
{reasonTooShort
|
||||
? `Please provide at least ${REASON_MIN} characters.`
|
||||
: `Please keep your message under ${REASON_MAX} characters.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{REASON_MIN}–{REASON_MAX} characters.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground tabular-nums">
|
||||
{trimmedReason.length}/{REASON_MAX}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={requestChange.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={requestChange.isPending}>
|
||||
{requestChange.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Send request
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -357,12 +357,12 @@ export default function ApplicantProjectPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{project.mentorAssignment?.mentor && (
|
||||
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
|
||||
{project.mentorAssignments?.[0]?.mentor && (
|
||||
<div className="rounded-lg border p-3 bg-muted/50">
|
||||
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
||||
{project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
|
||||
// to keep tracking + chat working unchanged.
|
||||
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
|
||||
|
||||
// Track view when project loads
|
||||
const trackView = trpc.mentor.trackView.useMutation()
|
||||
useEffect(() => {
|
||||
if (project?.mentorAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
||||
if (primaryAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project?.mentorAssignment?.id])
|
||||
}, [primaryAssignment?.id])
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
@@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
||||
const mentorAssignment = project.mentorAssignment
|
||||
const mentorAssignment = primaryAssignment
|
||||
const mentorAssignmentId = mentorAssignment?.id
|
||||
const programId = project.program?.id
|
||||
const viewerIsAssignedMentor =
|
||||
@@ -477,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
||||
currentUserId={primaryAssignment?.mentor?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function MentorWorkspaceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
// Get mentor assignment for this project
|
||||
@@ -27,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
|
||||
// Gracefully tolerates stale tabs where the caller no longer has access
|
||||
// (assignment dropped) — query just returns nothing in that case.
|
||||
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!projectId, retry: false }
|
||||
)
|
||||
|
||||
const currentUserId = session?.user?.id
|
||||
const coMentors = (projectMentors ?? []).filter(
|
||||
a => a.mentor.id !== currentUserId
|
||||
)
|
||||
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
|
||||
const visibleCoMentors = coMentorNames.slice(0, 3)
|
||||
const hiddenCoMentors = coMentorNames.slice(3)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -70,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||
)}
|
||||
{coMentors.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
You + {coMentors.length} co-mentor
|
||||
{coMentors.length === 1 ? '' : 's'}:{' '}
|
||||
<span className="text-foreground">
|
||||
{visibleCoMentors.join(', ')}
|
||||
</span>
|
||||
{hiddenCoMentors.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help underline decoration-dotted underline-offset-2">
|
||||
+{hiddenCoMentors.length} more
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-xs">
|
||||
{hiddenCoMentors.join(', ')}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +159,10 @@ export default function MentorWorkspaceDetailPage() {
|
||||
|
||||
<TabsContent value="files" className="mt-6">
|
||||
{assignment ? (
|
||||
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
|
||||
<WorkspaceFilesPanel
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={assignment.id}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
@@ -117,7 +175,7 @@ export default function MentorWorkspaceDetailPage() {
|
||||
|
||||
<TabsContent value="promotion" className="mt-6">
|
||||
{assignment ? (
|
||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
||||
<FilePromotionPanel projectId={projectId} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
FileText,
|
||||
Inbox,
|
||||
MessageCircle,
|
||||
Target,
|
||||
UserCheck,
|
||||
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
|
||||
{ status: 'PENDING' },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
if (statsLoading || poolLoading) {
|
||||
return (
|
||||
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
||||
}
|
||||
if (!stats || !pool) return null
|
||||
|
||||
const pendingCount = pendingChangeRequests?.length ?? 0
|
||||
// If there's at least one pending request, deep-link directly into the
|
||||
// first one's project (admins can resolve / view siblings from there).
|
||||
// Otherwise the card stays static.
|
||||
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
|
||||
const changeRequestsHref = firstPendingProjectId
|
||||
? `/admin/projects/${firstPendingProjectId}/mentor`
|
||||
: null
|
||||
|
||||
const requestedPct = stats.totalProjects
|
||||
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
||||
: 0
|
||||
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`md:col-span-2 xl:col-span-4 ${
|
||||
pendingCount > 0 ? 'border-amber-300 dark:border-amber-700' : ''
|
||||
}`}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Inbox
|
||||
className={`h-5 w-5 ${
|
||||
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Pending change requests</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Team members asking admin to swap a mentor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
|
||||
{changeRequestsHref ? (
|
||||
<Link
|
||||
href={changeRequestsHref}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
|
||||
>
|
||||
Review
|
||||
<ArrowRight className="ml-0.5 h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">All clear</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 xl:col-span-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface FilePromotionPanelProps {
|
||||
mentorAssignmentId: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -28,14 +28,14 @@ function formatFileSize(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) {
|
||||
export function FilePromotionPanel({ projectId }: FilePromotionPanelProps) {
|
||||
const [selectedSlot, setSelectedSlot] = useState<string>('')
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: workspaceFiles = [], isLoading: filesLoading } =
|
||||
trpc.mentor.workspaceGetFiles.useQuery(
|
||||
{ mentorAssignmentId },
|
||||
{ enabled: !!mentorAssignmentId },
|
||||
{ projectId },
|
||||
{ enabled: !!projectId },
|
||||
)
|
||||
|
||||
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({
|
||||
|
||||
@@ -12,10 +12,18 @@ import {
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react'
|
||||
import { Eye, FileText, Upload, Download, Trash2, MessageSquare, X, Loader2 } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
||||
|
||||
interface Props {
|
||||
/** Project the workspace belongs to — drives file list (project-scoped). */
|
||||
projectId: string
|
||||
/**
|
||||
* One MentorAssignment id on this project — needed only to mint upload tokens
|
||||
* (the token is signed against the assignment + project pair, but the file
|
||||
* itself is project-scoped so co-mentors see it).
|
||||
*/
|
||||
mentorAssignmentId: string
|
||||
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
|
||||
asApplicant?: boolean
|
||||
@@ -29,21 +37,21 @@ function formatSize(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) {
|
||||
export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
|
||||
{ mentorAssignmentId },
|
||||
{ enabled: !!mentorAssignmentId }
|
||||
{ projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
|
||||
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
||||
utils.mentor.workspaceGetFiles.invalidate({ projectId })
|
||||
setDescription('')
|
||||
toast.success('File uploaded')
|
||||
},
|
||||
@@ -51,7 +59,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
||||
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
|
||||
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
||||
utils.mentor.workspaceGetFiles.invalidate({ projectId })
|
||||
toast.success('File deleted')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
@@ -83,10 +91,43 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
||||
}
|
||||
}
|
||||
|
||||
const [previewFileId, setPreviewFileId] = useState<string | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
|
||||
const canPreviewMime = (m: string, name: string) =>
|
||||
m.startsWith('video/') || m === 'application/pdf' || m.startsWith('image/') || isOfficeFile(m, name)
|
||||
|
||||
const togglePreview = async (file: { id: string; mimeType: string; fileName: string }) => {
|
||||
if (previewFileId === file.id) {
|
||||
setPreviewFileId(null)
|
||||
setPreviewUrl(null)
|
||||
return
|
||||
}
|
||||
setPreviewFileId(file.id)
|
||||
setPreviewUrl(null)
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const { url } = await downloadMutation.mutateAsync({ mentorFileId: file.id, disposition: 'inline' })
|
||||
setPreviewUrl(url)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Preview failed')
|
||||
setPreviewFileId(null)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (mentorFileId: string) => {
|
||||
try {
|
||||
const { url } = await downloadMutation.mutateAsync({ mentorFileId })
|
||||
window.open(url, '_blank')
|
||||
const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' })
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = ''
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Download failed')
|
||||
}
|
||||
@@ -141,8 +182,12 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
||||
)}
|
||||
|
||||
<ul className="divide-y">
|
||||
{(files ?? []).map((f) => (
|
||||
<li key={f.id} className="flex items-center gap-3 py-3">
|
||||
{(files ?? []).map((f) => {
|
||||
const isOpen = previewFileId === f.id
|
||||
const previewable = canPreviewMime(f.mimeType, f.fileName)
|
||||
return (
|
||||
<li key={f.id} className="py-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{f.fileName}</div>
|
||||
@@ -160,7 +205,24 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
||||
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
|
||||
{previewable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => togglePreview(f)}
|
||||
title={isOpen ? 'Close preview' : 'Preview'}
|
||||
aria-label={isOpen ? 'Close preview' : 'Preview file'}
|
||||
>
|
||||
{isOpen ? <X className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownload(f.id)}
|
||||
title="Download"
|
||||
aria-label="Download file"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
@@ -184,8 +246,22 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="rounded-md border bg-muted/30 overflow-hidden">
|
||||
{previewLoading || !previewUrl ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading preview…
|
||||
</div>
|
||||
) : (
|
||||
<FilePreview file={{ mimeType: f.mimeType, fileName: f.fileName }} url={previewUrl} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
171
src/lib/email.ts
171
src/lib/email.ts
@@ -2752,6 +2752,177 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu
|
||||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-team mentor assignment (fires every time a mentor is added to a project)
|
||||
// =============================================================================
|
||||
|
||||
function getMentorTeamAssignmentTemplate(
|
||||
name: string,
|
||||
projectTitle: string,
|
||||
workspaceUrl: string,
|
||||
): EmailTemplate {
|
||||
const subject = `You've been assigned to a new MOPC project: "${projectTitle}"`
|
||||
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||||
const text = [
|
||||
greeting,
|
||||
'',
|
||||
`You have been assigned as a mentor to the project "${projectTitle}".`,
|
||||
'',
|
||||
'You may have co-mentors on this team — you can collaborate together in the project workspace.',
|
||||
'',
|
||||
`Open the workspace: ${workspaceUrl}`,
|
||||
'',
|
||||
'The MOPC team',
|
||||
].join('\n')
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||
<h1 style="margin:0;font-size:20px;font-weight:600;">New mentor assignment</h1>
|
||||
</div>
|
||||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||
<p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
|
||||
<p>You have been assigned as a mentor to the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||||
<p style="margin-top:24px;">
|
||||
<a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Project Workspace</a>
|
||||
</p>
|
||||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||
You may have co-mentors on this team — you can collaborate together in the project workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||
Monaco Ocean Protection Challenge
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim()
|
||||
|
||||
return { subject, text, html }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a per-team mentor assignment email. Fires every time a mentor is added
|
||||
* to a specific project (distinct from the one-time onboarding email).
|
||||
* Idempotency is enforced at the call site via MentorAssignment.notificationSentAt.
|
||||
* Never throws — failures are caught and logged.
|
||||
*/
|
||||
export async function sendMentorTeamAssignmentEmail(
|
||||
email: string,
|
||||
name: string | null,
|
||||
projectTitle: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
const workspaceUrl = `${baseUrl.replace(/\/$/, '')}/mentor/workspace/${projectId}`
|
||||
const template = getMentorTeamAssignmentTemplate(name || '', projectTitle, workspaceUrl)
|
||||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||
} catch (error) {
|
||||
console.error('[sendMentorTeamAssignmentEmail] failed', { email, projectId, error })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mentor change requests (PR 8) — admin notification when an applicant or admin
|
||||
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
||||
// =============================================================================
|
||||
|
||||
function getMentorChangeRequestTemplate(
|
||||
projectTitle: string,
|
||||
requesterName: string | null,
|
||||
reason: string,
|
||||
adminDashboardUrl: string,
|
||||
): EmailTemplate {
|
||||
const subject = `Mentor change request for "${projectTitle}"`
|
||||
const requesterLabel = requesterName || 'a team member'
|
||||
const text = [
|
||||
'Hi MOPC admins,',
|
||||
'',
|
||||
`A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`,
|
||||
'',
|
||||
'Reason:',
|
||||
`"${reason}"`,
|
||||
'',
|
||||
`Review the request: ${adminDashboardUrl}`,
|
||||
'',
|
||||
'The MOPC team',
|
||||
].join('\n')
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||
<h1 style="margin:0;font-size:20px;font-weight:600;">Mentor change request</h1>
|
||||
</div>
|
||||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||
<p style="margin-top:0;">Hi MOPC admins,</p>
|
||||
<p>A mentor change request has been opened by <strong>${escapeHtml(requesterLabel)}</strong> for the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||||
<blockquote style="margin:16px 0;padding:12px 16px;background:#f1f5f9;border-left:3px solid #557f8c;border-radius:4px;color:#0f172a;font-style:italic;white-space:pre-wrap;">${escapeHtml(reason)}</blockquote>
|
||||
<p style="margin-top:24px;">
|
||||
<a href="${adminDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Review Request</a>
|
||||
</p>
|
||||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||
Mentors are not notified of change requests; only admins see this.
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||
Monaco Ocean Protection Challenge
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim()
|
||||
|
||||
return { subject, text, html }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request
|
||||
* has been opened for a project. Sends one email per recipient.
|
||||
* Never throws — failures are caught and logged so the calling mutation
|
||||
* (mentor.requestChange) never fails because of email infrastructure issues.
|
||||
*/
|
||||
export async function sendMentorChangeRequestEmail(
|
||||
adminEmails: string[],
|
||||
projectTitle: string,
|
||||
requesterName: string | null,
|
||||
reason: string,
|
||||
adminDashboardUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (adminEmails.length === 0) {
|
||||
console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping')
|
||||
return
|
||||
}
|
||||
const template = getMentorChangeRequestTemplate(
|
||||
projectTitle,
|
||||
requesterName,
|
||||
reason,
|
||||
adminDashboardUrl,
|
||||
)
|
||||
await Promise.all(
|
||||
adminEmails.map((email) =>
|
||||
sendEmail({
|
||||
to: email,
|
||||
subject: template.subject,
|
||||
text: template.text,
|
||||
html: template.html,
|
||||
}).catch((err) => {
|
||||
console.error('[sendMentorChangeRequestEmail] send failed', { email, err })
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[sendMentorChangeRequestEmail] failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
function getFinalistConfirmationTemplate(
|
||||
name: string,
|
||||
projectTitle: string,
|
||||
|
||||
@@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
export type MentorUploadPayload = {
|
||||
mentorAssignmentId: string
|
||||
/**
|
||||
* Project the upload belongs to. Bound at token-issue time so the file's
|
||||
* project scope can't be tampered with separately from the assignment id.
|
||||
* Required (no legacy fallback) — tokens live <1h, so any in-flight tokens
|
||||
* issued before this field was added expire on their own.
|
||||
*/
|
||||
projectId: string
|
||||
uploaderUserId: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
@@ -47,5 +54,8 @@ export function verifyMentorUploadToken(token: string): MentorUploadPayload {
|
||||
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new Error('Invalid mentor upload token: expired')
|
||||
}
|
||||
if (typeof payload.projectId !== 'string' || payload.projectId.length === 0) {
|
||||
throw new Error('Invalid mentor upload token: missing projectId')
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -78,13 +78,17 @@ export async function getPresignedUrl(
|
||||
objectKey: string,
|
||||
method: 'GET' | 'PUT' = 'GET',
|
||||
expirySeconds: number = 900, // 15 minutes default
|
||||
options?: { downloadFileName?: string }
|
||||
options?: { downloadFileName?: string; inline?: boolean; contentType?: string }
|
||||
): Promise<string> {
|
||||
const publicClient = getPublicMinioClient()
|
||||
if (method === 'GET') {
|
||||
const respHeaders = options?.downloadFileName
|
||||
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
||||
: undefined
|
||||
let respHeaders: Record<string, string> | undefined
|
||||
if (options?.inline) {
|
||||
respHeaders = { 'response-content-disposition': 'inline' }
|
||||
if (options.contentType) respHeaders['response-content-type'] = options.contentType
|
||||
} else if (options?.downloadFileName) {
|
||||
respHeaders = { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
||||
}
|
||||
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
|
||||
} else {
|
||||
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)
|
||||
|
||||
@@ -1176,7 +1176,7 @@ export const applicantRouter = router({
|
||||
],
|
||||
},
|
||||
include: {
|
||||
mentorAssignment: { select: { mentorId: true } },
|
||||
mentorAssignments: { select: { mentorId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1187,7 +1187,10 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
if (!project.mentorAssignment) {
|
||||
// TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the
|
||||
// first one for legacy parity.
|
||||
const primaryMentorAssignment = project.mentorAssignments[0] ?? null
|
||||
if (!primaryMentorAssignment) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No mentor assigned to this project',
|
||||
@@ -1207,9 +1210,9 @@ export const applicantRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Notify the mentor
|
||||
// Notify the (primary) mentor
|
||||
await createNotification({
|
||||
userId: project.mentorAssignment.mentorId,
|
||||
userId: primaryMentorAssignment.mentorId,
|
||||
type: 'MENTOR_MESSAGE',
|
||||
title: 'New Message',
|
||||
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
|
||||
@@ -1313,12 +1316,13 @@ export const applicantRouter = router({
|
||||
submittedBy: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
mentorAssignment: {
|
||||
mentorAssignments: {
|
||||
include: {
|
||||
mentor: {
|
||||
select: { id: true, name: true, email: true },
|
||||
select: { id: true, name: true, email: true, expertiseTags: true },
|
||||
},
|
||||
},
|
||||
orderBy: { assignedAt: 'asc' },
|
||||
},
|
||||
wonAwards: {
|
||||
select: { id: true, name: true },
|
||||
@@ -1489,6 +1493,17 @@ export const applicantRouter = router({
|
||||
logoUrl = await provider.getDownloadUrl(project.logoKey)
|
||||
}
|
||||
|
||||
// Does this user have an open mentor-change request for this project?
|
||||
// (Used by the applicant mentor page to disable the "Request a change" button.)
|
||||
const myPendingChangeRequest = await ctx.prisma.mentorChangeRequest.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
requestedByUserId: ctx.user.id,
|
||||
status: 'PENDING',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
project: {
|
||||
...project,
|
||||
@@ -1502,6 +1517,7 @@ export const applicantRouter = router({
|
||||
hasPassedIntake: !!passedIntake,
|
||||
isIntakeOpen: !!activeIntakeRound,
|
||||
logoUrl,
|
||||
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1523,7 +1539,7 @@ export const applicantRouter = router({
|
||||
select: {
|
||||
id: true,
|
||||
programId: true,
|
||||
mentorAssignment: { select: { id: true } },
|
||||
mentorAssignments: { select: { id: true }, take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1531,8 +1547,8 @@ export const applicantRouter = router({
|
||||
return { hasMentor: false, hasEvaluationRounds: false }
|
||||
}
|
||||
|
||||
// Check if mentor is assigned
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
// Check if mentor is assigned (any active assignment counts)
|
||||
const hasMentor = project.mentorAssignments.length > 0
|
||||
|
||||
// Check if feedback is available — first check admin settings, then fall back to per-round config
|
||||
let hasEvaluationRounds = false
|
||||
@@ -2689,8 +2705,12 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
||||
// TODO(PR8 Task 7): when multiple mentors are assigned, surface them all
|
||||
// in the applicant message thread. For now we display the most recently
|
||||
// assigned (non-dropped) mentor as the "primary".
|
||||
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: input.projectId },
|
||||
orderBy: { assignedAt: 'desc' },
|
||||
include: { mentor: { select: { id: true, name: true, email: true } } },
|
||||
})
|
||||
|
||||
|
||||
@@ -772,7 +772,8 @@ export const finalistRouter = router({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
mentorAssignment: {
|
||||
mentorAssignments: {
|
||||
where: { droppedAt: null, completionStatus: { not: 'completed' } },
|
||||
select: {
|
||||
id: true,
|
||||
completionStatus: true,
|
||||
@@ -796,10 +797,12 @@ export const finalistRouter = router({
|
||||
data: { status: 'SUPERSEDED' },
|
||||
})
|
||||
|
||||
// Cascade: drop active mentor assignment (skip if completed or already dropped)
|
||||
const ma = confirmation.project.mentorAssignment
|
||||
// Cascade: drop ALL active mentor assignments (skip dropped/completed —
|
||||
// those were filtered out by the include `where` above). With multi-mentor
|
||||
// (PR8) we propagate the cascade to every active assignment.
|
||||
const activeAssignments = confirmation.project.mentorAssignments
|
||||
let cascadedMentorAssignment = false
|
||||
if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') {
|
||||
for (const ma of activeAssignments) {
|
||||
await ctx.prisma.mentorAssignment.update({
|
||||
where: { id: ma.id },
|
||||
data: {
|
||||
@@ -833,6 +836,7 @@ export const finalistRouter = router({
|
||||
reason: input.reason,
|
||||
projectId: confirmation.projectId,
|
||||
cascadedMentorAssignment,
|
||||
cascadedAssignmentCount: activeAssignments.length,
|
||||
},
|
||||
})
|
||||
return { ok: true, cascadedMentorAssignment }
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
MentorAssignmentMethod,
|
||||
MentorChangeRequestStatus,
|
||||
Prisma,
|
||||
type PrismaClient,
|
||||
} from '@prisma/client'
|
||||
import {
|
||||
sendMentorChangeRequestEmail,
|
||||
sendMentorTeamAssignmentEmail,
|
||||
} from '@/lib/email'
|
||||
import {
|
||||
getAIMentorSuggestions,
|
||||
getRoundRobinMentor,
|
||||
@@ -66,6 +75,42 @@ async function assertWorkspaceAccess(
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-scoped workspace access check (PR8 multi-mentor).
|
||||
*
|
||||
* Allowed when the user is either:
|
||||
* 1) currently assigned as a mentor on this project (droppedAt = null), OR
|
||||
* 2) a team member of the project.
|
||||
*
|
||||
* Also requires at least one active mentor assignment for the project with
|
||||
* workspaceEnabled = true — meaning the project actually has a live workspace.
|
||||
* Throws TRPCError on failure. Returns nothing on success.
|
||||
*/
|
||||
async function assertProjectWorkspaceAccess(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId, droppedAt: null, workspaceEnabled: true },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!liveMentorAssignment) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
|
||||
}
|
||||
const mentorOnProject = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId, mentorId: userId, droppedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (mentorOnProject) return
|
||||
const teamMembership = await prisma.teamMember.findFirst({
|
||||
where: { projectId, userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (teamMembership) return
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
|
||||
}
|
||||
|
||||
export const mentorRouter = router({
|
||||
/**
|
||||
* Get AI-suggested mentor matches for a project
|
||||
@@ -82,18 +127,15 @@ export const mentorRouter = router({
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
include: {
|
||||
mentorAssignment: true,
|
||||
mentorAssignments: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (project.mentorAssignment) {
|
||||
return {
|
||||
currentMentor: project.mentorAssignment,
|
||||
suggestions: [],
|
||||
source: 'ai' as const,
|
||||
message: 'Project already has a mentor assigned',
|
||||
}
|
||||
}
|
||||
// With multi-mentor (PR8) the project can have several mentors. The
|
||||
// suggestions endpoint is informational — return whatever AI suggests
|
||||
// and let `mentor.assign` enforce per-pair uniqueness. We still surface
|
||||
// an existing primary mentor in the payload so UIs can label it.
|
||||
const primaryMentor = project.mentorAssignments[0] ?? null
|
||||
|
||||
// Detect AI configuration so the UI can label "AI matching unavailable"
|
||||
// when we fall back to algorithmic ranking. An AI error mid-call still
|
||||
@@ -140,7 +182,9 @@ export const mentorRouter = router({
|
||||
})
|
||||
|
||||
return {
|
||||
currentMentor: null,
|
||||
// TODO(PR8 Task 8): return the full mentor list. Legacy field kept
|
||||
// until the admin UI is updated.
|
||||
currentMentor: primaryMentor,
|
||||
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
||||
source,
|
||||
message: null,
|
||||
@@ -219,26 +263,24 @@ export const mentorRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify project exists and doesn't have a mentor
|
||||
// Verify project exists (multi-mentor: stacking is allowed; duplicate
|
||||
// (projectId, mentorId) pairs are rejected by the unique constraint
|
||||
// below).
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
include: { mentorAssignment: true },
|
||||
})
|
||||
|
||||
if (project.mentorAssignment) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Project already has a mentor assigned',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify mentor exists
|
||||
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.mentorId },
|
||||
})
|
||||
|
||||
// Create assignment
|
||||
const assignment = await ctx.prisma.mentorAssignment.create({
|
||||
// Create assignment. P2002 on the composite (projectId, mentorId) unique
|
||||
// constraint means this exact mentor is already on this team — surface a
|
||||
// friendly error.
|
||||
let assignment
|
||||
try {
|
||||
assignment = await ctx.prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
mentorId: input.mentorId,
|
||||
@@ -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
|
||||
await logAudit({
|
||||
@@ -279,6 +333,8 @@ export const mentorRouter = router({
|
||||
mentorId: input.mentorId,
|
||||
mentorName: assignment.mentor.name,
|
||||
method: input.method,
|
||||
// PR8: per-team assignment (one row per mentor-project pair).
|
||||
assignmentScope: 'per-team',
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
@@ -320,6 +376,27 @@ export const mentorRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Send per-team email notification once per assignment row. Idempotency
|
||||
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
|
||||
// it null. If the same mentor is later dropped and re-assigned (new row,
|
||||
// fresh id), a new email is sent — intentional.
|
||||
if (assignment.notificationSentAt == null && assignment.mentor.email) {
|
||||
await sendMentorTeamAssignmentEmail(
|
||||
assignment.mentor.email,
|
||||
assignment.mentor.name,
|
||||
assignment.project.title,
|
||||
input.projectId,
|
||||
)
|
||||
try {
|
||||
await ctx.prisma.mentorAssignment.update({
|
||||
where: { id: assignment.id },
|
||||
data: { notificationSentAt: new Date() },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
|
||||
try {
|
||||
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
|
||||
@@ -351,13 +428,16 @@ export const mentorRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify project exists and doesn't have a mentor
|
||||
// Verify project exists and doesn't already have a mentor. Multi-mentor
|
||||
// stacking is reserved for explicit admin assignment via `mentor.assign`;
|
||||
// auto-assignment skips projects that already have at least one mentor
|
||||
// to avoid double-AI-assignments.
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
include: { mentorAssignment: true },
|
||||
include: { mentorAssignments: { select: { id: true } } },
|
||||
})
|
||||
|
||||
if (project.mentorAssignment) {
|
||||
if (project.mentorAssignments.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Project already has a mentor assigned',
|
||||
@@ -485,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
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
assignmentId: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
})
|
||||
.refine((v) => !!v.assignmentId || !!v.projectId, {
|
||||
message: 'Either assignmentId or projectId is required',
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
||||
where: { projectId: input.projectId },
|
||||
const assignment = input.assignmentId
|
||||
? await ctx.prisma.mentorAssignment.findUnique({
|
||||
where: { id: input.assignmentId },
|
||||
include: {
|
||||
mentor: { select: { id: true, name: true } },
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
})
|
||||
: await ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: input.projectId! },
|
||||
orderBy: { assignedAt: 'desc' },
|
||||
include: {
|
||||
mentor: { select: { id: true, name: true } },
|
||||
project: { select: { id: true, title: true } },
|
||||
@@ -501,13 +603,13 @@ export const mentorRouter = router({
|
||||
if (!assignment) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No mentor assignment found for this project',
|
||||
message: 'No mentor assignment found',
|
||||
})
|
||||
}
|
||||
|
||||
// Delete assignment
|
||||
await ctx.prisma.mentorAssignment.delete({
|
||||
where: { projectId: input.projectId },
|
||||
where: { id: assignment.id },
|
||||
})
|
||||
|
||||
// Audit outside transaction so failures don't roll back the unassignment
|
||||
@@ -518,7 +620,7 @@ export const mentorRouter = router({
|
||||
entityType: 'MentorAssignment',
|
||||
entityId: assignment.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
projectId: assignment.project.id,
|
||||
projectTitle: assignment.project.title,
|
||||
mentorId: assignment.mentor.id,
|
||||
mentorName: assignment.mentor.name,
|
||||
@@ -546,7 +648,7 @@ export const mentorRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
mentorAssignment: null,
|
||||
mentorAssignments: { none: {} },
|
||||
wantsMentorship: true,
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -716,7 +818,7 @@ export const mentorRouter = router({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: {
|
||||
mentorAssignment: null,
|
||||
mentorAssignments: { none: {} },
|
||||
// Only assign mentors to projects whose team has confirmed they will
|
||||
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
|
||||
// confirmations and any project without a confirmation row at all.
|
||||
@@ -834,7 +936,7 @@ export const mentorRouter = router({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: {
|
||||
mentorAssignment: { isNot: null },
|
||||
mentorAssignments: { some: {} },
|
||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||
},
|
||||
},
|
||||
@@ -906,13 +1008,13 @@ export const mentorRouter = router({
|
||||
ctx.prisma.projectRoundState.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: { wantsMentorship: true, mentorAssignment: { isNot: null } },
|
||||
project: { wantsMentorship: true, mentorAssignments: { some: {} } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.projectRoundState.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: { mentorAssignment: { isNot: null } },
|
||||
project: { mentorAssignments: { some: {} } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.mentorMessage.count({
|
||||
@@ -1107,7 +1209,11 @@ export const mentorRouter = router({
|
||||
status: true,
|
||||
oceanIssue: true,
|
||||
competitionCategory: true,
|
||||
mentorAssignment: {
|
||||
mentorAssignments: {
|
||||
// TODO(PR8 Task 8): surface all mentors in the activity view.
|
||||
// For now keep the legacy single-mentor activity row by picking the
|
||||
// latest-assigned, non-dropped assignment (or the most-recent overall).
|
||||
orderBy: { assignedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
method: true,
|
||||
@@ -1157,7 +1263,10 @@ export const mentorRouter = router({
|
||||
|
||||
const rows = projects.map((p) => {
|
||||
// Treat a dropped mentor assignment as if no mentor is assigned.
|
||||
const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null
|
||||
// TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most
|
||||
// recent non-dropped assignment for the activity row.
|
||||
const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null
|
||||
const ma = firstActive
|
||||
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
|
||||
const lastFileAt = ma?.files[0]?.createdAt ?? null
|
||||
const lastActivityAt = [lastMessageAt, lastFileAt]
|
||||
@@ -1235,6 +1344,50 @@ export const mentorRouter = router({
|
||||
return assignments
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all active mentors assigned to a project (PR8 multi-mentor).
|
||||
*
|
||||
* Returns one row per active MentorAssignment (droppedAt = null) with the
|
||||
* mentor's id + name. Used by the mentor workspace page to display the
|
||||
* co-mentor team so each mentor knows who else they're working with.
|
||||
*
|
||||
* Authorization: caller must be an active mentor on the project (or an
|
||||
* admin via mentorProcedure). Non-assigned mentors get FORBIDDEN.
|
||||
*/
|
||||
getProjectMentors: mentorProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
mentorId: ctx.user.id,
|
||||
droppedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (!ownAssignment) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not assigned to mentor this project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = await ctx.prisma.mentorAssignment.findMany({
|
||||
where: { projectId: input.projectId, droppedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
mentor: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { assignedAt: 'asc' },
|
||||
})
|
||||
|
||||
return assignments
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get detailed project info for a mentor's assigned project
|
||||
*/
|
||||
@@ -1279,7 +1432,7 @@ export const mentorRouter = router({
|
||||
files: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
mentorAssignment: {
|
||||
mentorAssignments: {
|
||||
include: {
|
||||
mentor: {
|
||||
select: { id: true, name: true, email: true },
|
||||
@@ -2080,6 +2233,7 @@ export const mentorRouter = router({
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600
|
||||
const uploadToken = signMentorUploadToken({
|
||||
mentorAssignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
uploaderUserId: ctx.user.id,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
@@ -2136,45 +2290,55 @@ export const mentorRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* List files in a workspace. Authorized for the assigned mentor or any
|
||||
* project team member.
|
||||
* List files in a project's mentor workspace. Authorized for any mentor
|
||||
* currently assigned to the project, or any team member of the project.
|
||||
*
|
||||
* Project-scoped (PR8): all co-mentors share one file list, and files
|
||||
* survive even when an originating assignment is later dropped.
|
||||
*/
|
||||
workspaceGetFiles: protectedProcedure
|
||||
.input(z.object({ mentorAssignmentId: z.string() }))
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
|
||||
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
|
||||
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
|
||||
return workspaceGetFilesService(input.projectId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Issue a short-lived presigned GET URL to download a workspace file.
|
||||
*/
|
||||
workspaceGetFileDownloadUrl: protectedProcedure
|
||||
.input(z.object({ mentorFileId: z.string() }))
|
||||
.input(z.object({
|
||||
mentorFileId: z.string(),
|
||||
disposition: z.enum(['inline', 'attachment']).default('attachment'),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const file = await ctx.prisma.mentorFile.findUnique({
|
||||
where: { id: input.mentorFileId },
|
||||
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
|
||||
select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
|
||||
})
|
||||
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
||||
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
||||
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
||||
{ downloadFileName: file.fileName })
|
||||
input.disposition === 'inline'
|
||||
? { inline: true, contentType: file.mimeType }
|
||||
: { downloadFileName: file.fileName })
|
||||
return { url }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a workspace file (uploader or assigned mentor only).
|
||||
* Delete a workspace file. Authorized for the uploader, any mentor
|
||||
* currently assigned to the file's project, or any team member of the
|
||||
* file's project. Final auth check lives in the service.
|
||||
*/
|
||||
workspaceDeleteFile: protectedProcedure
|
||||
.input(z.object({ mentorFileId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const file = await ctx.prisma.mentorFile.findUnique({
|
||||
where: { id: input.mentorFileId },
|
||||
select: { mentorAssignmentId: true },
|
||||
select: { projectId: true },
|
||||
})
|
||||
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
||||
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
||||
try {
|
||||
await workspaceDeleteFileService(
|
||||
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
|
||||
@@ -2204,12 +2368,12 @@ export const mentorRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const file = await ctx.prisma.mentorFile.findUnique({
|
||||
where: { id: input.mentorFileId },
|
||||
select: { mentorAssignmentId: true },
|
||||
select: { projectId: true },
|
||||
})
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||
}
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
||||
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
||||
return workspaceAddFileComment(
|
||||
{
|
||||
mentorFileId: input.mentorFileId,
|
||||
@@ -2414,4 +2578,243 @@ export const mentorRouter = router({
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
// ===========================================================================
|
||||
// Mentor change requests (PR8)
|
||||
//
|
||||
// Applicants (team members) or admins can open a PENDING change request for
|
||||
// a project — optionally targeting a specific co-mentor assignment. Admins
|
||||
// are notified by email; mentors are intentionally NOT notified, even after
|
||||
// resolution (per design decision in the PR8 plan).
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Open a new mentor change request. Allowed for:
|
||||
* • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
|
||||
* • a team member of the target project.
|
||||
*
|
||||
* Rejects with CONFLICT if the same user already has an open (PENDING) request
|
||||
* for the same project. The raw `reason` is intentionally NOT included in
|
||||
* audit logs — only its length — for privacy. Email delivery to admins is
|
||||
* best-effort and never throws.
|
||||
*/
|
||||
requestChange: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
targetAssignmentId: z.string().min(1).optional(),
|
||||
reason: z.string().min(10).max(2000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
// Authorization: admin OR team member of the project
|
||||
if (!isAdmin) {
|
||||
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
||||
where: { projectId: input.projectId, userId: ctx.user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not a member of this project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load project (also confirms it exists) and validate optional target
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
if (input.targetAssignmentId) {
|
||||
const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
|
||||
where: { id: input.targetAssignmentId },
|
||||
select: { id: true, projectId: true },
|
||||
})
|
||||
if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Target assignment does not belong to this project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// One open request per (user, project)
|
||||
const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
requestedByUserId: ctx.user.id,
|
||||
status: MentorChangeRequestStatus.PENDING,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (existingOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'You already have an open mentor change request for this project.',
|
||||
})
|
||||
}
|
||||
|
||||
const created = await ctx.prisma.mentorChangeRequest.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
targetAssignmentId: input.targetAssignmentId ?? null,
|
||||
requestedByUserId: ctx.user.id,
|
||||
reason: input.reason,
|
||||
status: MentorChangeRequestStatus.PENDING,
|
||||
},
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
})
|
||||
|
||||
// Notify admins (best-effort, never throw)
|
||||
try {
|
||||
const admins = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ roles: { has: 'SUPER_ADMIN' } },
|
||||
{ roles: { has: 'PROGRAM_ADMIN' } },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { email: true },
|
||||
})
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
|
||||
await sendMentorChangeRequestEmail(
|
||||
admins.map((a) => a.email),
|
||||
project.title,
|
||||
ctx.user.name ?? null,
|
||||
input.reason,
|
||||
adminDashboardUrl,
|
||||
)
|
||||
} catch (err) {
|
||||
// Defense-in-depth: the helper already has its own try/catch
|
||||
console.error('[mentor.requestChange] notify admins failed:', err)
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_CHANGE_REQUEST_CREATE',
|
||||
entityType: 'MentorChangeRequest',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
targetAssignmentId: input.targetAssignmentId ?? null,
|
||||
reasonLength: input.reason.length,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin inbox — list MentorChangeRequest rows, optionally filtered by status
|
||||
* and/or project. PENDING rows are surfaced first; within each status group
|
||||
* rows are ordered by createdAt desc. No pagination (low-volume admin view).
|
||||
*/
|
||||
listChangeRequests: adminProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
status: z.nativeEnum(MentorChangeRequestStatus).optional(),
|
||||
projectId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Prisma.MentorChangeRequestWhereInput = {}
|
||||
if (input?.status) where.status = input.status
|
||||
if (input?.projectId) where.projectId = input.projectId
|
||||
|
||||
const rows = await ctx.prisma.mentorChangeRequest.findMany({
|
||||
where,
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
targetAssignment: {
|
||||
select: {
|
||||
id: true,
|
||||
mentor: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
resolvedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
// PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
|
||||
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
// Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
|
||||
// is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
|
||||
// appears first regardless of enum string ordering.
|
||||
const statusRank: Record<MentorChangeRequestStatus, number> = {
|
||||
[MentorChangeRequestStatus.PENDING]: 0,
|
||||
[MentorChangeRequestStatus.RESOLVED]: 1,
|
||||
[MentorChangeRequestStatus.DISMISSED]: 2,
|
||||
}
|
||||
return rows.sort((a, b) => {
|
||||
const sa = statusRank[a.status] - statusRank[b.status]
|
||||
if (sa !== 0) return sa
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
|
||||
* is rejected. No email or notification is sent to the requester or mentors
|
||||
* (per PR8 design decision — mentors are never informed of change requests).
|
||||
*/
|
||||
resolveChangeRequest: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(['RESOLVED', 'DISMISSED']),
|
||||
resolutionNote: z.string().max(2000).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.mentorChangeRequest.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { id: true, status: true, projectId: true },
|
||||
})
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
|
||||
}
|
||||
if (existing.status !== MentorChangeRequestStatus.PENDING) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Request already resolved',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.mentorChangeRequest.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: input.status as MentorChangeRequestStatus,
|
||||
resolvedByUserId: ctx.user.id,
|
||||
resolvedAt: new Date(),
|
||||
resolutionNote: input.resolutionNote ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
|
||||
entityType: 'MentorChangeRequest',
|
||||
entityId: existing.id,
|
||||
detailsJson: {
|
||||
status: input.status,
|
||||
projectId: existing.projectId,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -188,7 +188,7 @@ export const projectRouter = router({
|
||||
orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
|
||||
}
|
||||
if (userHasRole(ctx.user, 'MENTOR')) {
|
||||
orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } })
|
||||
orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } })
|
||||
}
|
||||
if (userHasRole(ctx.user, 'APPLICANT')) {
|
||||
orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
|
||||
@@ -511,7 +511,7 @@ export const projectRouter = router({
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
mentorAssignment: {
|
||||
mentorAssignments: {
|
||||
include: {
|
||||
mentor: {
|
||||
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
||||
@@ -585,14 +585,18 @@ export const projectRouter = router({
|
||||
}))
|
||||
)
|
||||
|
||||
const mentorWithAvatar = project.mentorAssignment
|
||||
// TODO(PR8 Task 8): surface all mentors. For now we keep the legacy
|
||||
// single-mentor shape and just pick the first non-dropped assignment
|
||||
// so the admin UI keeps rendering without changes.
|
||||
const primaryAssignment = project.mentorAssignments[0] ?? null
|
||||
const mentorWithAvatar = primaryAssignment
|
||||
? {
|
||||
...project.mentorAssignment,
|
||||
...primaryAssignment,
|
||||
mentor: {
|
||||
...project.mentorAssignment.mentor,
|
||||
...primaryAssignment.mentor,
|
||||
avatarUrl: await getUserAvatarUrl(
|
||||
project.mentorAssignment.mentor.profileImageKey,
|
||||
project.mentorAssignment.mentor.profileImageProvider
|
||||
primaryAssignment.mentor.profileImageKey,
|
||||
primaryAssignment.mentor.profileImageProvider
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -1311,7 +1315,7 @@ export const projectRouter = router({
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
mentorAssignment: {
|
||||
mentorAssignments: {
|
||||
include: {
|
||||
mentor: {
|
||||
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
||||
@@ -1448,18 +1452,21 @@ export const projectRouter = router({
|
||||
}
|
||||
})
|
||||
),
|
||||
projectRaw.mentorAssignment
|
||||
? (async () => ({
|
||||
...projectRaw.mentorAssignment!,
|
||||
// TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first.
|
||||
(async () => {
|
||||
const primaryMa = projectRaw.mentorAssignments[0] ?? null
|
||||
if (!primaryMa) return null
|
||||
return {
|
||||
...primaryMa,
|
||||
mentor: {
|
||||
...projectRaw.mentorAssignment!.mentor,
|
||||
...primaryMa.mentor,
|
||||
avatarUrl: await getUserAvatarUrl(
|
||||
projectRaw.mentorAssignment!.mentor.profileImageKey,
|
||||
projectRaw.mentorAssignment!.mentor.profileImageProvider
|
||||
primaryMa.mentor.profileImageKey,
|
||||
primaryMa.mentor.profileImageProvider
|
||||
),
|
||||
},
|
||||
}))()
|
||||
: Promise.resolve(null),
|
||||
}
|
||||
})(),
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -236,7 +236,7 @@ export const roundRouter = router({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: {
|
||||
mentorAssignment: null,
|
||||
mentorAssignments: { none: {} },
|
||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -152,6 +152,11 @@ export async function markRead(
|
||||
|
||||
/**
|
||||
* Record a file upload in a workspace.
|
||||
*
|
||||
* `workspaceId` is the originating MentorAssignment id (kept on the row as an
|
||||
* audit-trail FK). We derive the project id from that assignment so the file
|
||||
* is bound to the project — meaning any co-mentor on the project can see/use
|
||||
* it, and the row survives if this particular assignment is later dropped.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
params: {
|
||||
@@ -180,6 +185,7 @@ export async function uploadFile(
|
||||
|
||||
return prisma.mentorFile.create({
|
||||
data: {
|
||||
projectId: assignment.projectId,
|
||||
mentorAssignmentId: params.workspaceId,
|
||||
uploadedByUserId: params.uploadedByUserId,
|
||||
fileName: params.fileName,
|
||||
@@ -238,9 +244,6 @@ export async function promoteFile(
|
||||
try {
|
||||
const file = await prisma.mentorFile.findUnique({
|
||||
where: { id: params.mentorFileId },
|
||||
include: {
|
||||
mentorAssignment: { select: { projectId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
@@ -265,7 +268,7 @@ export async function promoteFile(
|
||||
// Create promotion event
|
||||
await tx.submissionPromotionEvent.create({
|
||||
data: {
|
||||
projectId: file.mentorAssignment.projectId,
|
||||
projectId: file.projectId,
|
||||
roundId: params.roundId,
|
||||
slotKey: params.slotKey,
|
||||
sourceType: 'MENTOR_FILE',
|
||||
@@ -281,7 +284,7 @@ export async function promoteFile(
|
||||
entityId: params.mentorFileId,
|
||||
actorId: params.promotedById,
|
||||
detailsJson: {
|
||||
projectId: file.mentorAssignment.projectId,
|
||||
projectId: file.projectId,
|
||||
roundId: params.roundId,
|
||||
slotKey: params.slotKey,
|
||||
fileName: file.fileName,
|
||||
@@ -297,7 +300,7 @@ export async function promoteFile(
|
||||
entityType: 'MentorFile',
|
||||
entityId: params.mentorFileId,
|
||||
detailsJson: {
|
||||
projectId: file.mentorAssignment.projectId,
|
||||
projectId: file.projectId,
|
||||
slotKey: params.slotKey,
|
||||
},
|
||||
})
|
||||
@@ -314,14 +317,17 @@ export async function promoteFile(
|
||||
}
|
||||
|
||||
/**
|
||||
* List files for a workspace, newest first, with comment counts and uploader.
|
||||
* List files for a project, newest first, with comment counts and uploader.
|
||||
* Project-scoped: every mentor assigned to the project (and every team member)
|
||||
* sees the same file list, even if some files were uploaded under a now-dropped
|
||||
* assignment.
|
||||
*/
|
||||
export async function getFiles(
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
prisma: PrismaClient,
|
||||
) {
|
||||
return prisma.mentorFile.findMany({
|
||||
where: { mentorAssignmentId: workspaceId },
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
uploadedBy: { select: { id: true, name: true, email: true } },
|
||||
@@ -331,8 +337,10 @@ export async function getFiles(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file. Caller must be either the uploader OR the assigned mentor.
|
||||
* Removes the MinIO object and the DB row + cascade-deletes comments.
|
||||
* Delete a file. Caller must be either the uploader, OR any mentor currently
|
||||
* assigned (not dropped) to the file's project, OR a team member of the
|
||||
* file's project. Removes the MinIO object and the DB row + cascade-deletes
|
||||
* comments.
|
||||
*/
|
||||
export async function deleteFile(
|
||||
params: { mentorFileId: string; userId: string },
|
||||
@@ -341,13 +349,30 @@ export async function deleteFile(
|
||||
): Promise<void> {
|
||||
const file = await prisma.mentorFile.findUnique({
|
||||
where: { id: params.mentorFileId },
|
||||
include: { mentorAssignment: { select: { mentorId: true } } },
|
||||
})
|
||||
if (!file) throw new Error('File not found')
|
||||
const isUploader = file.uploadedByUserId === params.userId
|
||||
const isMentor = file.mentorAssignment.mentorId === params.userId
|
||||
if (!isUploader && !isMentor) {
|
||||
throw new Error('Only the uploader or the assigned mentor can delete this file')
|
||||
let isAuthorized = isUploader
|
||||
if (!isAuthorized) {
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: file.projectId, mentorId: params.userId, droppedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (mentorAssignment) {
|
||||
isAuthorized = true
|
||||
}
|
||||
}
|
||||
if (!isAuthorized) {
|
||||
const teamMembership = await prisma.teamMember.findFirst({
|
||||
where: { projectId: file.projectId, userId: params.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (teamMembership) {
|
||||
isAuthorized = true
|
||||
}
|
||||
}
|
||||
if (!isAuthorized) {
|
||||
throw new Error('Only the uploader, an assigned mentor, or a team member can delete this file')
|
||||
}
|
||||
try {
|
||||
await removeStorageObject(file.bucket, file.objectKey)
|
||||
|
||||
@@ -670,7 +670,7 @@ export async function getMentorSuggestionsForProject(
|
||||
projectTags: {
|
||||
include: { tag: true },
|
||||
},
|
||||
mentorAssignment: true,
|
||||
mentorAssignments: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -714,7 +714,7 @@ export async function getMentorSuggestionsForProject(
|
||||
|
||||
for (const mentor of mentors) {
|
||||
// Skip if already assigned to this project
|
||||
if (project.mentorAssignment?.mentorId === mentor.id) {
|
||||
if (project.mentorAssignments.some((ma) => ma.mentorId === mentor.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
138
tests/integration/mentor-file-scope.test.ts
Normal file
138
tests/integration/mentor-file-scope.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* PR8 — MentorFile schema invariant check
|
||||
*
|
||||
* The actual data migration (backfill of MentorFile.projectId from the
|
||||
* originating MentorAssignment.projectId) was verified against the May 7
|
||||
* production database dump in Task 2 of PR8. This file is a complementary
|
||||
* schema-invariant check that runs against the current dev DB:
|
||||
*
|
||||
* 1. MentorFile.projectId is now a required column (Prisma validation fails
|
||||
* when omitted).
|
||||
* 2. Files are scoped to the project, not to a single MentorAssignment —
|
||||
* deleting the originating assignment leaves the file in place with
|
||||
* mentorAssignmentId set to NULL (FK SetNull) and projectId unchanged.
|
||||
* This is what enables team-wide file visibility across co-mentors.
|
||||
*/
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestProject,
|
||||
cleanupTestData,
|
||||
uid,
|
||||
} from '../helpers'
|
||||
|
||||
describe('MentorFile scope invariants (PR8 schema)', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
const mentorFileIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
if (mentorFileIds.length > 0) {
|
||||
await prisma.mentorFile.deleteMany({ where: { id: { in: mentorFileIds } } })
|
||||
}
|
||||
for (const programId of programIds) {
|
||||
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
||||
await prisma.mentorFile.deleteMany({ where: { project: { programId } } })
|
||||
await cleanupTestData(programId, [])
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}
|
||||
})
|
||||
|
||||
it('MentorFile.projectId matches MentorAssignment.projectId when created via the workspace path', async () => {
|
||||
const program = await createTestProgram({ name: `mfscope-match-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: 'Scope Match' })
|
||||
const mentor = await createTestUser('MENTOR')
|
||||
userIds.push(mentor.id)
|
||||
|
||||
const assignment = await prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
mentorId: mentor.id,
|
||||
method: 'MANUAL',
|
||||
workspaceEnabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const file = await prisma.mentorFile.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
mentorAssignmentId: assignment.id,
|
||||
uploadedByUserId: mentor.id,
|
||||
fileName: 'invariant.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1024,
|
||||
bucket: 'mopc-files',
|
||||
objectKey: `Scope_Match/mentorship/${Date.now()}-invariant.pdf`,
|
||||
},
|
||||
})
|
||||
mentorFileIds.push(file.id)
|
||||
|
||||
expect(file.projectId).toBe(assignment.projectId)
|
||||
})
|
||||
|
||||
it('creating a MentorFile without a projectId is rejected by Prisma', async () => {
|
||||
const program = await createTestProgram({ name: `mfscope-noproj-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const mentor = await createTestUser('MENTOR')
|
||||
userIds.push(mentor.id)
|
||||
|
||||
// `projectId` is required in the schema — Prisma should reject this.
|
||||
// Cast away the type for the deliberate omission.
|
||||
await expect(
|
||||
prisma.mentorFile.create({
|
||||
data: {
|
||||
uploadedByUserId: mentor.id,
|
||||
fileName: 'no-project.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 10,
|
||||
bucket: 'mopc-files',
|
||||
objectKey: 'orphan/no-project.pdf',
|
||||
} as unknown as Parameters<typeof prisma.mentorFile.create>[0]['data'],
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('dropping the originating MentorAssignment leaves the MentorFile in place (SetNull)', async () => {
|
||||
const program = await createTestProgram({ name: `mfscope-setnull-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: 'SetNull Project' })
|
||||
const mentor = await createTestUser('MENTOR')
|
||||
userIds.push(mentor.id)
|
||||
|
||||
const assignment = await prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
mentorId: mentor.id,
|
||||
method: 'MANUAL',
|
||||
workspaceEnabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const file = await prisma.mentorFile.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
mentorAssignmentId: assignment.id,
|
||||
uploadedByUserId: mentor.id,
|
||||
fileName: 'survives-drop.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 2048,
|
||||
bucket: 'mopc-files',
|
||||
objectKey: `SetNull_Project/mentorship/${Date.now()}-survives.pdf`,
|
||||
},
|
||||
})
|
||||
mentorFileIds.push(file.id)
|
||||
|
||||
await prisma.mentorAssignment.delete({ where: { id: assignment.id } })
|
||||
|
||||
const after = await prisma.mentorFile.findUnique({ where: { id: file.id } })
|
||||
expect(after).not.toBeNull()
|
||||
expect(after?.mentorAssignmentId).toBeNull()
|
||||
expect(after?.projectId).toBe(project.id)
|
||||
})
|
||||
})
|
||||
@@ -213,12 +213,12 @@ describe('mentor.autoAssignBulkForRound', () => {
|
||||
|
||||
expect(result.assigned).toBe(1)
|
||||
|
||||
const requestedAssigned = await prisma.mentorAssignment.findUnique({
|
||||
const requestedAssigned = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: projWithRequest.id },
|
||||
})
|
||||
expect(requestedAssigned).not.toBeNull()
|
||||
|
||||
const skippedNotAssigned = await prisma.mentorAssignment.findUnique({
|
||||
const skippedNotAssigned = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: projWithoutRequest.id },
|
||||
})
|
||||
expect(skippedNotAssigned).toBeNull()
|
||||
@@ -291,7 +291,7 @@ describe('mentor.autoAssignBulkForRound', () => {
|
||||
expect(result.assigned).toBe(1)
|
||||
expect(result.skipped).toBe(1)
|
||||
|
||||
const stillExisting = await prisma.mentorAssignment.findUnique({
|
||||
const stillExisting = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: projAlreadyAssigned.id },
|
||||
})
|
||||
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
|
||||
@@ -377,17 +377,17 @@ describe('mentor.autoAssignBulkForRound', () => {
|
||||
|
||||
expect(result.assigned).toBe(1)
|
||||
|
||||
const confirmedAssigned = await prisma.mentorAssignment.findUnique({
|
||||
const confirmedAssigned = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: projConfirmed.id },
|
||||
})
|
||||
expect(confirmedAssigned).not.toBeNull()
|
||||
|
||||
const pendingAssigned = await prisma.mentorAssignment.findUnique({
|
||||
const pendingAssigned = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: projPending.id },
|
||||
})
|
||||
expect(pendingAssigned).toBeNull()
|
||||
|
||||
const noConfAssigned = await prisma.mentorAssignment.findUnique({
|
||||
const noConfAssigned = await prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: projNoConfirmation.id },
|
||||
})
|
||||
expect(noConfAssigned).toBeNull()
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => {
|
||||
})
|
||||
await prisma.mentorFile.create({
|
||||
data: {
|
||||
projectId: projReqAssigned.id,
|
||||
mentorAssignmentId: a1.id,
|
||||
uploadedByUserId: mentor.id,
|
||||
fileName: 'plan.pdf',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
const samplePayload: MentorUploadPayload = {
|
||||
mentorAssignmentId: 'ma-123',
|
||||
projectId: 'proj-789',
|
||||
uploaderUserId: 'user-456',
|
||||
fileName: 'doc.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { signMentorUploadToken } from '../../src/lib/mentor-upload-token'
|
||||
|
||||
describe('mentor.workspace files end-to-end', () => {
|
||||
let programId: string
|
||||
let projectId: string
|
||||
let mentor: { id: string; email: string; role: 'MENTOR' }
|
||||
let outsider: { id: string; email: string; role: 'JURY_MEMBER' }
|
||||
let assignmentId: string
|
||||
@@ -18,6 +19,7 @@ describe('mentor.workspace files end-to-end', () => {
|
||||
const program = await createTestProgram({ name: `mentor-files-${uid()}` })
|
||||
programId = program.id
|
||||
const project = await createTestProject(programId, { title: 'Test Project' })
|
||||
projectId = project.id
|
||||
|
||||
const m = await createTestUser('MENTOR')
|
||||
userIds.push(m.id)
|
||||
@@ -79,6 +81,7 @@ describe('mentor.workspace files end-to-end', () => {
|
||||
it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => {
|
||||
const forged = signMentorUploadToken({
|
||||
mentorAssignmentId: assignmentId,
|
||||
projectId,
|
||||
uploaderUserId: 'someone-else',
|
||||
fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
|
||||
bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf',
|
||||
@@ -94,7 +97,7 @@ describe('mentor.workspace files end-to-end', () => {
|
||||
mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50,
|
||||
})
|
||||
await caller.workspaceUploadFile({ uploadToken: a.uploadToken })
|
||||
const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
|
||||
const files = await caller.workspaceGetFiles({ projectId })
|
||||
expect(files.length).toBeGreaterThanOrEqual(2)
|
||||
expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(files[1].createdAt).getTime(),
|
||||
@@ -104,7 +107,7 @@ describe('mentor.workspace files end-to-end', () => {
|
||||
it('refuses workspaceGetFiles to outsiders', async () => {
|
||||
const caller = createCaller(mentorRouter, outsider)
|
||||
await expect(
|
||||
caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
|
||||
caller.workspaceGetFiles({ projectId })
|
||||
).rejects.toThrow(/FORBIDDEN|not a member/i)
|
||||
})
|
||||
|
||||
|
||||
509
tests/unit/multi-mentor-assignment.test.ts
Normal file
509
tests/unit/multi-mentor-assignment.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* PR8 — Multi-mentor stacking + change-request procedures
|
||||
*
|
||||
* Covers the API surface added by PR8 Tasks 4 + 6:
|
||||
* - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId),
|
||||
* idempotent per-row email notification (via MentorAssignment.notificationSentAt),
|
||||
* re-assignment after drop creates a new row and re-fires the email.
|
||||
* - mentor.requestChange: auth (team-member or admin), validation, single open
|
||||
* request per (user, project), target-assignment cross-project guard.
|
||||
* - mentor.listChangeRequests: admin-only, PENDING-first ordering.
|
||||
* - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved.
|
||||
*/
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma, createCaller } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestProject,
|
||||
cleanupTestData,
|
||||
uid,
|
||||
} from '../helpers'
|
||||
import { mentorRouter } from '../../src/server/routers/mentor'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
async function createUserWithRoles(
|
||||
primaryRole: UserRole,
|
||||
rolesArray: UserRole[],
|
||||
overrides: { name?: string; expertiseTags?: string[] } = {},
|
||||
) {
|
||||
const id = uid('user')
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: `${id}@test.local`,
|
||||
name: overrides.name ?? `Test ${primaryRole}`,
|
||||
role: primaryRole,
|
||||
roles: rolesArray,
|
||||
status: 'ACTIVE',
|
||||
expertiseTags: overrides.expertiseTags ?? [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mentor.assign — stacking + per-team email idempotency', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const programId of programIds) {
|
||||
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
||||
await cleanupTestData(programId, [])
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}
|
||||
})
|
||||
|
||||
it('stacks two different mentors on the same project (both rows active)', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: 'Stacking Project' })
|
||||
|
||||
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
|
||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
|
||||
userIds.push(m1.id, m2.id)
|
||||
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
|
||||
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
|
||||
|
||||
expect(a1.id).not.toBe(a2.id)
|
||||
expect(a1.mentorId).toBe(m1.id)
|
||||
expect(a2.mentorId).toBe(m2.id)
|
||||
|
||||
const rows = await prisma.mentorAssignment.findMany({
|
||||
where: { projectId: project.id },
|
||||
orderBy: { assignedAt: 'asc' },
|
||||
})
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.every((r) => r.droppedAt === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: 'Dup Project' })
|
||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||
userIds.push(mentor.id)
|
||||
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
await caller.assign({ projectId: project.id, mentorId: mentor.id })
|
||||
await expect(
|
||||
caller.assign({ projectId: project.id, mentorId: mentor.id }),
|
||||
).rejects.toThrow(/already assigned/i)
|
||||
})
|
||||
|
||||
it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `assign-email-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
|
||||
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
|
||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||
userIds.push(mentor.id)
|
||||
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id })
|
||||
const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id })
|
||||
|
||||
// assign() returns the row before the post-write stamp; re-read for the
|
||||
// current value.
|
||||
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
|
||||
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
|
||||
|
||||
expect(row1?.notificationSentAt).not.toBeNull()
|
||||
expect(row2?.notificationSentAt).not.toBeNull()
|
||||
// Each row carries its own timestamp — they're independent.
|
||||
expect(row1?.id).not.toBe(row2?.id)
|
||||
})
|
||||
|
||||
it('stamps notificationSentAt independently for each co-mentor on the same project', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
|
||||
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
|
||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
|
||||
userIds.push(m1.id, m2.id)
|
||||
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
|
||||
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
|
||||
|
||||
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
|
||||
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
|
||||
|
||||
expect(row1?.notificationSentAt).not.toBeNull()
|
||||
expect(row2?.notificationSentAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
|
||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||
userIds.push(mentor.id)
|
||||
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
|
||||
const stamp1 = (
|
||||
await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
|
||||
)?.notificationSentAt
|
||||
expect(stamp1).not.toBeNull()
|
||||
|
||||
// Hard-delete the first row (simulates a "fully dropped → repository clean"
|
||||
// state — the unique constraint also blocks any re-assign while the row
|
||||
// exists, so the row must go away).
|
||||
await prisma.mentorAssignment.delete({ where: { id: a1.id } })
|
||||
|
||||
// Re-assign: new row, new notificationSentAt stamp.
|
||||
const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
|
||||
const stamp2 = (
|
||||
await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
|
||||
)?.notificationSentAt
|
||||
|
||||
expect(a2.id).not.toBe(a1.id)
|
||||
expect(stamp2).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const programId of programIds) {
|
||||
await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } })
|
||||
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
||||
await prisma.teamMember.deleteMany({ where: { project: { programId } } })
|
||||
await cleanupTestData(programId, [])
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds a project with a LEAD team member (applicant), an unrelated
|
||||
* non-team-member (applicant), and an admin. Returns the IDs.
|
||||
*/
|
||||
async function setupProjectWithTeam(label: string) {
|
||||
const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` })
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `${label}-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { title: `Project ${label}` })
|
||||
const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
|
||||
name: `Team ${label}`,
|
||||
})
|
||||
const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
|
||||
name: `Outsider ${label}`,
|
||||
})
|
||||
userIds.push(teamMember.id, outsider.id)
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
userId: teamMember.id,
|
||||
role: 'LEAD',
|
||||
},
|
||||
})
|
||||
return { admin, program, project, teamMember, outsider }
|
||||
}
|
||||
|
||||
it('team member can open a change request (PENDING)', async () => {
|
||||
const { project, teamMember } = await setupProjectWithTeam('rc-teamok')
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
const created = await caller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'We would like a mentor with deeper marine biology experience.',
|
||||
})
|
||||
expect(created.status).toBe('PENDING')
|
||||
|
||||
const persisted = await prisma.mentorChangeRequest.findUnique({
|
||||
where: { id: created.id },
|
||||
})
|
||||
expect(persisted?.requestedByUserId).toBe(teamMember.id)
|
||||
expect(persisted?.projectId).toBe(project.id)
|
||||
})
|
||||
|
||||
it('non-team-member non-admin is rejected with FORBIDDEN', async () => {
|
||||
const { project, outsider } = await setupProjectWithTeam('rc-outsider')
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: outsider.id,
|
||||
email: outsider.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
await expect(
|
||||
caller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'I have no business asking for this.',
|
||||
}),
|
||||
).rejects.toThrow(/FORBIDDEN|not a member/i)
|
||||
})
|
||||
|
||||
it('admin (no team membership) can open a change request', async () => {
|
||||
const { admin, project } = await setupProjectWithTeam('rc-admin')
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
const created = await caller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Admin-initiated mentor swap due to internal escalation.',
|
||||
})
|
||||
expect(created.status).toBe('PENDING')
|
||||
})
|
||||
|
||||
it('reason < 10 chars is rejected (Zod validation)', async () => {
|
||||
const { project, teamMember } = await setupProjectWithTeam('rc-short')
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
await expect(
|
||||
caller.requestChange({ projectId: project.id, reason: 'too short' }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('opening a second request while the first is still PENDING throws CONFLICT', async () => {
|
||||
const { project, teamMember } = await setupProjectWithTeam('rc-conflict')
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
await caller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'First request — still pending please review.',
|
||||
})
|
||||
await expect(
|
||||
caller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Second request while first is open.',
|
||||
}),
|
||||
).rejects.toThrow(/already.*open|CONFLICT/i)
|
||||
})
|
||||
|
||||
it('after the first request is resolved, the same user can open a new one', async () => {
|
||||
const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen')
|
||||
const teamCaller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
const adminCaller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
const first = await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'First request — please address my concerns.',
|
||||
})
|
||||
await adminCaller.resolveChangeRequest({
|
||||
id: first.id,
|
||||
status: 'RESOLVED',
|
||||
resolutionNote: 'Mentor swapped.',
|
||||
})
|
||||
|
||||
const second = await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Second request — new concern after resolution.',
|
||||
})
|
||||
expect(second.status).toBe('PENDING')
|
||||
expect(second.id).not.toBe(first.id)
|
||||
})
|
||||
|
||||
it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => {
|
||||
const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj')
|
||||
// Make a second project + mentor assignment NOT on the requester's project.
|
||||
const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` })
|
||||
programIds.push(otherProgram.id)
|
||||
const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' })
|
||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||
userIds.push(mentor.id)
|
||||
const foreignAssignment = await prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: otherProject.id,
|
||||
mentorId: mentor.id,
|
||||
method: 'MANUAL',
|
||||
assignedBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
const caller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
await expect(
|
||||
caller.requestChange({
|
||||
projectId: projectA.id,
|
||||
targetAssignmentId: foreignAssignment.id,
|
||||
reason: 'Trying to point at a foreign assignment row.',
|
||||
}),
|
||||
).rejects.toThrow(/does not belong|BAD_REQUEST/i)
|
||||
})
|
||||
|
||||
it('listChangeRequests is FORBIDDEN for applicant', async () => {
|
||||
const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden')
|
||||
const teamCaller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'A real request, but list should still be admin-only.',
|
||||
})
|
||||
await expect(teamCaller.listChangeRequests({})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => {
|
||||
const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order')
|
||||
const teamCaller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
const adminCaller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
// Create two requests: open one, resolve it; open a second one (still PENDING).
|
||||
const resolvedReq = await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Will be resolved before the second request opens.',
|
||||
})
|
||||
await adminCaller.resolveChangeRequest({
|
||||
id: resolvedReq.id,
|
||||
status: 'RESOLVED',
|
||||
})
|
||||
const pendingReq = await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Still pending — should be listed first.',
|
||||
})
|
||||
|
||||
const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{
|
||||
id: string
|
||||
status: string
|
||||
}>
|
||||
const ids = rows.map((r) => r.id)
|
||||
// PENDING must come before RESOLVED in the listing.
|
||||
expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id))
|
||||
expect(rows[0].status).toBe('PENDING')
|
||||
})
|
||||
|
||||
it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => {
|
||||
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve')
|
||||
const teamCaller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
const adminCaller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
const req = await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Please resolve this request.',
|
||||
})
|
||||
const result = await adminCaller.resolveChangeRequest({
|
||||
id: req.id,
|
||||
status: 'RESOLVED',
|
||||
resolutionNote: 'Replacement mentor assigned.',
|
||||
})
|
||||
expect(result.status).toBe('RESOLVED')
|
||||
expect(result.resolvedByUserId).toBe(admin.id)
|
||||
expect(result.resolvedAt).not.toBeNull()
|
||||
expect(result.resolutionNote).toBe('Replacement mentor assigned.')
|
||||
})
|
||||
|
||||
it('resolveChangeRequest by non-admin is FORBIDDEN', async () => {
|
||||
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid')
|
||||
const teamCaller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
const adminCaller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
const req = await adminCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Admin opens, applicant should not resolve.',
|
||||
})
|
||||
await expect(
|
||||
teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => {
|
||||
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice')
|
||||
const teamCaller = createCaller(mentorRouter, {
|
||||
id: teamMember.id,
|
||||
email: teamMember.email,
|
||||
role: 'APPLICANT',
|
||||
})
|
||||
const adminCaller = createCaller(mentorRouter, {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
role: 'SUPER_ADMIN',
|
||||
})
|
||||
const req = await teamCaller.requestChange({
|
||||
projectId: project.id,
|
||||
reason: 'Will resolve, then try to resolve again.',
|
||||
})
|
||||
await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' })
|
||||
await expect(
|
||||
adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }),
|
||||
).rejects.toThrow(/already resolved|BAD_REQUEST/i)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user