2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '../utils/audit'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
export const liveVotingRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Get or create a live voting session for a round
|
|
|
|
|
*/
|
|
|
|
|
getSession: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
let session = await ctx.prisma.liveVotingSession.findUnique({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { name: true, year: true } },
|
2026-02-04 14:15:06 +01:00
|
|
|
projects: {
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: { id: true, title: true, teamName: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!session) {
|
|
|
|
|
// Create session
|
|
|
|
|
session = await ctx.prisma.liveVotingSession.create({
|
|
|
|
|
data: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { name: true, year: true } },
|
2026-02-04 14:15:06 +01:00
|
|
|
projects: {
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: { id: true, title: true, teamName: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get current votes if voting is in progress
|
|
|
|
|
let currentVotes: { userId: string; score: number }[] = []
|
|
|
|
|
if (session.currentProjectId) {
|
|
|
|
|
const votes = await ctx.prisma.liveVote.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
sessionId: session.id,
|
|
|
|
|
projectId: session.currentProjectId,
|
|
|
|
|
},
|
|
|
|
|
select: { userId: true, score: true },
|
|
|
|
|
})
|
|
|
|
|
currentVotes = votes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...session,
|
|
|
|
|
currentVotes,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get session for jury member voting
|
|
|
|
|
*/
|
|
|
|
|
getSessionForVoting: protectedProcedure
|
|
|
|
|
.input(z.object({ sessionId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { name: true, year: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get current project if in progress
|
|
|
|
|
let currentProject = null
|
|
|
|
|
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
|
|
|
|
|
currentProject = await ctx.prisma.project.findUnique({
|
|
|
|
|
where: { id: session.currentProjectId },
|
|
|
|
|
select: { id: true, title: true, teamName: true, description: true },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get user's vote for current project
|
|
|
|
|
let userVote = null
|
|
|
|
|
if (session.currentProjectId) {
|
|
|
|
|
userVote = await ctx.prisma.liveVote.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
sessionId: session.id,
|
|
|
|
|
projectId: session.currentProjectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate time remaining
|
|
|
|
|
let timeRemaining = null
|
|
|
|
|
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
|
|
|
|
|
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
|
|
|
|
|
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
session: {
|
|
|
|
|
id: session.id,
|
|
|
|
|
status: session.status,
|
|
|
|
|
votingStartedAt: session.votingStartedAt,
|
|
|
|
|
votingEndsAt: session.votingEndsAt,
|
|
|
|
|
},
|
|
|
|
|
round: session.round,
|
|
|
|
|
currentProject,
|
|
|
|
|
userVote,
|
|
|
|
|
timeRemaining,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get public session info for display
|
|
|
|
|
*/
|
|
|
|
|
getPublicSession: protectedProcedure
|
|
|
|
|
.input(z.object({ sessionId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { name: true, year: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get all projects in order
|
|
|
|
|
const projectOrder = (session.projectOrderJson as string[]) || []
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: { id: { in: projectOrder } },
|
|
|
|
|
select: { id: true, title: true, teamName: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Sort by order
|
|
|
|
|
const sortedProjects = projectOrder
|
|
|
|
|
.map((id) => projects.find((p) => p.id === id))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
|
|
|
|
// Get scores for each project
|
|
|
|
|
const scores = await ctx.prisma.liveVote.groupBy({
|
|
|
|
|
by: ['projectId'],
|
|
|
|
|
where: { sessionId: session.id },
|
|
|
|
|
_avg: { score: true },
|
|
|
|
|
_count: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const projectsWithScores = sortedProjects.map((project) => {
|
|
|
|
|
const projectScore = scores.find((s) => s.projectId === project!.id)
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
averageScore: projectScore?._avg.score || null,
|
|
|
|
|
voteCount: projectScore?._count || 0,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
session: {
|
|
|
|
|
id: session.id,
|
|
|
|
|
status: session.status,
|
|
|
|
|
currentProjectId: session.currentProjectId,
|
|
|
|
|
votingEndsAt: session.votingEndsAt,
|
|
|
|
|
},
|
|
|
|
|
round: session.round,
|
|
|
|
|
projects: projectsWithScores,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set project order for voting
|
|
|
|
|
*/
|
|
|
|
|
setProjectOrder: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
projectIds: z.array(z.string()),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
data: {
|
|
|
|
|
projectOrderJson: input.projectIds,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start voting for a project
|
|
|
|
|
*/
|
|
|
|
|
startVoting: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
durationSeconds: z.number().int().min(10).max(300).default(30),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const votingEndsAt = new Date(now.getTime() + input.durationSeconds * 1000)
|
|
|
|
|
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'IN_PROGRESS',
|
|
|
|
|
currentProjectId: input.projectId,
|
|
|
|
|
votingStartedAt: now,
|
|
|
|
|
votingEndsAt,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'START_VOTING',
|
|
|
|
|
entityType: 'LiveVotingSession',
|
|
|
|
|
entityId: session.id,
|
|
|
|
|
detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop voting
|
|
|
|
|
*/
|
|
|
|
|
stopVoting: adminProcedure
|
|
|
|
|
.input(z.object({ sessionId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'PAUSED',
|
|
|
|
|
votingEndsAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* End session
|
|
|
|
|
*/
|
|
|
|
|
endSession: adminProcedure
|
|
|
|
|
.input(z.object({ sessionId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'COMPLETED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'END_SESSION',
|
|
|
|
|
entityType: 'LiveVotingSession',
|
|
|
|
|
entityId: session.id,
|
|
|
|
|
detailsJson: {},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Submit a vote
|
|
|
|
|
*/
|
|
|
|
|
vote: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
score: z.number().int().min(1).max(10),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify session is in progress
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (session.status !== 'IN_PROGRESS') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Voting is not currently active',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.currentProjectId !== input.projectId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot vote for this project right now',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if voting window is still open
|
|
|
|
|
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Voting window has closed',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upsert vote (allow vote change during window)
|
|
|
|
|
const vote = await ctx.prisma.liveVote.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
sessionId_projectId_userId: {
|
|
|
|
|
sessionId: input.sessionId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
sessionId: input.sessionId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
score: input.score,
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
score: input.score,
|
|
|
|
|
votedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return vote
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
* Get results for a session (with weighted jury + audience scoring)
|
2026-01-30 13:41:32 +01:00
|
|
|
*/
|
|
|
|
|
getResults: protectedProcedure
|
|
|
|
|
.input(z.object({ sessionId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { name: true, year: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
const audienceWeight = session.audienceVoteWeight || 0
|
|
|
|
|
const juryWeight = 1 - audienceWeight
|
|
|
|
|
|
|
|
|
|
// Get jury votes grouped by project
|
|
|
|
|
const juryScores = await ctx.prisma.liveVote.groupBy({
|
2026-01-30 13:41:32 +01:00
|
|
|
by: ['projectId'],
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
where: { sessionId: input.sessionId, isAudienceVote: false },
|
|
|
|
|
_avg: { score: true },
|
|
|
|
|
_count: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get audience votes grouped by project
|
|
|
|
|
const audienceScores = await ctx.prisma.liveVote.groupBy({
|
|
|
|
|
by: ['projectId'],
|
|
|
|
|
where: { sessionId: input.sessionId, isAudienceVote: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
_avg: { score: true },
|
|
|
|
|
_count: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get project details
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
const allProjectIds = [
|
|
|
|
|
...new Set([
|
|
|
|
|
...juryScores.map((s) => s.projectId),
|
|
|
|
|
...audienceScores.map((s) => s.projectId),
|
|
|
|
|
]),
|
|
|
|
|
]
|
2026-01-30 13:41:32 +01:00
|
|
|
const projects = await ctx.prisma.project.findMany({
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
where: { id: { in: allProjectIds } },
|
2026-01-30 13:41:32 +01:00
|
|
|
select: { id: true, title: true, teamName: true },
|
|
|
|
|
})
|
|
|
|
|
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s]))
|
|
|
|
|
|
|
|
|
|
// Combine and calculate weighted scores
|
|
|
|
|
const results = juryScores
|
|
|
|
|
.map((jurySc) => {
|
|
|
|
|
const project = projects.find((p) => p.id === jurySc.projectId)
|
|
|
|
|
const audienceSc = audienceMap.get(jurySc.projectId)
|
|
|
|
|
const juryAvg = jurySc._avg.score || 0
|
|
|
|
|
const audienceAvg = audienceSc?._avg.score || 0
|
|
|
|
|
const weightedTotal = audienceWeight > 0 && audienceSc
|
|
|
|
|
? juryAvg * juryWeight + audienceAvg * audienceWeight
|
|
|
|
|
: juryAvg
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return {
|
|
|
|
|
project,
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
juryAverage: juryAvg,
|
|
|
|
|
juryVoteCount: jurySc._count,
|
|
|
|
|
audienceAverage: audienceAvg,
|
|
|
|
|
audienceVoteCount: audienceSc?._count || 0,
|
|
|
|
|
weightedTotal,
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
})
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
.sort((a, b) => b.weightedTotal - a.weightedTotal)
|
|
|
|
|
|
|
|
|
|
// Detect ties
|
|
|
|
|
const ties: string[][] = []
|
|
|
|
|
for (let i = 0; i < results.length - 1; i++) {
|
|
|
|
|
if (Math.abs(results[i].weightedTotal - results[i + 1].weightedTotal) < 0.001) {
|
|
|
|
|
const tieGroup = [results[i].project?.id, results[i + 1].project?.id].filter(Boolean) as string[]
|
|
|
|
|
ties.push(tieGroup)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
session,
|
|
|
|
|
results,
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
ties,
|
|
|
|
|
tieBreakerMethod: session.tieBreakerMethod,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update presentation settings for a live voting session
|
|
|
|
|
*/
|
|
|
|
|
updatePresentationSettings: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
presentationSettingsJson: z.object({
|
|
|
|
|
theme: z.string().optional(),
|
|
|
|
|
autoAdvance: z.boolean().optional(),
|
|
|
|
|
autoAdvanceDelay: z.number().int().min(5).max(120).optional(),
|
|
|
|
|
scoreDisplayFormat: z.enum(['bar', 'number', 'radial']).optional(),
|
|
|
|
|
showVoteCount: z.boolean().optional(),
|
|
|
|
|
brandingOverlay: z.string().optional(),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
data: {
|
|
|
|
|
presentationSettingsJson: input.presentationSettingsJson,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update session config (audience voting, tie-breaker)
|
|
|
|
|
*/
|
|
|
|
|
updateSessionConfig: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
allowAudienceVotes: z.boolean().optional(),
|
|
|
|
|
audienceVoteWeight: z.number().min(0).max(1).optional(),
|
|
|
|
|
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { sessionId, ...data } = input
|
|
|
|
|
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
|
|
|
where: { id: sessionId },
|
|
|
|
|
data,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
try {
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE_SESSION_CONFIG',
|
|
|
|
|
entityType: 'LiveVotingSession',
|
|
|
|
|
entityId: sessionId,
|
|
|
|
|
detailsJson: data,
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Audit log errors should never break the operation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cast an audience vote
|
|
|
|
|
*/
|
|
|
|
|
castAudienceVote: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
score: z.number().int().min(1).max(10),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify session is in progress and allows audience votes
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (session.status !== 'IN_PROGRESS') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Voting is not currently active',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!session.allowAudienceVotes) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Audience voting is not enabled for this session',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.currentProjectId !== input.projectId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot vote for this project right now',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Voting window has closed',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upsert audience vote
|
|
|
|
|
const vote = await ctx.prisma.liveVote.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
sessionId_projectId_userId: {
|
|
|
|
|
sessionId: input.sessionId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
sessionId: input.sessionId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
score: input.score,
|
|
|
|
|
isAudienceVote: true,
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
score: input.score,
|
|
|
|
|
votedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return vote
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get public results for a live voting session (no auth required)
|
|
|
|
|
*/
|
|
|
|
|
getPublicResults: publicProcedure
|
|
|
|
|
.input(z.object({ sessionId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.sessionId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
status: true,
|
|
|
|
|
currentProjectId: true,
|
|
|
|
|
votingEndsAt: true,
|
|
|
|
|
presentationSettingsJson: true,
|
|
|
|
|
allowAudienceVotes: true,
|
|
|
|
|
audienceVoteWeight: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Only return data if session is in progress or completed
|
|
|
|
|
if (session.status !== 'IN_PROGRESS' && session.status !== 'COMPLETED') {
|
|
|
|
|
return {
|
|
|
|
|
session: {
|
|
|
|
|
id: session.id,
|
|
|
|
|
status: session.status,
|
|
|
|
|
presentationSettings: session.presentationSettingsJson,
|
|
|
|
|
},
|
|
|
|
|
projects: [],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get all votes grouped by project (anonymized - no user data)
|
|
|
|
|
const scores = await ctx.prisma.liveVote.groupBy({
|
|
|
|
|
by: ['projectId'],
|
|
|
|
|
where: { sessionId: input.sessionId },
|
|
|
|
|
_avg: { score: true },
|
|
|
|
|
_count: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const projectIds = scores.map((s) => s.projectId)
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: { id: { in: projectIds } },
|
|
|
|
|
select: { id: true, title: true, teamName: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const projectsWithScores = scores.map((score) => {
|
|
|
|
|
const project = projects.find((p) => p.id === score.projectId)
|
|
|
|
|
return {
|
|
|
|
|
id: project?.id,
|
|
|
|
|
title: project?.title,
|
|
|
|
|
teamName: project?.teamName,
|
|
|
|
|
averageScore: score._avg.score || 0,
|
|
|
|
|
voteCount: score._count,
|
|
|
|
|
}
|
|
|
|
|
}).sort((a, b) => b.averageScore - a.averageScore)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
session: {
|
|
|
|
|
id: session.id,
|
|
|
|
|
status: session.status,
|
|
|
|
|
currentProjectId: session.currentProjectId,
|
|
|
|
|
votingEndsAt: session.votingEndsAt,
|
|
|
|
|
presentationSettings: session.presentationSettingsJson,
|
|
|
|
|
allowAudienceVotes: session.allowAudienceVotes,
|
|
|
|
|
},
|
|
|
|
|
projects: projectsWithScores,
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
})
|