Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -384,4 +384,267 @@ export const fileRouter = router({
|
||||
|
||||
return grouped
|
||||
}),
|
||||
|
||||
/**
|
||||
* Replace a file with a new version
|
||||
*/
|
||||
replaceFile: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
oldFileId: z.string(),
|
||||
fileName: z.string(),
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
bucket: z.string(),
|
||||
objectKey: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check user has access to the project (assigned or team member)
|
||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!assignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to replace files for this project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get the old file to read its version
|
||||
const oldFile = await ctx.prisma.projectFile.findUniqueOrThrow({
|
||||
where: { id: input.oldFileId },
|
||||
select: { id: true, version: true, projectId: true },
|
||||
})
|
||||
|
||||
if (oldFile.projectId !== input.projectId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'File does not belong to the specified project',
|
||||
})
|
||||
}
|
||||
|
||||
// Create new file and update old file in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
const newFile = await tx.projectFile.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
version: oldFile.version + 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Link old file to new file
|
||||
await tx.projectFile.update({
|
||||
where: { id: input.oldFileId },
|
||||
data: { replacedById: newFile.id },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'REPLACE_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: newFile.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
oldFileId: input.oldFileId,
|
||||
oldVersion: oldFile.version,
|
||||
newVersion: newFile.version,
|
||||
fileName: input.fileName,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return newFile
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get version history for a file
|
||||
*/
|
||||
getVersionHistory: protectedProcedure
|
||||
.input(z.object({ fileId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Find the requested file
|
||||
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
|
||||
where: { id: input.fileId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
bucket: true,
|
||||
objectKey: true,
|
||||
version: true,
|
||||
replacedById: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Walk backwards: find all prior versions by following replacedById chains
|
||||
// First, collect ALL files for this project with the same fileType to find the chain
|
||||
const allRelatedFiles = await ctx.prisma.projectFile.findMany({
|
||||
where: { projectId: file.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
bucket: true,
|
||||
objectKey: true,
|
||||
version: true,
|
||||
replacedById: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { version: 'asc' },
|
||||
})
|
||||
|
||||
// Build a chain map: fileId -> file that replaced it
|
||||
const replacedByMap = new Map(
|
||||
allRelatedFiles.filter((f) => f.replacedById).map((f) => [f.replacedById!, f.id])
|
||||
)
|
||||
|
||||
// Walk from the current file backwards through replacedById to find all versions in chain
|
||||
const versions: typeof allRelatedFiles = []
|
||||
|
||||
// Find the root of this version chain (walk backwards)
|
||||
let currentId: string | undefined = input.fileId
|
||||
const visited = new Set<string>()
|
||||
while (currentId && !visited.has(currentId)) {
|
||||
visited.add(currentId)
|
||||
const prevId = replacedByMap.get(currentId)
|
||||
if (prevId) {
|
||||
currentId = prevId
|
||||
} else {
|
||||
break // reached root
|
||||
}
|
||||
}
|
||||
|
||||
// Now walk forward from root
|
||||
let walkId: string | undefined = currentId
|
||||
const fileMap = new Map(allRelatedFiles.map((f) => [f.id, f]))
|
||||
const forwardVisited = new Set<string>()
|
||||
while (walkId && !forwardVisited.has(walkId)) {
|
||||
forwardVisited.add(walkId)
|
||||
const f = fileMap.get(walkId)
|
||||
if (f) {
|
||||
versions.push(f)
|
||||
walkId = f.replacedById ?? undefined
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return versions
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get bulk download URLs for project files
|
||||
*/
|
||||
getBulkDownloadUrls: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
fileIds: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!assignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project\'s files',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get files
|
||||
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||
if (input.fileIds && input.fileIds.length > 0) {
|
||||
where.id = { in: input.fileIds }
|
||||
}
|
||||
|
||||
const files = await ctx.prisma.projectFile.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
bucket: true,
|
||||
objectKey: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Generate signed URLs for each file
|
||||
const results = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900)
|
||||
return {
|
||||
fileId: file.id,
|
||||
fileName: file.fileName,
|
||||
downloadUrl,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user