Files
MOPC-Portal/src/server/services/mentor-workspace.ts
Matt 6b40fe7726
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m4s
refactor: tech debt batch 3 — type safety + assignment router split
#5 — Replaced 55x PrismaClient | any with proper Prisma types across 8 files
- Service files: PrismaClient | any → PrismaClient, tx: any → Prisma.TransactionClient
- Fixed 4 real bugs uncovered by typing:
  - mentor-workspace.ts: wrong FK fields (mentorAssignmentId → workspaceId, role → senderRole)
  - ai-shortlist.ts: untyped string passed to CompetitionCategory enum filter
  - result-lock.ts: unknown passed where Prisma.InputJsonValue required

#9 — Split assignment.ts (2,775 lines) into 6 focused files:
  - shared.ts (93 lines) — MOVABLE_EVAL_STATUSES, buildBatchNotifications, getCandidateJurors
  - assignment-crud.ts (473 lines) — 8 core CRUD procedures
  - assignment-suggestions.ts (880 lines) — AI suggestions + job runner
  - assignment-notifications.ts (138 lines) — 2 notification procedures
  - assignment-redistribution.ts (1,162 lines) — 8 reassign/transfer procedures
  - index.ts (15 lines) — barrel export with router merge, zero frontend changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:47:06 +01:00

315 lines
8.6 KiB
TypeScript

/**
* Mentor Workspace Service
*
* Manages mentor-applicant workspace: activation, messaging, file management,
* and file promotion to official submissions. Operates on MentorAssignment,
* MentorMessage, MentorFile, MentorFileComment, SubmissionPromotionEvent.
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
type WorkspaceResult = { success: boolean; errors?: string[] }
// ─── Workspace Activation ───────────────────────────────────────────────────
/**
* Activate a mentor workspace for a given assignment.
*/
export async function activateWorkspace(
workspaceId: string,
actorId: string,
prisma: PrismaClient,
): Promise<WorkspaceResult> {
try {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: workspaceId },
})
if (!assignment) {
return { success: false, errors: ['Mentor assignment not found'] }
}
if (assignment.workspaceEnabled) {
return { success: false, errors: ['Workspace is already enabled'] }
}
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.mentorAssignment.update({
where: { id: workspaceId },
data: {
workspaceEnabled: true,
workspaceOpenAt: new Date(),
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'mentor_workspace.activated',
entityType: 'MentorAssignment',
entityId: workspaceId,
actorId,
detailsJson: {
projectId: assignment.projectId,
mentorId: assignment.mentorId,
},
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'mentor-workspace' },
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'WORKSPACE_ACTIVATE',
entityType: 'MentorAssignment',
entityId: workspaceId,
detailsJson: { projectId: assignment.projectId },
})
})
return { success: true }
} catch (error) {
console.error('[MentorWorkspace] activateWorkspace failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}
// ─── Messaging ──────────────────────────────────────────────────────────────
/**
* Send a message in a mentor workspace.
*/
export async function sendMessage(
params: {
workspaceId: string
senderId: string
message: string
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
},
prisma: PrismaClient,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: params.workspaceId },
})
if (!assignment) {
throw new Error('Mentor assignment not found')
}
if (!assignment.workspaceEnabled) {
throw new Error('Workspace is not enabled for this assignment')
}
return prisma.mentorMessage.create({
data: {
workspaceId: params.workspaceId,
projectId: assignment.projectId,
senderId: params.senderId,
message: params.message,
senderRole: params.role,
},
include: {
sender: { select: { id: true, name: true, email: true } },
},
})
}
/**
* Get messages for a workspace.
*/
export async function getMessages(
workspaceId: string,
prisma: PrismaClient,
) {
return prisma.mentorMessage.findMany({
where: { workspaceId },
include: {
sender: { select: { id: true, name: true, email: true, role: true } },
},
orderBy: { createdAt: 'asc' },
})
}
/**
* Mark a message as read.
*/
export async function markRead(
messageId: string,
prisma: PrismaClient,
): Promise<void> {
await prisma.mentorMessage.update({
where: { id: messageId },
data: { isRead: true },
})
}
// ─── File Management ────────────────────────────────────────────────────────
/**
* Record a file upload in a workspace.
*/
export async function uploadFile(
params: {
workspaceId: string
uploadedByUserId: string
fileName: string
mimeType: string
size: number
bucket: string
objectKey: string
description?: string
},
prisma: PrismaClient,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: params.workspaceId },
})
if (!assignment) {
throw new Error('Mentor assignment not found')
}
if (!assignment.workspaceEnabled) {
throw new Error('Workspace is not enabled for this assignment')
}
return prisma.mentorFile.create({
data: {
mentorAssignmentId: params.workspaceId,
uploadedByUserId: params.uploadedByUserId,
fileName: params.fileName,
mimeType: params.mimeType,
size: params.size,
bucket: params.bucket,
objectKey: params.objectKey,
description: params.description,
},
include: {
uploadedBy: { select: { id: true, name: true, email: true } },
},
})
}
/**
* Add a comment to a file.
*/
export async function addFileComment(
params: {
mentorFileId: string
authorId: string
content: string
parentCommentId?: string
},
prisma: PrismaClient,
) {
return prisma.mentorFileComment.create({
data: {
mentorFileId: params.mentorFileId,
authorId: params.authorId,
content: params.content,
parentCommentId: params.parentCommentId,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
}
// ─── File Promotion ─────────────────────────────────────────────────────────
/**
* Promote a mentor file to an official submission.
* Creates SubmissionPromotionEvent and marks MentorFile.isPromoted = true.
*/
export async function promoteFile(
params: {
mentorFileId: string
roundId: string
slotKey: string
promotedById: string
},
prisma: PrismaClient,
): Promise<{ success: boolean; errors?: string[] }> {
try {
const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId },
include: {
mentorAssignment: { select: { projectId: true } },
},
})
if (!file) {
return { success: false, errors: ['Mentor file not found'] }
}
if (file.isPromoted) {
return { success: false, errors: ['File is already promoted'] }
}
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Mark file as promoted
await tx.mentorFile.update({
where: { id: params.mentorFileId },
data: {
isPromoted: true,
promotedAt: new Date(),
promotedByUserId: params.promotedById,
},
})
// Create promotion event
await tx.submissionPromotionEvent.create({
data: {
projectId: file.mentorAssignment.projectId,
roundId: params.roundId,
slotKey: params.slotKey,
sourceType: 'MENTOR_FILE',
sourceFileId: params.mentorFileId,
promotedById: params.promotedById,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'mentor_file.promoted',
entityType: 'MentorFile',
entityId: params.mentorFileId,
actorId: params.promotedById,
detailsJson: {
projectId: file.mentorAssignment.projectId,
roundId: params.roundId,
slotKey: params.slotKey,
fileName: file.fileName,
},
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'mentor-workspace' },
},
})
await logAudit({
prisma: tx,
userId: params.promotedById,
action: 'MENTOR_FILE_PROMOTE',
entityType: 'MentorFile',
entityId: params.mentorFileId,
detailsJson: {
projectId: file.mentorAssignment.projectId,
slotKey: params.slotKey,
},
})
})
return { success: true }
} catch (error) {
console.error('[MentorWorkspace] promoteFile failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}