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
|
NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum PartnerVisibility {
|
enum PartnerVisibility {
|
||||||
ADMIN_ONLY
|
ADMIN_ONLY
|
||||||
JURY_VISIBLE
|
JURY_VISIBLE
|
||||||
@@ -133,7 +132,6 @@ enum PartnerType {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE ENUMS
|
// COMPETITION / ROUND ENGINE ENUMS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -171,7 +169,6 @@ enum ProjectRoundStateValue {
|
|||||||
WITHDRAWN
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum CapMode {
|
enum CapMode {
|
||||||
HARD
|
HARD
|
||||||
SOFT
|
SOFT
|
||||||
@@ -428,6 +425,10 @@ model User {
|
|||||||
// Grand-finale logistics
|
// Grand-finale logistics
|
||||||
finalistAttendances AttendingMember[]
|
finalistAttendances AttendingMember[]
|
||||||
|
|
||||||
|
// Mentor change requests
|
||||||
|
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
|
||||||
|
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -629,7 +630,9 @@ model Project {
|
|||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
mentorAssignment MentorAssignment?
|
mentorAssignments MentorAssignment[]
|
||||||
|
mentorFiles MentorFile[]
|
||||||
|
mentorChangeRequests MentorChangeRequest[]
|
||||||
filteringResults FilteringResult[]
|
filteringResults FilteringResult[]
|
||||||
awardEligibilities AwardEligibility[]
|
awardEligibilities AwardEligibility[]
|
||||||
awardVotes AwardVote[]
|
awardVotes AwardVote[]
|
||||||
@@ -1270,7 +1273,7 @@ model TeamMember {
|
|||||||
|
|
||||||
model MentorAssignment {
|
model MentorAssignment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String @unique // One mentor per project
|
projectId String // Team can have multiple mentors; uniqueness enforced via composite below
|
||||||
mentorId String // User with MENTOR role or expertise
|
mentorId String // User with MENTOR role or expertise
|
||||||
|
|
||||||
// Assignment tracking
|
// Assignment tracking
|
||||||
@@ -1278,6 +1281,9 @@ model MentorAssignment {
|
|||||||
assignedAt DateTime @default(now())
|
assignedAt DateTime @default(now())
|
||||||
assignedBy String? // Admin who assigned
|
assignedBy String? // Admin who assigned
|
||||||
|
|
||||||
|
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
|
||||||
|
notificationSentAt DateTime?
|
||||||
|
|
||||||
// AI assignment metadata
|
// AI assignment metadata
|
||||||
aiConfidenceScore Float?
|
aiConfidenceScore Float?
|
||||||
expertiseMatchScore Float?
|
expertiseMatchScore Float?
|
||||||
@@ -1304,11 +1310,47 @@ model MentorAssignment {
|
|||||||
milestoneCompletions MentorMilestoneCompletion[]
|
milestoneCompletions MentorMilestoneCompletion[]
|
||||||
messages MentorMessage[]
|
messages MentorMessage[]
|
||||||
files MentorFile[]
|
files MentorFile[]
|
||||||
|
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
|
||||||
|
|
||||||
|
@@unique([projectId, mentorId])
|
||||||
|
@@index([projectId])
|
||||||
@@index([mentorId])
|
@@index([mentorId])
|
||||||
@@index([method])
|
@@index([method])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MENTOR CHANGE REQUESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum MentorChangeRequestStatus {
|
||||||
|
PENDING
|
||||||
|
RESOLVED
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model MentorChangeRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
targetAssignmentId String? // Optional: a specific co-mentor the request is about
|
||||||
|
requestedByUserId String?
|
||||||
|
reason String @db.Text
|
||||||
|
status MentorChangeRequestStatus @default(PENDING)
|
||||||
|
resolvedByUserId String?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
resolutionNote String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
|
||||||
|
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([targetAssignmentId])
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// FILTERING ROUND SYSTEM
|
// FILTERING ROUND SYSTEM
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2449,7 +2491,8 @@ model AssignmentIntent {
|
|||||||
|
|
||||||
model MentorFile {
|
model MentorFile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
mentorAssignmentId String
|
projectId String // Primary access scope: files belong to the team
|
||||||
|
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
|
||||||
uploadedByUserId String
|
uploadedByUserId String
|
||||||
|
|
||||||
fileName String
|
fileName String
|
||||||
@@ -2468,13 +2511,15 @@ model MentorFile {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
|
||||||
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
|
||||||
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
|
||||||
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
|
||||||
comments MentorFileComment[]
|
comments MentorFileComment[]
|
||||||
promotionEvents SubmissionPromotionEvent[]
|
promotionEvents SubmissionPromotionEvent[]
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
@@index([mentorAssignmentId])
|
@@index([mentorAssignmentId])
|
||||||
@@index([uploadedByUserId])
|
@@index([uploadedByUserId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -27,15 +29,35 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
|
Inbox,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Users,
|
Users,
|
||||||
|
UserPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
|
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
|
||||||
|
const [unassignTarget, setUnassignTarget] = useState<{
|
||||||
|
assignmentId: string
|
||||||
|
mentorName: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
||||||
|
|
||||||
const { data: candidatesData, isLoading: candidatesLoading } =
|
// Already-assigned mentors (full list). Project.get spreads the underlying
|
||||||
trpc.mentor.getCandidates.useQuery(
|
// `mentorAssignments` relation so we can read it directly.
|
||||||
{ projectId },
|
const assignedMentorAssignments = useMemo(() => {
|
||||||
{ enabled: !!project && !project.mentorAssignment },
|
if (!project) return []
|
||||||
|
// The Prisma relation is included via `...project` spread; type comes
|
||||||
|
// through the tRPC client.
|
||||||
|
type Assignment = NonNullable<typeof project>['mentorAssignments'][number]
|
||||||
|
return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter(
|
||||||
|
(a) => !a.droppedAt,
|
||||||
)
|
)
|
||||||
|
}, [project])
|
||||||
|
const assignedMentorIds = useMemo(
|
||||||
|
() => new Set(assignedMentorAssignments.map((a) => a.mentorId)),
|
||||||
|
[assignedMentorAssignments],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: candidatesData, isLoading: candidatesLoading } =
|
||||||
|
trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: suggestionsData,
|
data: suggestionsData,
|
||||||
@@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
refetch: refetchSuggestions,
|
refetch: refetchSuggestions,
|
||||||
} = trpc.mentor.getSuggestions.useQuery(
|
} = trpc.mentor.getSuggestions.useQuery(
|
||||||
{ projectId, limit: 5 },
|
{ projectId, limit: 5 },
|
||||||
{ enabled: !!project && !project.mentorAssignment },
|
{ enabled: !!project },
|
||||||
)
|
)
|
||||||
|
|
||||||
const assignMutation = trpc.mentor.assign.useMutation({
|
const assignMutation = trpc.mentor.assign.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Mentor assigned')
|
toast.success('Mentor added')
|
||||||
utils.project.get.invalidate({ id: projectId })
|
utils.project.get.invalidate({ id: projectId })
|
||||||
utils.mentor.getCandidates.invalidate({ projectId })
|
utils.mentor.getCandidates.invalidate({ projectId })
|
||||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||||
@@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
utils.project.get.invalidate({ id: projectId })
|
utils.project.get.invalidate({ id: projectId })
|
||||||
utils.mentor.getCandidates.invalidate({ projectId })
|
utils.mentor.getCandidates.invalidate({ projectId })
|
||||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||||
|
setUnassignTarget(null)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
setUnassignTarget(null)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredCandidates = useMemo(() => {
|
const filteredCandidates = useMemo(() => {
|
||||||
if (!candidatesData) return []
|
if (!candidatesData) return []
|
||||||
|
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
if (!q) return candidatesData.candidates
|
if (!q) return base
|
||||||
return candidatesData.candidates.filter((c) => {
|
return base.filter((c) => {
|
||||||
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
return hay.includes(q)
|
return hay.includes(q)
|
||||||
})
|
})
|
||||||
}, [candidatesData, search])
|
}, [candidatesData, search, assignedMentorIds])
|
||||||
|
|
||||||
|
const filteredSuggestions = useMemo(() => {
|
||||||
|
if (!suggestionsData) return []
|
||||||
|
return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId))
|
||||||
|
}, [suggestionsData, assignedMentorIds])
|
||||||
|
|
||||||
if (projectLoading) return <MentorAssignmentSkeleton />
|
if (projectLoading) return <MentorAssignmentSkeleton />
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMentor = !!project.mentorAssignment
|
|
||||||
const teamSize = project.teamMembers?.length ?? 0
|
const teamSize = project.teamMembers?.length ?? 0
|
||||||
const aiSource = suggestionsData?.source ?? 'ai'
|
const aiSource = suggestionsData?.source ?? 'ai'
|
||||||
|
|
||||||
@@ -206,80 +254,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ─── Pending Change Requests ─── */}
|
||||||
|
<PendingChangeRequestsPanel projectId={projectId} />
|
||||||
|
|
||||||
{/* ─── Currently Assigned ─── */}
|
{/* ─── Currently Assigned ─── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Currently Assigned</CardTitle>
|
<CardTitle className="text-lg">Currently Assigned</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{assignedMentorAssignments.length === 0
|
||||||
|
? 'No mentors assigned yet'
|
||||||
|
: `${assignedMentorAssignments.length} mentor${
|
||||||
|
assignedMentorAssignments.length === 1 ? '' : 's'
|
||||||
|
} on this team`}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{hasMentor ? (
|
{assignedMentorAssignments.length === 0 ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="rounded-md border border-dashed py-8 text-center">
|
||||||
<div className="flex items-center gap-4">
|
<Users className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No mentors assigned yet — add one below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{assignedMentorAssignments.map((a) => {
|
||||||
|
const m = a.mentor
|
||||||
|
const tags = m.expertiseTags ?? []
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-start gap-4">
|
||||||
<Avatar className="h-12 w-12">
|
<Avatar className="h-12 w-12">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{getInitials(
|
{getInitials(m.name || m.email)}
|
||||||
project.mentorAssignment!.mentor.name ||
|
|
||||||
project.mentorAssignment!.mentor.email,
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
|
href={`/admin/mentors/${m.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{project.mentorAssignment!.mentor.name || 'Unnamed'}
|
{m.name || 'Unnamed'}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">{m.email}</p>
|
||||||
{project.mentorAssignment!.mentor.email}
|
{tags.length > 0 && (
|
||||||
</p>
|
|
||||||
{project.mentorAssignment!.mentor.expertiseTags &&
|
|
||||||
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{project.mentorAssignment!.mentor.expertiseTags
|
{tags.slice(0, 5).map((tag: string) => (
|
||||||
.slice(0, 5)
|
|
||||||
.map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-xs">
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{tags.length > 5 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{tags.length - 5}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
Assigned{' '}
|
||||||
|
{new Date(a.assignedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
{a.method.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => unassignMutation.mutate({ projectId })}
|
onClick={() =>
|
||||||
|
setUnassignTarget({
|
||||||
|
assignmentId: a.id,
|
||||||
|
mentorName: m.name || m.email,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={unassignMutation.isPending}
|
disabled={unassignMutation.isPending}
|
||||||
>
|
>
|
||||||
{unassignMutation.isPending ? (
|
Unassign
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
'Unassign'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
) : (
|
)
|
||||||
<p className="text-muted-foreground text-sm">
|
})}
|
||||||
No mentor assigned yet — pick one below.
|
</ul>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ─── Pick a Mentor ─── */}
|
{/* ─── Add a Mentor ─── */}
|
||||||
{!hasMentor && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Pick a Mentor</CardTitle>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Add a Mentor
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Browse all eligible mentors or use AI to surface the best fits.
|
Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -311,7 +391,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : filteredCandidates.length === 0 ? (
|
) : filteredCandidates.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
No matching mentors. Try a different search.
|
{assignedMentorIds.size > 0 && search.trim() === ''
|
||||||
|
? 'All eligible mentors are already assigned.'
|
||||||
|
: 'No matching mentors. Try a different search.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-md border">
|
<div className="overflow-hidden rounded-md border">
|
||||||
@@ -376,7 +458,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -422,13 +504,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<Skeleton key={i} className="h-24 w-full" />
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
|
) : filteredSuggestions.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||||
No suggestions available.
|
{assignedMentorIds.size > 0
|
||||||
|
? 'All top suggestions are already assigned.'
|
||||||
|
: 'No suggestions available.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{suggestionsData.suggestions.map((s, i) => (
|
{filteredSuggestions.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={s.mentorId}
|
key={s.mentorId}
|
||||||
className="flex items-start justify-between rounded-md border p-4"
|
className="flex items-start justify-between rounded-md border p-4"
|
||||||
@@ -503,7 +587,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -515,8 +599,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ─── Unassign confirm ─── */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!unassignTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setUnassignTarget(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Unassign mentor?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{unassignTarget
|
||||||
|
? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.`
|
||||||
|
: ''}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={unassignMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!unassignTarget) return
|
||||||
|
unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId })
|
||||||
|
}}
|
||||||
|
disabled={unassignMutation.isPending}
|
||||||
|
>
|
||||||
|
{unassignMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Unassign'
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Pending Change Requests panel
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PendingChangeRequestsPanel({ projectId }: { projectId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({
|
||||||
|
projectId,
|
||||||
|
status: 'PENDING',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [resolveTarget, setResolveTarget] = useState<{
|
||||||
|
id: string
|
||||||
|
status: 'RESOLVED' | 'DISMISSED'
|
||||||
|
requesterName: string
|
||||||
|
} | null>(null)
|
||||||
|
const [resolutionNote, setResolutionNote] = useState('')
|
||||||
|
|
||||||
|
const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
toast.success(
|
||||||
|
`Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`,
|
||||||
|
)
|
||||||
|
utils.mentor.listChangeRequests.invalidate()
|
||||||
|
setResolveTarget(null)
|
||||||
|
setResolutionNote('')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Inbox className="h-5 w-5" />
|
||||||
|
Pending change requests
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requests || requests.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="border-amber-300 dark:border-amber-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Inbox className="h-5 w-5 text-amber-600" />
|
||||||
|
Pending change requests
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{requests.length}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Team members or mentors have asked admin to change a mentor on this team.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{requests.map((r) => (
|
||||||
|
<ChangeRequestRow
|
||||||
|
key={r.id}
|
||||||
|
request={r}
|
||||||
|
onResolve={(status) =>
|
||||||
|
setResolveTarget({
|
||||||
|
id: r.id,
|
||||||
|
status,
|
||||||
|
requesterName:
|
||||||
|
r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!resolveTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setResolveTarget(null)
|
||||||
|
setResolutionNote('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{resolveTarget?.status === 'RESOLVED'
|
||||||
|
? 'Mark request resolved'
|
||||||
|
: 'Dismiss request'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{resolveTarget?.status === 'RESOLVED'
|
||||||
|
? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.`
|
||||||
|
: `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="resolution-note">Resolution note (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="resolution-note"
|
||||||
|
value={resolutionNote}
|
||||||
|
onChange={(e) => setResolutionNote(e.target.value)}
|
||||||
|
placeholder="e.g. Replaced Jane with John based on expertise mismatch."
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setResolveTarget(null)
|
||||||
|
setResolutionNote('')
|
||||||
|
}}
|
||||||
|
disabled={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!resolveTarget) return
|
||||||
|
resolveMutation.mutate({
|
||||||
|
id: resolveTarget.id,
|
||||||
|
status: resolveTarget.status,
|
||||||
|
resolutionNote: resolutionNote.trim() || undefined,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
{resolveMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : resolveTarget?.status === 'RESOLVED' ? (
|
||||||
|
'Mark Resolved'
|
||||||
|
) : (
|
||||||
|
'Dismiss'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeRequestRowProps = {
|
||||||
|
request: {
|
||||||
|
id: string
|
||||||
|
reason: string
|
||||||
|
createdAt: Date
|
||||||
|
requestedBy: { id: string; name: string | null; email: string } | null
|
||||||
|
targetAssignment: {
|
||||||
|
id: string
|
||||||
|
mentor: { id: string; name: string | null; email: string }
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
onResolve: (status: 'RESOLVED' | 'DISMISSED') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeRequestRow({ request, onResolve }: ChangeRequestRowProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const reasonIsLong = request.reason.length > 240
|
||||||
|
return (
|
||||||
|
<li className="rounded-md border bg-card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{request.requestedBy?.name ?? request.requestedBy?.email ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
{request.requestedBy?.email && request.requestedBy.name && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{request.requestedBy.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
·{' '}
|
||||||
|
{new Date(request.createdAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{request.targetAssignment && (
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
About:{' '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{request.targetAssignment.mentor.name ||
|
||||||
|
request.targetAssignment.mentor.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
expanded || !reasonIsLong
|
||||||
|
? 'text-sm whitespace-pre-wrap'
|
||||||
|
: 'text-sm whitespace-pre-wrap line-clamp-4'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{request.reason}
|
||||||
|
</p>
|
||||||
|
{reasonIsLong && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary text-xs hover:underline"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? 'Show less' : 'Show more'}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col gap-2">
|
||||||
|
<Button size="sm" onClick={() => onResolve('RESOLVED')}>
|
||||||
|
Mark Resolved
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onResolve('DISMISSED')}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -9,13 +11,17 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { RequestChangeDialog } from './request-change-dialog'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
FileText,
|
FileText,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export default function ApplicantMentorPage() {
|
export default function ApplicantMentorPage() {
|
||||||
@@ -41,6 +47,8 @@ export default function ApplicantMentorPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||||
|
|
||||||
if (dashLoading) {
|
if (dashLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -72,7 +80,20 @@ export default function ApplicantMentorPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentor = dashboardData?.project?.mentorAssignment?.mentor
|
const assignments = dashboardData?.project?.mentorAssignments ?? []
|
||||||
|
const hasMentors = assignments.length > 0
|
||||||
|
const primaryAssignment = assignments[0] ?? null
|
||||||
|
const primaryMentor = primaryAssignment?.mentor
|
||||||
|
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
|
||||||
|
|
||||||
|
const dialogMentors = assignments
|
||||||
|
.filter((a) => !!a.mentor)
|
||||||
|
.map((a) => ({
|
||||||
|
assignmentId: a.id,
|
||||||
|
name: a.mentor?.name || a.mentor?.email || 'Mentor',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -83,23 +104,72 @@ export default function ApplicantMentorPage() {
|
|||||||
Mentor Communication
|
Mentor Communication
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Chat with your assigned mentor
|
{assignments.length > 1
|
||||||
|
? 'Chat with your assigned mentor team'
|
||||||
|
: 'Chat with your assigned mentor'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info */}
|
{/* Mentor list */}
|
||||||
{mentor ? (
|
{hasMentors ? (
|
||||||
<Card className="bg-muted/50">
|
<section className="space-y-3">
|
||||||
<CardContent className="p-4">
|
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
{assignments.map((assignment) => {
|
||||||
<div>
|
const mentor = assignment.mentor
|
||||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
if (!mentor) return null
|
||||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
const expertise = mentor.expertiseTags ?? []
|
||||||
|
return (
|
||||||
|
<Card key={assignment.id} className="bg-muted/50">
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">
|
||||||
|
{mentor.name || 'Mentor'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{mentor.email}
|
||||||
|
</p>
|
||||||
|
{assignment.assignedAt && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{expertise.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{expertise.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="font-normal">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request change action */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{hasPendingChangeRequest
|
||||||
|
? "You have a pending mentor change request — admins will follow up soon."
|
||||||
|
: 'Need a different match? Let the program admins know.'}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsChangeOpen(true)}
|
||||||
|
disabled={hasPendingChangeRequest}
|
||||||
|
>
|
||||||
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-muted/50">
|
<Card className="bg-muted/50">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||||
@@ -113,12 +183,14 @@ export default function ApplicantMentorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
{mentor && (
|
{primaryMentor && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Messages</CardTitle>
|
<CardTitle>Messages</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your conversation history with {mentor.name || 'your mentor'}
|
{assignments.length > 1
|
||||||
|
? 'Your conversation history with your mentor team'
|
||||||
|
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -136,12 +208,23 @@ export default function ApplicantMentorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Files */}
|
{/* Files */}
|
||||||
{dashboardData?.project?.mentorAssignment?.id && (
|
{primaryAssignment?.id && projectId && (
|
||||||
<WorkspaceFilesPanel
|
<WorkspaceFilesPanel
|
||||||
mentorAssignmentId={dashboardData.project.mentorAssignment.id}
|
projectId={projectId}
|
||||||
|
mentorAssignmentId={primaryAssignment.id}
|
||||||
asApplicant
|
asApplicant
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Request change dialog */}
|
||||||
|
{projectId && (
|
||||||
|
<RequestChangeDialog
|
||||||
|
projectId={projectId}
|
||||||
|
mentors={dialogMentors}
|
||||||
|
open={isChangeOpen}
|
||||||
|
onOpenChange={setIsChangeOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info */}
|
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
|
||||||
{project.mentorAssignment?.mentor && (
|
{project.mentorAssignments?.[0]?.mentor && (
|
||||||
<div className="rounded-lg border p-3 bg-muted/50">
|
<div className="rounded-lg border p-3 bg-muted/50">
|
||||||
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
{project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
|
||||||
|
// to keep tracking + chat working unchanged.
|
||||||
|
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
|
||||||
|
|
||||||
// Track view when project loads
|
// Track view when project loads
|
||||||
const trackView = trpc.mentor.trackView.useMutation()
|
const trackView = trpc.mentor.trackView.useMutation()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project?.mentorAssignment?.id) {
|
if (primaryAssignment?.id) {
|
||||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [project?.mentorAssignment?.id])
|
}, [primaryAssignment?.id])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
@@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||||
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
||||||
const mentorAssignment = project.mentorAssignment
|
const mentorAssignment = primaryAssignment
|
||||||
const mentorAssignmentId = mentorAssignment?.id
|
const mentorAssignmentId = mentorAssignment?.id
|
||||||
const programId = project.program?.id
|
const programId = project.program?.id
|
||||||
const viewerIsAssignedMentor =
|
const viewerIsAssignedMentor =
|
||||||
@@ -477,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<MentorChat
|
<MentorChat
|
||||||
messages={mentorMessages || []}
|
messages={mentorMessages || []}
|
||||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
currentUserId={primaryAssignment?.mentor?.id || ''}
|
||||||
onSendMessage={async (message) => {
|
onSendMessage={async (message) => {
|
||||||
await sendMessage.mutateAsync({ projectId, message })
|
await sendMessage.mutateAsync({ projectId, message })
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function MentorWorkspaceDetailPage() {
|
export default function MentorWorkspaceDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { data: session } = useSession()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
// Get mentor assignment for this project
|
// Get mentor assignment for this project
|
||||||
@@ -27,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
{ enabled: !!projectId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
|
||||||
|
// Gracefully tolerates stale tabs where the caller no longer has access
|
||||||
|
// (assignment dropped) — query just returns nothing in that case.
|
||||||
|
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
|
||||||
|
{ projectId },
|
||||||
|
{ enabled: !!projectId, retry: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentUserId = session?.user?.id
|
||||||
|
const coMentors = (projectMentors ?? []).filter(
|
||||||
|
a => a.mentor.id !== currentUserId
|
||||||
|
)
|
||||||
|
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
|
||||||
|
const visibleCoMentors = coMentorNames.slice(0, 3)
|
||||||
|
const hiddenCoMentors = coMentorNames.slice(3)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -70,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
{project.teamName && (
|
{project.teamName && (
|
||||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||||
)}
|
)}
|
||||||
|
{coMentors.length > 0 && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
You + {coMentors.length} co-mentor
|
||||||
|
{coMentors.length === 1 ? '' : 's'}:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{visibleCoMentors.join(', ')}
|
||||||
|
</span>
|
||||||
|
{hiddenCoMentors.length > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-help underline decoration-dotted underline-offset-2">
|
||||||
|
+{hiddenCoMentors.length} more
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-xs">
|
||||||
|
{hiddenCoMentors.join(', ')}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +159,10 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
|
|
||||||
<TabsContent value="files" className="mt-6">
|
<TabsContent value="files" className="mt-6">
|
||||||
{assignment ? (
|
{assignment ? (
|
||||||
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
|
<WorkspaceFilesPanel
|
||||||
|
projectId={projectId}
|
||||||
|
mentorAssignmentId={assignment.id}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
@@ -117,7 +175,7 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
|
|
||||||
<TabsContent value="promotion" className="mt-6">
|
<TabsContent value="promotion" className="mt-6">
|
||||||
{assignment ? (
|
{assignment ? (
|
||||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
<FilePromotionPanel projectId={projectId} />
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
|
Inbox,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Target,
|
Target,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
|||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||||
|
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
|
||||||
|
{ status: 'PENDING' },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
if (statsLoading || poolLoading) {
|
if (statsLoading || poolLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
|||||||
}
|
}
|
||||||
if (!stats || !pool) return null
|
if (!stats || !pool) return null
|
||||||
|
|
||||||
|
const pendingCount = pendingChangeRequests?.length ?? 0
|
||||||
|
// If there's at least one pending request, deep-link directly into the
|
||||||
|
// first one's project (admins can resolve / view siblings from there).
|
||||||
|
// Otherwise the card stays static.
|
||||||
|
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
|
||||||
|
const changeRequestsHref = firstPendingProjectId
|
||||||
|
? `/admin/projects/${firstPendingProjectId}/mentor`
|
||||||
|
: null
|
||||||
|
|
||||||
const requestedPct = stats.totalProjects
|
const requestedPct = stats.totalProjects
|
||||||
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
||||||
: 0
|
: 0
|
||||||
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={`md:col-span-2 xl:col-span-4 ${
|
||||||
|
pendingCount > 0 ? 'border-amber-300 dark:border-amber-700' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center justify-between py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Inbox
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Pending change requests</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Team members asking admin to swap a mentor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
|
||||||
|
{changeRequestsHref ? (
|
||||||
|
<Link
|
||||||
|
href={changeRequestsHref}
|
||||||
|
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
<ArrowRight className="ml-0.5 h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">All clear</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="md:col-span-2 xl:col-span-4">
|
<Card className="md:col-span-2 xl:col-span-4">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface FilePromotionPanelProps {
|
interface FilePromotionPanelProps {
|
||||||
mentorAssignmentId: string
|
projectId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -28,14 +28,14 @@ function formatFileSize(bytes: number): string {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) {
|
export function FilePromotionPanel({ projectId }: FilePromotionPanelProps) {
|
||||||
const [selectedSlot, setSelectedSlot] = useState<string>('')
|
const [selectedSlot, setSelectedSlot] = useState<string>('')
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const { data: workspaceFiles = [], isLoading: filesLoading } =
|
const { data: workspaceFiles = [], isLoading: filesLoading } =
|
||||||
trpc.mentor.workspaceGetFiles.useQuery(
|
trpc.mentor.workspaceGetFiles.useQuery(
|
||||||
{ mentorAssignmentId },
|
{ projectId },
|
||||||
{ enabled: !!mentorAssignmentId },
|
{ enabled: !!projectId },
|
||||||
)
|
)
|
||||||
|
|
||||||
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({
|
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({
|
||||||
|
|||||||
@@ -12,10 +12,18 @@ import {
|
|||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react'
|
import { Eye, FileText, Upload, Download, Trash2, MessageSquare, X, Loader2 } from 'lucide-react'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** Project the workspace belongs to — drives file list (project-scoped). */
|
||||||
|
projectId: string
|
||||||
|
/**
|
||||||
|
* One MentorAssignment id on this project — needed only to mint upload tokens
|
||||||
|
* (the token is signed against the assignment + project pair, but the file
|
||||||
|
* itself is project-scoped so co-mentors see it).
|
||||||
|
*/
|
||||||
mentorAssignmentId: string
|
mentorAssignmentId: string
|
||||||
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
|
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
|
||||||
asApplicant?: boolean
|
asApplicant?: boolean
|
||||||
@@ -29,21 +37,21 @@ function formatSize(bytes: number): string {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) {
|
export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) {
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
|
||||||
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
|
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
|
||||||
{ mentorAssignmentId },
|
{ projectId },
|
||||||
{ enabled: !!mentorAssignmentId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
|
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
|
||||||
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
|
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
utils.mentor.workspaceGetFiles.invalidate({ projectId })
|
||||||
setDescription('')
|
setDescription('')
|
||||||
toast.success('File uploaded')
|
toast.success('File uploaded')
|
||||||
},
|
},
|
||||||
@@ -51,7 +59,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
|||||||
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
|
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
|
||||||
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
|
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
utils.mentor.workspaceGetFiles.invalidate({ projectId })
|
||||||
toast.success('File deleted')
|
toast.success('File deleted')
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
@@ -83,10 +91,43 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [previewFileId, setPreviewFileId] = useState<string | null>(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
|
||||||
|
const canPreviewMime = (m: string, name: string) =>
|
||||||
|
m.startsWith('video/') || m === 'application/pdf' || m.startsWith('image/') || isOfficeFile(m, name)
|
||||||
|
|
||||||
|
const togglePreview = async (file: { id: string; mimeType: string; fileName: string }) => {
|
||||||
|
if (previewFileId === file.id) {
|
||||||
|
setPreviewFileId(null)
|
||||||
|
setPreviewUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreviewFileId(file.id)
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setPreviewLoading(true)
|
||||||
|
try {
|
||||||
|
const { url } = await downloadMutation.mutateAsync({ mentorFileId: file.id, disposition: 'inline' })
|
||||||
|
setPreviewUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Preview failed')
|
||||||
|
setPreviewFileId(null)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDownload = async (mentorFileId: string) => {
|
const handleDownload = async (mentorFileId: string) => {
|
||||||
try {
|
try {
|
||||||
const { url } = await downloadMutation.mutateAsync({ mentorFileId })
|
const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' })
|
||||||
window.open(url, '_blank')
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = ''
|
||||||
|
a.rel = 'noopener'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Download failed')
|
toast.error(err instanceof Error ? err.message : 'Download failed')
|
||||||
}
|
}
|
||||||
@@ -141,8 +182,12 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{(files ?? []).map((f) => (
|
{(files ?? []).map((f) => {
|
||||||
<li key={f.id} className="flex items-center gap-3 py-3">
|
const isOpen = previewFileId === f.id
|
||||||
|
const previewable = canPreviewMime(f.mimeType, f.fileName)
|
||||||
|
return (
|
||||||
|
<li key={f.id} className="py-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">{f.fileName}</div>
|
<div className="font-medium truncate">{f.fileName}</div>
|
||||||
@@ -160,7 +205,24 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
|||||||
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
|
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
|
{previewable && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => togglePreview(f)}
|
||||||
|
title={isOpen ? 'Close preview' : 'Preview'}
|
||||||
|
aria-label={isOpen ? 'Close preview' : 'Preview file'}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDownload(f.id)}
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download file"
|
||||||
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -184,8 +246,22 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="rounded-md border bg-muted/30 overflow-hidden">
|
||||||
|
{previewLoading || !previewUrl ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Loading preview…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FilePreview file={{ mimeType: f.mimeType, fileName: f.fileName }} url={previewUrl} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
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 })
|
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Per-team mentor assignment (fires every time a mentor is added to a project)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function getMentorTeamAssignmentTemplate(
|
||||||
|
name: string,
|
||||||
|
projectTitle: string,
|
||||||
|
workspaceUrl: string,
|
||||||
|
): EmailTemplate {
|
||||||
|
const subject = `You've been assigned to a new MOPC project: "${projectTitle}"`
|
||||||
|
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||||||
|
const text = [
|
||||||
|
greeting,
|
||||||
|
'',
|
||||||
|
`You have been assigned as a mentor to the project "${projectTitle}".`,
|
||||||
|
'',
|
||||||
|
'You may have co-mentors on this team — you can collaborate together in the project workspace.',
|
||||||
|
'',
|
||||||
|
`Open the workspace: ${workspaceUrl}`,
|
||||||
|
'',
|
||||||
|
'The MOPC team',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||||
|
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||||
|
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||||
|
<h1 style="margin:0;font-size:20px;font-weight:600;">New mentor assignment</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||||
|
<p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
|
||||||
|
<p>You have been assigned as a mentor to the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||||||
|
<p style="margin-top:24px;">
|
||||||
|
<a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Project Workspace</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||||
|
You may have co-mentors on this team — you can collaborate together in the project workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return { subject, text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a per-team mentor assignment email. Fires every time a mentor is added
|
||||||
|
* to a specific project (distinct from the one-time onboarding email).
|
||||||
|
* Idempotency is enforced at the call site via MentorAssignment.notificationSentAt.
|
||||||
|
* Never throws — failures are caught and logged.
|
||||||
|
*/
|
||||||
|
export async function sendMentorTeamAssignmentEmail(
|
||||||
|
email: string,
|
||||||
|
name: string | null,
|
||||||
|
projectTitle: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||||
|
const workspaceUrl = `${baseUrl.replace(/\/$/, '')}/mentor/workspace/${projectId}`
|
||||||
|
const template = getMentorTeamAssignmentTemplate(name || '', projectTitle, workspaceUrl)
|
||||||
|
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sendMentorTeamAssignmentEmail] failed', { email, projectId, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mentor change requests (PR 8) — admin notification when an applicant or admin
|
||||||
|
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function getMentorChangeRequestTemplate(
|
||||||
|
projectTitle: string,
|
||||||
|
requesterName: string | null,
|
||||||
|
reason: string,
|
||||||
|
adminDashboardUrl: string,
|
||||||
|
): EmailTemplate {
|
||||||
|
const subject = `Mentor change request for "${projectTitle}"`
|
||||||
|
const requesterLabel = requesterName || 'a team member'
|
||||||
|
const text = [
|
||||||
|
'Hi MOPC admins,',
|
||||||
|
'',
|
||||||
|
`A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`,
|
||||||
|
'',
|
||||||
|
'Reason:',
|
||||||
|
`"${reason}"`,
|
||||||
|
'',
|
||||||
|
`Review the request: ${adminDashboardUrl}`,
|
||||||
|
'',
|
||||||
|
'The MOPC team',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||||
|
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||||
|
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||||
|
<h1 style="margin:0;font-size:20px;font-weight:600;">Mentor change request</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||||
|
<p style="margin-top:0;">Hi MOPC admins,</p>
|
||||||
|
<p>A mentor change request has been opened by <strong>${escapeHtml(requesterLabel)}</strong> for the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||||||
|
<blockquote style="margin:16px 0;padding:12px 16px;background:#f1f5f9;border-left:3px solid #557f8c;border-radius:4px;color:#0f172a;font-style:italic;white-space:pre-wrap;">${escapeHtml(reason)}</blockquote>
|
||||||
|
<p style="margin-top:24px;">
|
||||||
|
<a href="${adminDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Review Request</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||||
|
Mentors are not notified of change requests; only admins see this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return { subject, text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request
|
||||||
|
* has been opened for a project. Sends one email per recipient.
|
||||||
|
* Never throws — failures are caught and logged so the calling mutation
|
||||||
|
* (mentor.requestChange) never fails because of email infrastructure issues.
|
||||||
|
*/
|
||||||
|
export async function sendMentorChangeRequestEmail(
|
||||||
|
adminEmails: string[],
|
||||||
|
projectTitle: string,
|
||||||
|
requesterName: string | null,
|
||||||
|
reason: string,
|
||||||
|
adminDashboardUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (adminEmails.length === 0) {
|
||||||
|
console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const template = getMentorChangeRequestTemplate(
|
||||||
|
projectTitle,
|
||||||
|
requesterName,
|
||||||
|
reason,
|
||||||
|
adminDashboardUrl,
|
||||||
|
)
|
||||||
|
await Promise.all(
|
||||||
|
adminEmails.map((email) =>
|
||||||
|
sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: template.subject,
|
||||||
|
text: template.text,
|
||||||
|
html: template.html,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[sendMentorChangeRequestEmail] send failed', { email, err })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sendMentorChangeRequestEmail] failed', { error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getFinalistConfirmationTemplate(
|
function getFinalistConfirmationTemplate(
|
||||||
name: string,
|
name: string,
|
||||||
projectTitle: string,
|
projectTitle: string,
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto'
|
|||||||
|
|
||||||
export type MentorUploadPayload = {
|
export type MentorUploadPayload = {
|
||||||
mentorAssignmentId: string
|
mentorAssignmentId: string
|
||||||
|
/**
|
||||||
|
* Project the upload belongs to. Bound at token-issue time so the file's
|
||||||
|
* project scope can't be tampered with separately from the assignment id.
|
||||||
|
* Required (no legacy fallback) — tokens live <1h, so any in-flight tokens
|
||||||
|
* issued before this field was added expire on their own.
|
||||||
|
*/
|
||||||
|
projectId: string
|
||||||
uploaderUserId: string
|
uploaderUserId: string
|
||||||
fileName: string
|
fileName: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
@@ -47,5 +54,8 @@ export function verifyMentorUploadToken(token: string): MentorUploadPayload {
|
|||||||
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
|
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
throw new Error('Invalid mentor upload token: expired')
|
throw new Error('Invalid mentor upload token: expired')
|
||||||
}
|
}
|
||||||
|
if (typeof payload.projectId !== 'string' || payload.projectId.length === 0) {
|
||||||
|
throw new Error('Invalid mentor upload token: missing projectId')
|
||||||
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,13 +78,17 @@ export async function getPresignedUrl(
|
|||||||
objectKey: string,
|
objectKey: string,
|
||||||
method: 'GET' | 'PUT' = 'GET',
|
method: 'GET' | 'PUT' = 'GET',
|
||||||
expirySeconds: number = 900, // 15 minutes default
|
expirySeconds: number = 900, // 15 minutes default
|
||||||
options?: { downloadFileName?: string }
|
options?: { downloadFileName?: string; inline?: boolean; contentType?: string }
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const publicClient = getPublicMinioClient()
|
const publicClient = getPublicMinioClient()
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
const respHeaders = options?.downloadFileName
|
let respHeaders: Record<string, string> | undefined
|
||||||
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
if (options?.inline) {
|
||||||
: undefined
|
respHeaders = { 'response-content-disposition': 'inline' }
|
||||||
|
if (options.contentType) respHeaders['response-content-type'] = options.contentType
|
||||||
|
} else if (options?.downloadFileName) {
|
||||||
|
respHeaders = { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
||||||
|
}
|
||||||
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
|
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
|
||||||
} else {
|
} else {
|
||||||
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)
|
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)
|
||||||
|
|||||||
@@ -1176,7 +1176,7 @@ export const applicantRouter = router({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
mentorAssignment: { select: { mentorId: true } },
|
mentorAssignments: { select: { mentorId: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1187,7 +1187,10 @@ export const applicantRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project.mentorAssignment) {
|
// TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the
|
||||||
|
// first one for legacy parity.
|
||||||
|
const primaryMentorAssignment = project.mentorAssignments[0] ?? null
|
||||||
|
if (!primaryMentorAssignment) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'No mentor assigned to this project',
|
message: 'No mentor assigned to this project',
|
||||||
@@ -1207,9 +1210,9 @@ export const applicantRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify the mentor
|
// Notify the (primary) mentor
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: project.mentorAssignment.mentorId,
|
userId: primaryMentorAssignment.mentorId,
|
||||||
type: 'MENTOR_MESSAGE',
|
type: 'MENTOR_MESSAGE',
|
||||||
title: 'New Message',
|
title: 'New Message',
|
||||||
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
|
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
|
||||||
@@ -1313,12 +1316,13 @@ export const applicantRouter = router({
|
|||||||
submittedBy: {
|
submittedBy: {
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
},
|
},
|
||||||
mentorAssignment: {
|
mentorAssignments: {
|
||||||
include: {
|
include: {
|
||||||
mentor: {
|
mentor: {
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true, expertiseTags: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
orderBy: { assignedAt: 'asc' },
|
||||||
},
|
},
|
||||||
wonAwards: {
|
wonAwards: {
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
@@ -1489,6 +1493,17 @@ export const applicantRouter = router({
|
|||||||
logoUrl = await provider.getDownloadUrl(project.logoKey)
|
logoUrl = await provider.getDownloadUrl(project.logoKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Does this user have an open mentor-change request for this project?
|
||||||
|
// (Used by the applicant mentor page to disable the "Request a change" button.)
|
||||||
|
const myPendingChangeRequest = await ctx.prisma.mentorChangeRequest.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: project.id,
|
||||||
|
requestedByUserId: ctx.user.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project: {
|
project: {
|
||||||
...project,
|
...project,
|
||||||
@@ -1502,6 +1517,7 @@ export const applicantRouter = router({
|
|||||||
hasPassedIntake: !!passedIntake,
|
hasPassedIntake: !!passedIntake,
|
||||||
isIntakeOpen: !!activeIntakeRound,
|
isIntakeOpen: !!activeIntakeRound,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
|
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -1523,7 +1539,7 @@ export const applicantRouter = router({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
programId: true,
|
programId: true,
|
||||||
mentorAssignment: { select: { id: true } },
|
mentorAssignments: { select: { id: true }, take: 1 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1531,8 +1547,8 @@ export const applicantRouter = router({
|
|||||||
return { hasMentor: false, hasEvaluationRounds: false }
|
return { hasMentor: false, hasEvaluationRounds: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if mentor is assigned
|
// Check if mentor is assigned (any active assignment counts)
|
||||||
const hasMentor = !!project.mentorAssignment
|
const hasMentor = project.mentorAssignments.length > 0
|
||||||
|
|
||||||
// Check if feedback is available — first check admin settings, then fall back to per-round config
|
// Check if feedback is available — first check admin settings, then fall back to per-round config
|
||||||
let hasEvaluationRounds = false
|
let hasEvaluationRounds = false
|
||||||
@@ -2689,8 +2705,12 @@ export const applicantRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
// TODO(PR8 Task 7): when multiple mentors are assigned, surface them all
|
||||||
|
// in the applicant message thread. For now we display the most recently
|
||||||
|
// assigned (non-dropped) mentor as the "primary".
|
||||||
|
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: input.projectId },
|
where: { projectId: input.projectId },
|
||||||
|
orderBy: { assignedAt: 'desc' },
|
||||||
include: { mentor: { select: { id: true, name: true, email: true } } },
|
include: { mentor: { select: { id: true, name: true, email: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -772,7 +772,8 @@ export const finalistRouter = router({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
mentorAssignment: {
|
mentorAssignments: {
|
||||||
|
where: { droppedAt: null, completionStatus: { not: 'completed' } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
completionStatus: true,
|
completionStatus: true,
|
||||||
@@ -796,10 +797,12 @@ export const finalistRouter = router({
|
|||||||
data: { status: 'SUPERSEDED' },
|
data: { status: 'SUPERSEDED' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cascade: drop active mentor assignment (skip if completed or already dropped)
|
// Cascade: drop ALL active mentor assignments (skip dropped/completed —
|
||||||
const ma = confirmation.project.mentorAssignment
|
// those were filtered out by the include `where` above). With multi-mentor
|
||||||
|
// (PR8) we propagate the cascade to every active assignment.
|
||||||
|
const activeAssignments = confirmation.project.mentorAssignments
|
||||||
let cascadedMentorAssignment = false
|
let cascadedMentorAssignment = false
|
||||||
if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') {
|
for (const ma of activeAssignments) {
|
||||||
await ctx.prisma.mentorAssignment.update({
|
await ctx.prisma.mentorAssignment.update({
|
||||||
where: { id: ma.id },
|
where: { id: ma.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -833,6 +836,7 @@ export const finalistRouter = router({
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
projectId: confirmation.projectId,
|
projectId: confirmation.projectId,
|
||||||
cascadedMentorAssignment,
|
cascadedMentorAssignment,
|
||||||
|
cascadedAssignmentCount: activeAssignments.length,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { ok: true, cascadedMentorAssignment }
|
return { ok: true, cascadedMentorAssignment }
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
|
import {
|
||||||
|
MentorAssignmentMethod,
|
||||||
|
MentorChangeRequestStatus,
|
||||||
|
Prisma,
|
||||||
|
type PrismaClient,
|
||||||
|
} from '@prisma/client'
|
||||||
|
import {
|
||||||
|
sendMentorChangeRequestEmail,
|
||||||
|
sendMentorTeamAssignmentEmail,
|
||||||
|
} from '@/lib/email'
|
||||||
import {
|
import {
|
||||||
getAIMentorSuggestions,
|
getAIMentorSuggestions,
|
||||||
getRoundRobinMentor,
|
getRoundRobinMentor,
|
||||||
@@ -66,6 +75,42 @@ async function assertWorkspaceAccess(
|
|||||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-scoped workspace access check (PR8 multi-mentor).
|
||||||
|
*
|
||||||
|
* Allowed when the user is either:
|
||||||
|
* 1) currently assigned as a mentor on this project (droppedAt = null), OR
|
||||||
|
* 2) a team member of the project.
|
||||||
|
*
|
||||||
|
* Also requires at least one active mentor assignment for the project with
|
||||||
|
* workspaceEnabled = true — meaning the project actually has a live workspace.
|
||||||
|
* Throws TRPCError on failure. Returns nothing on success.
|
||||||
|
*/
|
||||||
|
async function assertProjectWorkspaceAccess(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||||
|
where: { projectId, droppedAt: null, workspaceEnabled: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!liveMentorAssignment) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
|
||||||
|
}
|
||||||
|
const mentorOnProject = await prisma.mentorAssignment.findFirst({
|
||||||
|
where: { projectId, mentorId: userId, droppedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (mentorOnProject) return
|
||||||
|
const teamMembership = await prisma.teamMember.findFirst({
|
||||||
|
where: { projectId, userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (teamMembership) return
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
|
||||||
|
}
|
||||||
|
|
||||||
export const mentorRouter = router({
|
export const mentorRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get AI-suggested mentor matches for a project
|
* Get AI-suggested mentor matches for a project
|
||||||
@@ -82,18 +127,15 @@ export const mentorRouter = router({
|
|||||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
include: {
|
include: {
|
||||||
mentorAssignment: true,
|
mentorAssignments: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (project.mentorAssignment) {
|
// With multi-mentor (PR8) the project can have several mentors. The
|
||||||
return {
|
// suggestions endpoint is informational — return whatever AI suggests
|
||||||
currentMentor: project.mentorAssignment,
|
// and let `mentor.assign` enforce per-pair uniqueness. We still surface
|
||||||
suggestions: [],
|
// an existing primary mentor in the payload so UIs can label it.
|
||||||
source: 'ai' as const,
|
const primaryMentor = project.mentorAssignments[0] ?? null
|
||||||
message: 'Project already has a mentor assigned',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect AI configuration so the UI can label "AI matching unavailable"
|
// Detect AI configuration so the UI can label "AI matching unavailable"
|
||||||
// when we fall back to algorithmic ranking. An AI error mid-call still
|
// when we fall back to algorithmic ranking. An AI error mid-call still
|
||||||
@@ -140,7 +182,9 @@ export const mentorRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentMentor: null,
|
// TODO(PR8 Task 8): return the full mentor list. Legacy field kept
|
||||||
|
// until the admin UI is updated.
|
||||||
|
currentMentor: primaryMentor,
|
||||||
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
||||||
source,
|
source,
|
||||||
message: null,
|
message: null,
|
||||||
@@ -219,26 +263,24 @@ export const mentorRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Verify project exists and doesn't have a mentor
|
// Verify project exists (multi-mentor: stacking is allowed; duplicate
|
||||||
|
// (projectId, mentorId) pairs are rejected by the unique constraint
|
||||||
|
// below).
|
||||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
include: { mentorAssignment: true },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (project.mentorAssignment) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'CONFLICT',
|
|
||||||
message: 'Project already has a mentor assigned',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify mentor exists
|
// Verify mentor exists
|
||||||
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
||||||
where: { id: input.mentorId },
|
where: { id: input.mentorId },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create assignment
|
// Create assignment. P2002 on the composite (projectId, mentorId) unique
|
||||||
const assignment = await ctx.prisma.mentorAssignment.create({
|
// constraint means this exact mentor is already on this team — surface a
|
||||||
|
// friendly error.
|
||||||
|
let assignment
|
||||||
|
try {
|
||||||
|
assignment = await ctx.prisma.mentorAssignment.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
mentorId: input.mentorId,
|
mentorId: input.mentorId,
|
||||||
@@ -265,6 +307,18 @@ export const mentorRouter = router({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'This mentor is already assigned to that project.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the assignment
|
// Audit outside transaction so failures don't roll back the assignment
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -279,6 +333,8 @@ export const mentorRouter = router({
|
|||||||
mentorId: input.mentorId,
|
mentorId: input.mentorId,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
method: input.method,
|
method: input.method,
|
||||||
|
// PR8: per-team assignment (one row per mentor-project pair).
|
||||||
|
assignmentScope: 'per-team',
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
@@ -320,6 +376,27 @@ export const mentorRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Send per-team email notification once per assignment row. Idempotency
|
||||||
|
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
|
||||||
|
// it null. If the same mentor is later dropped and re-assigned (new row,
|
||||||
|
// fresh id), a new email is sent — intentional.
|
||||||
|
if (assignment.notificationSentAt == null && assignment.mentor.email) {
|
||||||
|
await sendMentorTeamAssignmentEmail(
|
||||||
|
assignment.mentor.email,
|
||||||
|
assignment.mentor.name,
|
||||||
|
assignment.project.title,
|
||||||
|
input.projectId,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await ctx.prisma.mentorAssignment.update({
|
||||||
|
where: { id: assignment.id },
|
||||||
|
data: { notificationSentAt: new Date() },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
|
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
|
||||||
try {
|
try {
|
||||||
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
|
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
|
||||||
@@ -351,13 +428,16 @@ export const mentorRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Verify project exists and doesn't have a mentor
|
// Verify project exists and doesn't already have a mentor. Multi-mentor
|
||||||
|
// stacking is reserved for explicit admin assignment via `mentor.assign`;
|
||||||
|
// auto-assignment skips projects that already have at least one mentor
|
||||||
|
// to avoid double-AI-assignments.
|
||||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
include: { mentorAssignment: true },
|
include: { mentorAssignments: { select: { id: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (project.mentorAssignment) {
|
if (project.mentorAssignments.length > 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
message: 'Project already has a mentor assigned',
|
message: 'Project already has a mentor assigned',
|
||||||
@@ -485,13 +565,35 @@ export const mentorRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove mentor assignment
|
* Remove mentor assignment.
|
||||||
|
*
|
||||||
|
* Multi-mentor (PR8): callers should pass `assignmentId` to target a
|
||||||
|
* specific co-mentor. Legacy callers passing only `projectId` get the
|
||||||
|
* most-recent assignment removed (kept for backward compatibility).
|
||||||
*/
|
*/
|
||||||
unassign: adminProcedure
|
unassign: adminProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
assignmentId: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((v) => !!v.assignmentId || !!v.projectId, {
|
||||||
|
message: 'Either assignmentId or projectId is required',
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
const assignment = input.assignmentId
|
||||||
where: { projectId: input.projectId },
|
? await ctx.prisma.mentorAssignment.findUnique({
|
||||||
|
where: { id: input.assignmentId },
|
||||||
|
include: {
|
||||||
|
mentor: { select: { id: true, name: true } },
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await ctx.prisma.mentorAssignment.findFirst({
|
||||||
|
where: { projectId: input.projectId! },
|
||||||
|
orderBy: { assignedAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
mentor: { select: { id: true, name: true } },
|
mentor: { select: { id: true, name: true } },
|
||||||
project: { select: { id: true, title: true } },
|
project: { select: { id: true, title: true } },
|
||||||
@@ -501,13 +603,13 @@ export const mentorRouter = router({
|
|||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'No mentor assignment found for this project',
|
message: 'No mentor assignment found',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete assignment
|
// Delete assignment
|
||||||
await ctx.prisma.mentorAssignment.delete({
|
await ctx.prisma.mentorAssignment.delete({
|
||||||
where: { projectId: input.projectId },
|
where: { id: assignment.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the unassignment
|
// Audit outside transaction so failures don't roll back the unassignment
|
||||||
@@ -518,7 +620,7 @@ export const mentorRouter = router({
|
|||||||
entityType: 'MentorAssignment',
|
entityType: 'MentorAssignment',
|
||||||
entityId: assignment.id,
|
entityId: assignment.id,
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
projectId: input.projectId,
|
projectId: assignment.project.id,
|
||||||
projectTitle: assignment.project.title,
|
projectTitle: assignment.project.title,
|
||||||
mentorId: assignment.mentor.id,
|
mentorId: assignment.mentor.id,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
@@ -546,7 +648,7 @@ export const mentorRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
programId: input.programId,
|
programId: input.programId,
|
||||||
mentorAssignment: null,
|
mentorAssignments: { none: {} },
|
||||||
wantsMentorship: true,
|
wantsMentorship: true,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -716,7 +818,7 @@ export const mentorRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
project: {
|
project: {
|
||||||
mentorAssignment: null,
|
mentorAssignments: { none: {} },
|
||||||
// Only assign mentors to projects whose team has confirmed they will
|
// Only assign mentors to projects whose team has confirmed they will
|
||||||
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
|
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
|
||||||
// confirmations and any project without a confirmation row at all.
|
// confirmations and any project without a confirmation row at all.
|
||||||
@@ -834,7 +936,7 @@ export const mentorRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
project: {
|
project: {
|
||||||
mentorAssignment: { isNot: null },
|
mentorAssignments: { some: {} },
|
||||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -906,13 +1008,13 @@ export const mentorRouter = router({
|
|||||||
ctx.prisma.projectRoundState.count({
|
ctx.prisma.projectRoundState.count({
|
||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
project: { wantsMentorship: true, mentorAssignment: { isNot: null } },
|
project: { wantsMentorship: true, mentorAssignments: { some: {} } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ctx.prisma.projectRoundState.count({
|
ctx.prisma.projectRoundState.count({
|
||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
project: { mentorAssignment: { isNot: null } },
|
project: { mentorAssignments: { some: {} } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ctx.prisma.mentorMessage.count({
|
ctx.prisma.mentorMessage.count({
|
||||||
@@ -1107,7 +1209,11 @@ export const mentorRouter = router({
|
|||||||
status: true,
|
status: true,
|
||||||
oceanIssue: true,
|
oceanIssue: true,
|
||||||
competitionCategory: true,
|
competitionCategory: true,
|
||||||
mentorAssignment: {
|
mentorAssignments: {
|
||||||
|
// TODO(PR8 Task 8): surface all mentors in the activity view.
|
||||||
|
// For now keep the legacy single-mentor activity row by picking the
|
||||||
|
// latest-assigned, non-dropped assignment (or the most-recent overall).
|
||||||
|
orderBy: { assignedAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
method: true,
|
method: true,
|
||||||
@@ -1157,7 +1263,10 @@ export const mentorRouter = router({
|
|||||||
|
|
||||||
const rows = projects.map((p) => {
|
const rows = projects.map((p) => {
|
||||||
// Treat a dropped mentor assignment as if no mentor is assigned.
|
// Treat a dropped mentor assignment as if no mentor is assigned.
|
||||||
const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null
|
// TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most
|
||||||
|
// recent non-dropped assignment for the activity row.
|
||||||
|
const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null
|
||||||
|
const ma = firstActive
|
||||||
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
|
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
|
||||||
const lastFileAt = ma?.files[0]?.createdAt ?? null
|
const lastFileAt = ma?.files[0]?.createdAt ?? null
|
||||||
const lastActivityAt = [lastMessageAt, lastFileAt]
|
const lastActivityAt = [lastMessageAt, lastFileAt]
|
||||||
@@ -1235,6 +1344,50 @@ export const mentorRouter = router({
|
|||||||
return assignments
|
return assignments
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all active mentors assigned to a project (PR8 multi-mentor).
|
||||||
|
*
|
||||||
|
* Returns one row per active MentorAssignment (droppedAt = null) with the
|
||||||
|
* mentor's id + name. Used by the mentor workspace page to display the
|
||||||
|
* co-mentor team so each mentor knows who else they're working with.
|
||||||
|
*
|
||||||
|
* Authorization: caller must be an active mentor on the project (or an
|
||||||
|
* admin via mentorProcedure). Non-assigned mentors get FORBIDDEN.
|
||||||
|
*/
|
||||||
|
getProjectMentors: mentorProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
mentorId: ctx.user.id,
|
||||||
|
droppedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!ownAssignment) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You are not assigned to mentor this project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = await ctx.prisma.mentorAssignment.findMany({
|
||||||
|
where: { projectId: input.projectId, droppedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
mentor: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { assignedAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return assignments
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get detailed project info for a mentor's assigned project
|
* Get detailed project info for a mentor's assigned project
|
||||||
*/
|
*/
|
||||||
@@ -1279,7 +1432,7 @@ export const mentorRouter = router({
|
|||||||
files: {
|
files: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
},
|
},
|
||||||
mentorAssignment: {
|
mentorAssignments: {
|
||||||
include: {
|
include: {
|
||||||
mentor: {
|
mentor: {
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
@@ -2080,6 +2233,7 @@ export const mentorRouter = router({
|
|||||||
const exp = Math.floor(Date.now() / 1000) + 3600
|
const exp = Math.floor(Date.now() / 1000) + 3600
|
||||||
const uploadToken = signMentorUploadToken({
|
const uploadToken = signMentorUploadToken({
|
||||||
mentorAssignmentId: assignment.id,
|
mentorAssignmentId: assignment.id,
|
||||||
|
projectId: assignment.projectId,
|
||||||
uploaderUserId: ctx.user.id,
|
uploaderUserId: ctx.user.id,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
@@ -2136,45 +2290,55 @@ export const mentorRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files in a workspace. Authorized for the assigned mentor or any
|
* List files in a project's mentor workspace. Authorized for any mentor
|
||||||
* project team member.
|
* currently assigned to the project, or any team member of the project.
|
||||||
|
*
|
||||||
|
* Project-scoped (PR8): all co-mentors share one file list, and files
|
||||||
|
* survive even when an originating assignment is later dropped.
|
||||||
*/
|
*/
|
||||||
workspaceGetFiles: protectedProcedure
|
workspaceGetFiles: protectedProcedure
|
||||||
.input(z.object({ mentorAssignmentId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
|
||||||
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
|
return workspaceGetFilesService(input.projectId, ctx.prisma)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue a short-lived presigned GET URL to download a workspace file.
|
* Issue a short-lived presigned GET URL to download a workspace file.
|
||||||
*/
|
*/
|
||||||
workspaceGetFileDownloadUrl: protectedProcedure
|
workspaceGetFileDownloadUrl: protectedProcedure
|
||||||
.input(z.object({ mentorFileId: z.string() }))
|
.input(z.object({
|
||||||
|
mentorFileId: z.string(),
|
||||||
|
disposition: z.enum(['inline', 'attachment']).default('attachment'),
|
||||||
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const file = await ctx.prisma.mentorFile.findUnique({
|
const file = await ctx.prisma.mentorFile.findUnique({
|
||||||
where: { id: input.mentorFileId },
|
where: { id: input.mentorFileId },
|
||||||
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
|
select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
|
||||||
})
|
})
|
||||||
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
||||||
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
||||||
{ downloadFileName: file.fileName })
|
input.disposition === 'inline'
|
||||||
|
? { inline: true, contentType: file.mimeType }
|
||||||
|
: { downloadFileName: file.fileName })
|
||||||
return { url }
|
return { url }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a workspace file (uploader or assigned mentor only).
|
* Delete a workspace file. Authorized for the uploader, any mentor
|
||||||
|
* currently assigned to the file's project, or any team member of the
|
||||||
|
* file's project. Final auth check lives in the service.
|
||||||
*/
|
*/
|
||||||
workspaceDeleteFile: protectedProcedure
|
workspaceDeleteFile: protectedProcedure
|
||||||
.input(z.object({ mentorFileId: z.string() }))
|
.input(z.object({ mentorFileId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const file = await ctx.prisma.mentorFile.findUnique({
|
const file = await ctx.prisma.mentorFile.findUnique({
|
||||||
where: { id: input.mentorFileId },
|
where: { id: input.mentorFileId },
|
||||||
select: { mentorAssignmentId: true },
|
select: { projectId: true },
|
||||||
})
|
})
|
||||||
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
||||||
try {
|
try {
|
||||||
await workspaceDeleteFileService(
|
await workspaceDeleteFileService(
|
||||||
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
|
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
|
||||||
@@ -2204,12 +2368,12 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const file = await ctx.prisma.mentorFile.findUnique({
|
const file = await ctx.prisma.mentorFile.findUnique({
|
||||||
where: { id: input.mentorFileId },
|
where: { id: input.mentorFileId },
|
||||||
select: { mentorAssignmentId: true },
|
select: { projectId: true },
|
||||||
})
|
})
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||||
}
|
}
|
||||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
||||||
return workspaceAddFileComment(
|
return workspaceAddFileComment(
|
||||||
{
|
{
|
||||||
mentorFileId: input.mentorFileId,
|
mentorFileId: input.mentorFileId,
|
||||||
@@ -2414,4 +2578,243 @@ export const mentorRouter = router({
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Mentor change requests (PR8)
|
||||||
|
//
|
||||||
|
// Applicants (team members) or admins can open a PENDING change request for
|
||||||
|
// a project — optionally targeting a specific co-mentor assignment. Admins
|
||||||
|
// are notified by email; mentors are intentionally NOT notified, even after
|
||||||
|
// resolution (per design decision in the PR8 plan).
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a new mentor change request. Allowed for:
|
||||||
|
* • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
|
||||||
|
* • a team member of the target project.
|
||||||
|
*
|
||||||
|
* Rejects with CONFLICT if the same user already has an open (PENDING) request
|
||||||
|
* for the same project. The raw `reason` is intentionally NOT included in
|
||||||
|
* audit logs — only its length — for privacy. Email delivery to admins is
|
||||||
|
* best-effort and never throws.
|
||||||
|
*/
|
||||||
|
requestChange: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
targetAssignmentId: z.string().min(1).optional(),
|
||||||
|
reason: z.string().min(10).max(2000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
|
// Authorization: admin OR team member of the project
|
||||||
|
if (!isAdmin) {
|
||||||
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
||||||
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!teamMembership) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You are not a member of this project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project (also confirms it exists) and validate optional target
|
||||||
|
const project = await ctx.prisma.project.findUnique({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
})
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.targetAssignmentId) {
|
||||||
|
const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
|
||||||
|
where: { id: input.targetAssignmentId },
|
||||||
|
select: { id: true, projectId: true },
|
||||||
|
})
|
||||||
|
if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Target assignment does not belong to this project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One open request per (user, project)
|
||||||
|
const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
requestedByUserId: ctx.user.id,
|
||||||
|
status: MentorChangeRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (existingOpen) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'You already have an open mentor change request for this project.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.prisma.mentorChangeRequest.create({
|
||||||
|
data: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
targetAssignmentId: input.targetAssignmentId ?? null,
|
||||||
|
requestedByUserId: ctx.user.id,
|
||||||
|
reason: input.reason,
|
||||||
|
status: MentorChangeRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
select: { id: true, status: true, createdAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify admins (best-effort, never throw)
|
||||||
|
try {
|
||||||
|
const admins = await ctx.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ roles: { has: 'SUPER_ADMIN' } },
|
||||||
|
{ roles: { has: 'PROGRAM_ADMIN' } },
|
||||||
|
],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: { email: true },
|
||||||
|
})
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||||
|
const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
|
||||||
|
await sendMentorChangeRequestEmail(
|
||||||
|
admins.map((a) => a.email),
|
||||||
|
project.title,
|
||||||
|
ctx.user.name ?? null,
|
||||||
|
input.reason,
|
||||||
|
adminDashboardUrl,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
// Defense-in-depth: the helper already has its own try/catch
|
||||||
|
console.error('[mentor.requestChange] notify admins failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'MENTOR_CHANGE_REQUEST_CREATE',
|
||||||
|
entityType: 'MentorChangeRequest',
|
||||||
|
entityId: created.id,
|
||||||
|
detailsJson: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
targetAssignmentId: input.targetAssignmentId ?? null,
|
||||||
|
reasonLength: input.reason.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return created
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin inbox — list MentorChangeRequest rows, optionally filtered by status
|
||||||
|
* and/or project. PENDING rows are surfaced first; within each status group
|
||||||
|
* rows are ordered by createdAt desc. No pagination (low-volume admin view).
|
||||||
|
*/
|
||||||
|
listChangeRequests: adminProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
status: z.nativeEnum(MentorChangeRequestStatus).optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const where: Prisma.MentorChangeRequestWhereInput = {}
|
||||||
|
if (input?.status) where.status = input.status
|
||||||
|
if (input?.projectId) where.projectId = input.projectId
|
||||||
|
|
||||||
|
const rows = await ctx.prisma.mentorChangeRequest.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
targetAssignment: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
mentor: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
resolvedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
// PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
|
||||||
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
|
||||||
|
// is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
|
||||||
|
// appears first regardless of enum string ordering.
|
||||||
|
const statusRank: Record<MentorChangeRequestStatus, number> = {
|
||||||
|
[MentorChangeRequestStatus.PENDING]: 0,
|
||||||
|
[MentorChangeRequestStatus.RESOLVED]: 1,
|
||||||
|
[MentorChangeRequestStatus.DISMISSED]: 2,
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => {
|
||||||
|
const sa = statusRank[a.status] - statusRank[b.status]
|
||||||
|
if (sa !== 0) return sa
|
||||||
|
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
|
||||||
|
* is rejected. No email or notification is sent to the requester or mentors
|
||||||
|
* (per PR8 design decision — mentors are never informed of change requests).
|
||||||
|
*/
|
||||||
|
resolveChangeRequest: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
status: z.enum(['RESOLVED', 'DISMISSED']),
|
||||||
|
resolutionNote: z.string().max(2000).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.prisma.mentorChangeRequest.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { id: true, status: true, projectId: true },
|
||||||
|
})
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
|
||||||
|
}
|
||||||
|
if (existing.status !== MentorChangeRequestStatus.PENDING) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Request already resolved',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await ctx.prisma.mentorChangeRequest.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: input.status as MentorChangeRequestStatus,
|
||||||
|
resolvedByUserId: ctx.user.id,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
resolutionNote: input.resolutionNote ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
|
||||||
|
entityType: 'MentorChangeRequest',
|
||||||
|
entityId: existing.id,
|
||||||
|
detailsJson: {
|
||||||
|
status: input.status,
|
||||||
|
projectId: existing.projectId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export const projectRouter = router({
|
|||||||
orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
|
orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
|
||||||
}
|
}
|
||||||
if (userHasRole(ctx.user, 'MENTOR')) {
|
if (userHasRole(ctx.user, 'MENTOR')) {
|
||||||
orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } })
|
orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } })
|
||||||
}
|
}
|
||||||
if (userHasRole(ctx.user, 'APPLICANT')) {
|
if (userHasRole(ctx.user, 'APPLICANT')) {
|
||||||
orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
|
orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
|
||||||
@@ -511,7 +511,7 @@ export const projectRouter = router({
|
|||||||
},
|
},
|
||||||
orderBy: { joinedAt: 'asc' },
|
orderBy: { joinedAt: 'asc' },
|
||||||
},
|
},
|
||||||
mentorAssignment: {
|
mentorAssignments: {
|
||||||
include: {
|
include: {
|
||||||
mentor: {
|
mentor: {
|
||||||
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
||||||
@@ -585,14 +585,18 @@ export const projectRouter = router({
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
const mentorWithAvatar = project.mentorAssignment
|
// TODO(PR8 Task 8): surface all mentors. For now we keep the legacy
|
||||||
|
// single-mentor shape and just pick the first non-dropped assignment
|
||||||
|
// so the admin UI keeps rendering without changes.
|
||||||
|
const primaryAssignment = project.mentorAssignments[0] ?? null
|
||||||
|
const mentorWithAvatar = primaryAssignment
|
||||||
? {
|
? {
|
||||||
...project.mentorAssignment,
|
...primaryAssignment,
|
||||||
mentor: {
|
mentor: {
|
||||||
...project.mentorAssignment.mentor,
|
...primaryAssignment.mentor,
|
||||||
avatarUrl: await getUserAvatarUrl(
|
avatarUrl: await getUserAvatarUrl(
|
||||||
project.mentorAssignment.mentor.profileImageKey,
|
primaryAssignment.mentor.profileImageKey,
|
||||||
project.mentorAssignment.mentor.profileImageProvider
|
primaryAssignment.mentor.profileImageProvider
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1311,7 +1315,7 @@ export const projectRouter = router({
|
|||||||
},
|
},
|
||||||
orderBy: { joinedAt: 'asc' },
|
orderBy: { joinedAt: 'asc' },
|
||||||
},
|
},
|
||||||
mentorAssignment: {
|
mentorAssignments: {
|
||||||
include: {
|
include: {
|
||||||
mentor: {
|
mentor: {
|
||||||
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
||||||
@@ -1448,18 +1452,21 @@ export const projectRouter = router({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
projectRaw.mentorAssignment
|
// TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first.
|
||||||
? (async () => ({
|
(async () => {
|
||||||
...projectRaw.mentorAssignment!,
|
const primaryMa = projectRaw.mentorAssignments[0] ?? null
|
||||||
|
if (!primaryMa) return null
|
||||||
|
return {
|
||||||
|
...primaryMa,
|
||||||
mentor: {
|
mentor: {
|
||||||
...projectRaw.mentorAssignment!.mentor,
|
...primaryMa.mentor,
|
||||||
avatarUrl: await getUserAvatarUrl(
|
avatarUrl: await getUserAvatarUrl(
|
||||||
projectRaw.mentorAssignment!.mentor.profileImageKey,
|
primaryMa.mentor.profileImageKey,
|
||||||
projectRaw.mentorAssignment!.mentor.profileImageProvider
|
primaryMa.mentor.profileImageProvider
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}))()
|
}
|
||||||
: Promise.resolve(null),
|
})(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export const roundRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
project: {
|
project: {
|
||||||
mentorAssignment: null,
|
mentorAssignments: { none: {} },
|
||||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ export async function markRead(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a file upload in a workspace.
|
* Record a file upload in a workspace.
|
||||||
|
*
|
||||||
|
* `workspaceId` is the originating MentorAssignment id (kept on the row as an
|
||||||
|
* audit-trail FK). We derive the project id from that assignment so the file
|
||||||
|
* is bound to the project — meaning any co-mentor on the project can see/use
|
||||||
|
* it, and the row survives if this particular assignment is later dropped.
|
||||||
*/
|
*/
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
params: {
|
params: {
|
||||||
@@ -180,6 +185,7 @@ export async function uploadFile(
|
|||||||
|
|
||||||
return prisma.mentorFile.create({
|
return prisma.mentorFile.create({
|
||||||
data: {
|
data: {
|
||||||
|
projectId: assignment.projectId,
|
||||||
mentorAssignmentId: params.workspaceId,
|
mentorAssignmentId: params.workspaceId,
|
||||||
uploadedByUserId: params.uploadedByUserId,
|
uploadedByUserId: params.uploadedByUserId,
|
||||||
fileName: params.fileName,
|
fileName: params.fileName,
|
||||||
@@ -238,9 +244,6 @@ export async function promoteFile(
|
|||||||
try {
|
try {
|
||||||
const file = await prisma.mentorFile.findUnique({
|
const file = await prisma.mentorFile.findUnique({
|
||||||
where: { id: params.mentorFileId },
|
where: { id: params.mentorFileId },
|
||||||
include: {
|
|
||||||
mentorAssignment: { select: { projectId: true } },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -265,7 +268,7 @@ export async function promoteFile(
|
|||||||
// Create promotion event
|
// Create promotion event
|
||||||
await tx.submissionPromotionEvent.create({
|
await tx.submissionPromotionEvent.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: file.mentorAssignment.projectId,
|
projectId: file.projectId,
|
||||||
roundId: params.roundId,
|
roundId: params.roundId,
|
||||||
slotKey: params.slotKey,
|
slotKey: params.slotKey,
|
||||||
sourceType: 'MENTOR_FILE',
|
sourceType: 'MENTOR_FILE',
|
||||||
@@ -281,7 +284,7 @@ export async function promoteFile(
|
|||||||
entityId: params.mentorFileId,
|
entityId: params.mentorFileId,
|
||||||
actorId: params.promotedById,
|
actorId: params.promotedById,
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
projectId: file.mentorAssignment.projectId,
|
projectId: file.projectId,
|
||||||
roundId: params.roundId,
|
roundId: params.roundId,
|
||||||
slotKey: params.slotKey,
|
slotKey: params.slotKey,
|
||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
@@ -297,7 +300,7 @@ export async function promoteFile(
|
|||||||
entityType: 'MentorFile',
|
entityType: 'MentorFile',
|
||||||
entityId: params.mentorFileId,
|
entityId: params.mentorFileId,
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
projectId: file.mentorAssignment.projectId,
|
projectId: file.projectId,
|
||||||
slotKey: params.slotKey,
|
slotKey: params.slotKey,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -314,14 +317,17 @@ export async function promoteFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files for a workspace, newest first, with comment counts and uploader.
|
* List files for a project, newest first, with comment counts and uploader.
|
||||||
|
* Project-scoped: every mentor assigned to the project (and every team member)
|
||||||
|
* sees the same file list, even if some files were uploaded under a now-dropped
|
||||||
|
* assignment.
|
||||||
*/
|
*/
|
||||||
export async function getFiles(
|
export async function getFiles(
|
||||||
workspaceId: string,
|
projectId: string,
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.mentorFile.findMany({
|
return prisma.mentorFile.findMany({
|
||||||
where: { mentorAssignmentId: workspaceId },
|
where: { projectId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
uploadedBy: { select: { id: true, name: true, email: true } },
|
uploadedBy: { select: { id: true, name: true, email: true } },
|
||||||
@@ -331,8 +337,10 @@ export async function getFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a file. Caller must be either the uploader OR the assigned mentor.
|
* Delete a file. Caller must be either the uploader, OR any mentor currently
|
||||||
* Removes the MinIO object and the DB row + cascade-deletes comments.
|
* assigned (not dropped) to the file's project, OR a team member of the
|
||||||
|
* file's project. Removes the MinIO object and the DB row + cascade-deletes
|
||||||
|
* comments.
|
||||||
*/
|
*/
|
||||||
export async function deleteFile(
|
export async function deleteFile(
|
||||||
params: { mentorFileId: string; userId: string },
|
params: { mentorFileId: string; userId: string },
|
||||||
@@ -341,13 +349,30 @@ export async function deleteFile(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const file = await prisma.mentorFile.findUnique({
|
const file = await prisma.mentorFile.findUnique({
|
||||||
where: { id: params.mentorFileId },
|
where: { id: params.mentorFileId },
|
||||||
include: { mentorAssignment: { select: { mentorId: true } } },
|
|
||||||
})
|
})
|
||||||
if (!file) throw new Error('File not found')
|
if (!file) throw new Error('File not found')
|
||||||
const isUploader = file.uploadedByUserId === params.userId
|
const isUploader = file.uploadedByUserId === params.userId
|
||||||
const isMentor = file.mentorAssignment.mentorId === params.userId
|
let isAuthorized = isUploader
|
||||||
if (!isUploader && !isMentor) {
|
if (!isAuthorized) {
|
||||||
throw new Error('Only the uploader or the assigned mentor can delete this file')
|
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||||
|
where: { projectId: file.projectId, mentorId: params.userId, droppedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (mentorAssignment) {
|
||||||
|
isAuthorized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAuthorized) {
|
||||||
|
const teamMembership = await prisma.teamMember.findFirst({
|
||||||
|
where: { projectId: file.projectId, userId: params.userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (teamMembership) {
|
||||||
|
isAuthorized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAuthorized) {
|
||||||
|
throw new Error('Only the uploader, an assigned mentor, or a team member can delete this file')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await removeStorageObject(file.bucket, file.objectKey)
|
await removeStorageObject(file.bucket, file.objectKey)
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ export async function getMentorSuggestionsForProject(
|
|||||||
projectTags: {
|
projectTags: {
|
||||||
include: { tag: true },
|
include: { tag: true },
|
||||||
},
|
},
|
||||||
mentorAssignment: true,
|
mentorAssignments: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -714,7 +714,7 @@ export async function getMentorSuggestionsForProject(
|
|||||||
|
|
||||||
for (const mentor of mentors) {
|
for (const mentor of mentors) {
|
||||||
// Skip if already assigned to this project
|
// Skip if already assigned to this project
|
||||||
if (project.mentorAssignment?.mentorId === mentor.id) {
|
if (project.mentorAssignments.some((ma) => ma.mentorId === mentor.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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)
|
expect(result.assigned).toBe(1)
|
||||||
|
|
||||||
const requestedAssigned = await prisma.mentorAssignment.findUnique({
|
const requestedAssigned = await prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: projWithRequest.id },
|
where: { projectId: projWithRequest.id },
|
||||||
})
|
})
|
||||||
expect(requestedAssigned).not.toBeNull()
|
expect(requestedAssigned).not.toBeNull()
|
||||||
|
|
||||||
const skippedNotAssigned = await prisma.mentorAssignment.findUnique({
|
const skippedNotAssigned = await prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: projWithoutRequest.id },
|
where: { projectId: projWithoutRequest.id },
|
||||||
})
|
})
|
||||||
expect(skippedNotAssigned).toBeNull()
|
expect(skippedNotAssigned).toBeNull()
|
||||||
@@ -291,7 +291,7 @@ describe('mentor.autoAssignBulkForRound', () => {
|
|||||||
expect(result.assigned).toBe(1)
|
expect(result.assigned).toBe(1)
|
||||||
expect(result.skipped).toBe(1)
|
expect(result.skipped).toBe(1)
|
||||||
|
|
||||||
const stillExisting = await prisma.mentorAssignment.findUnique({
|
const stillExisting = await prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: projAlreadyAssigned.id },
|
where: { projectId: projAlreadyAssigned.id },
|
||||||
})
|
})
|
||||||
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
|
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
|
||||||
@@ -377,17 +377,17 @@ describe('mentor.autoAssignBulkForRound', () => {
|
|||||||
|
|
||||||
expect(result.assigned).toBe(1)
|
expect(result.assigned).toBe(1)
|
||||||
|
|
||||||
const confirmedAssigned = await prisma.mentorAssignment.findUnique({
|
const confirmedAssigned = await prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: projConfirmed.id },
|
where: { projectId: projConfirmed.id },
|
||||||
})
|
})
|
||||||
expect(confirmedAssigned).not.toBeNull()
|
expect(confirmedAssigned).not.toBeNull()
|
||||||
|
|
||||||
const pendingAssigned = await prisma.mentorAssignment.findUnique({
|
const pendingAssigned = await prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: projPending.id },
|
where: { projectId: projPending.id },
|
||||||
})
|
})
|
||||||
expect(pendingAssigned).toBeNull()
|
expect(pendingAssigned).toBeNull()
|
||||||
|
|
||||||
const noConfAssigned = await prisma.mentorAssignment.findUnique({
|
const noConfAssigned = await prisma.mentorAssignment.findFirst({
|
||||||
where: { projectId: projNoConfirmation.id },
|
where: { projectId: projNoConfirmation.id },
|
||||||
})
|
})
|
||||||
expect(noConfAssigned).toBeNull()
|
expect(noConfAssigned).toBeNull()
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => {
|
|||||||
})
|
})
|
||||||
await prisma.mentorFile.create({
|
await prisma.mentorFile.create({
|
||||||
data: {
|
data: {
|
||||||
|
projectId: projReqAssigned.id,
|
||||||
mentorAssignmentId: a1.id,
|
mentorAssignmentId: a1.id,
|
||||||
uploadedByUserId: mentor.id,
|
uploadedByUserId: mentor.id,
|
||||||
fileName: 'plan.pdf',
|
fileName: 'plan.pdf',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
const samplePayload: MentorUploadPayload = {
|
const samplePayload: MentorUploadPayload = {
|
||||||
mentorAssignmentId: 'ma-123',
|
mentorAssignmentId: 'ma-123',
|
||||||
|
projectId: 'proj-789',
|
||||||
uploaderUserId: 'user-456',
|
uploaderUserId: 'user-456',
|
||||||
fileName: 'doc.pdf',
|
fileName: 'doc.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { signMentorUploadToken } from '../../src/lib/mentor-upload-token'
|
|||||||
|
|
||||||
describe('mentor.workspace files end-to-end', () => {
|
describe('mentor.workspace files end-to-end', () => {
|
||||||
let programId: string
|
let programId: string
|
||||||
|
let projectId: string
|
||||||
let mentor: { id: string; email: string; role: 'MENTOR' }
|
let mentor: { id: string; email: string; role: 'MENTOR' }
|
||||||
let outsider: { id: string; email: string; role: 'JURY_MEMBER' }
|
let outsider: { id: string; email: string; role: 'JURY_MEMBER' }
|
||||||
let assignmentId: string
|
let assignmentId: string
|
||||||
@@ -18,6 +19,7 @@ describe('mentor.workspace files end-to-end', () => {
|
|||||||
const program = await createTestProgram({ name: `mentor-files-${uid()}` })
|
const program = await createTestProgram({ name: `mentor-files-${uid()}` })
|
||||||
programId = program.id
|
programId = program.id
|
||||||
const project = await createTestProject(programId, { title: 'Test Project' })
|
const project = await createTestProject(programId, { title: 'Test Project' })
|
||||||
|
projectId = project.id
|
||||||
|
|
||||||
const m = await createTestUser('MENTOR')
|
const m = await createTestUser('MENTOR')
|
||||||
userIds.push(m.id)
|
userIds.push(m.id)
|
||||||
@@ -79,6 +81,7 @@ describe('mentor.workspace files end-to-end', () => {
|
|||||||
it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => {
|
it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => {
|
||||||
const forged = signMentorUploadToken({
|
const forged = signMentorUploadToken({
|
||||||
mentorAssignmentId: assignmentId,
|
mentorAssignmentId: assignmentId,
|
||||||
|
projectId,
|
||||||
uploaderUserId: 'someone-else',
|
uploaderUserId: 'someone-else',
|
||||||
fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
|
fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
|
||||||
bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf',
|
bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf',
|
||||||
@@ -94,7 +97,7 @@ describe('mentor.workspace files end-to-end', () => {
|
|||||||
mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50,
|
mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50,
|
||||||
})
|
})
|
||||||
await caller.workspaceUploadFile({ uploadToken: a.uploadToken })
|
await caller.workspaceUploadFile({ uploadToken: a.uploadToken })
|
||||||
const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
|
const files = await caller.workspaceGetFiles({ projectId })
|
||||||
expect(files.length).toBeGreaterThanOrEqual(2)
|
expect(files.length).toBeGreaterThanOrEqual(2)
|
||||||
expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual(
|
expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual(
|
||||||
new Date(files[1].createdAt).getTime(),
|
new Date(files[1].createdAt).getTime(),
|
||||||
@@ -104,7 +107,7 @@ describe('mentor.workspace files end-to-end', () => {
|
|||||||
it('refuses workspaceGetFiles to outsiders', async () => {
|
it('refuses workspaceGetFiles to outsiders', async () => {
|
||||||
const caller = createCaller(mentorRouter, outsider)
|
const caller = createCaller(mentorRouter, outsider)
|
||||||
await expect(
|
await expect(
|
||||||
caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
|
caller.workspaceGetFiles({ projectId })
|
||||||
).rejects.toThrow(/FORBIDDEN|not a member/i)
|
).rejects.toThrow(/FORBIDDEN|not a member/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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