Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
295
src/server/routers/export.ts
Normal file
295
src/server/routers/export.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
|
||||
export const exportRouter = router({
|
||||
/**
|
||||
* Export evaluations as CSV data
|
||||
*/
|
||||
evaluations: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
includeDetails: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { roundId: input.roundId },
|
||||
},
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
project: { select: { title: true, teamName: true, tags: true } },
|
||||
},
|
||||
},
|
||||
form: { select: { criteriaJson: true } },
|
||||
},
|
||||
orderBy: [
|
||||
{ assignment: { project: { title: 'asc' } } },
|
||||
{ submittedAt: 'asc' },
|
||||
],
|
||||
})
|
||||
|
||||
// Get criteria labels from form
|
||||
const criteriaLabels: Record<string, string> = {}
|
||||
if (evaluations.length > 0) {
|
||||
const criteria = evaluations[0].form.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
criteria.forEach((c) => {
|
||||
criteriaLabels[c.id] = c.label
|
||||
})
|
||||
}
|
||||
|
||||
// Build export data
|
||||
const data = evaluations.map((e) => {
|
||||
const scores = e.criterionScoresJson as Record<string, number> | null
|
||||
const criteriaScores: Record<string, number | null> = {}
|
||||
|
||||
Object.keys(criteriaLabels).forEach((id) => {
|
||||
criteriaScores[criteriaLabels[id]] = scores?.[id] ?? null
|
||||
})
|
||||
|
||||
return {
|
||||
projectTitle: e.assignment.project.title,
|
||||
teamName: e.assignment.project.teamName,
|
||||
tags: e.assignment.project.tags.join(', '),
|
||||
jurorName: e.assignment.user.name,
|
||||
jurorEmail: e.assignment.user.email,
|
||||
...criteriaScores,
|
||||
globalScore: e.globalScore,
|
||||
decision: e.binaryDecision ? 'Yes' : 'No',
|
||||
feedback: input.includeDetails ? e.feedbackText : null,
|
||||
submittedAt: e.submittedAt?.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'Evaluation',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
columns: [
|
||||
'projectTitle',
|
||||
'teamName',
|
||||
'tags',
|
||||
'jurorName',
|
||||
'jurorEmail',
|
||||
...Object.values(criteriaLabels),
|
||||
'globalScore',
|
||||
'decision',
|
||||
...(input.includeDetails ? ['feedback'] : []),
|
||||
'submittedAt',
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Export project scores summary
|
||||
*/
|
||||
projectScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
evaluation: {
|
||||
where: { status: 'SUBMITTED' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
})
|
||||
|
||||
const data = projects.map((p) => {
|
||||
const evaluations = p.assignments
|
||||
.map((a) => a.evaluation)
|
||||
.filter((e) => e !== null)
|
||||
|
||||
const globalScores = evaluations
|
||||
.map((e) => e?.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
|
||||
const yesVotes = evaluations.filter(
|
||||
(e) => e?.binaryDecision === true
|
||||
).length
|
||||
|
||||
return {
|
||||
title: p.title,
|
||||
teamName: p.teamName,
|
||||
status: p.status,
|
||||
tags: p.tags.join(', '),
|
||||
totalEvaluations: evaluations.length,
|
||||
averageScore:
|
||||
globalScores.length > 0
|
||||
? (
|
||||
globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
||||
).toFixed(2)
|
||||
: null,
|
||||
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
|
||||
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
|
||||
yesVotes,
|
||||
noVotes: evaluations.length - yesVotes,
|
||||
yesPercentage:
|
||||
evaluations.length > 0
|
||||
? ((yesVotes / evaluations.length) * 100).toFixed(1)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'ProjectScores',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
columns: [
|
||||
'title',
|
||||
'teamName',
|
||||
'status',
|
||||
'tags',
|
||||
'totalEvaluations',
|
||||
'averageScore',
|
||||
'minScore',
|
||||
'maxScore',
|
||||
'yesVotes',
|
||||
'noVotes',
|
||||
'yesPercentage',
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Export assignments
|
||||
*/
|
||||
assignments: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
project: { select: { title: true, teamName: true } },
|
||||
evaluation: { select: { status: true, submittedAt: true } },
|
||||
},
|
||||
orderBy: [{ project: { title: 'asc' } }, { user: { name: 'asc' } }],
|
||||
})
|
||||
|
||||
const data = assignments.map((a) => ({
|
||||
projectTitle: a.project.title,
|
||||
teamName: a.project.teamName,
|
||||
jurorName: a.user.name,
|
||||
jurorEmail: a.user.email,
|
||||
method: a.method,
|
||||
isRequired: a.isRequired ? 'Yes' : 'No',
|
||||
isCompleted: a.isCompleted ? 'Yes' : 'No',
|
||||
evaluationStatus: a.evaluation?.status ?? 'NOT_STARTED',
|
||||
submittedAt: a.evaluation?.submittedAt?.toISOString() ?? null,
|
||||
assignedAt: a.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
return {
|
||||
data,
|
||||
columns: [
|
||||
'projectTitle',
|
||||
'teamName',
|
||||
'jurorName',
|
||||
'jurorEmail',
|
||||
'method',
|
||||
'isRequired',
|
||||
'isCompleted',
|
||||
'evaluationStatus',
|
||||
'submittedAt',
|
||||
'assignedAt',
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Export audit logs as CSV data
|
||||
*/
|
||||
auditLogs: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
entityType: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { userId, action, entityType, startDate, endDate } = input
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (userId) where.userId = userId
|
||||
if (action) where.action = { contains: action, mode: 'insensitive' }
|
||||
if (entityType) where.entityType = entityType
|
||||
if (startDate || endDate) {
|
||||
where.timestamp = {}
|
||||
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
|
||||
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
|
||||
}
|
||||
|
||||
const logs = await ctx.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
take: 10000, // Limit export to 10k records
|
||||
})
|
||||
|
||||
const data = logs.map((log) => ({
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
userName: log.user?.name ?? 'System',
|
||||
userEmail: log.user?.email ?? 'N/A',
|
||||
action: log.action,
|
||||
entityType: log.entityType,
|
||||
entityId: log.entityId ?? '',
|
||||
ipAddress: log.ipAddress ?? '',
|
||||
userAgent: log.userAgent ?? '',
|
||||
details: log.detailsJson ? JSON.stringify(log.detailsJson) : '',
|
||||
}))
|
||||
|
||||
return {
|
||||
data,
|
||||
columns: [
|
||||
'timestamp',
|
||||
'userName',
|
||||
'userEmail',
|
||||
'action',
|
||||
'entityType',
|
||||
'entityId',
|
||||
'ipAddress',
|
||||
'userAgent',
|
||||
'details',
|
||||
],
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user