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:
2026-02-05 23:31:41 +01:00
parent f038c95777
commit 59436ed67a
68 changed files with 14541 additions and 546 deletions

View File

@@ -4,20 +4,22 @@
* Calculates scores for jury/mentor-project matching based on:
* - Tag overlap (expertise match)
* - Bio/description match (text similarity)
* - Workload balance
* - Workload balance (respects preferredWorkload and maxAssignments)
* - Country match (mentors only)
* - Geographic diversity penalty (prevents clustering by country)
* - Previous round familiarity bonus (continuity across rounds)
* - COI penalty (conflict of interest hard-block)
* - Availability window check (F2: penalizes jurors unavailable during voting)
*
* Score Breakdown:
* - Tag overlap: 0-40 points (weighted by confidence)
* - Bio match: 0-15 points (if bio exists)
* - Workload balance: 0-25 points
* - Workload balance: 0-25 points (uses preferredWorkload as soft target)
* - Country match: 0-15 points (mentors only)
* - Geo diversity: -15 per excess same-country assignment (threshold: 2)
* - Previous round familiarity: +10 if reviewed in earlier round
* - COI: juror skipped entirely if conflict declared
* - Availability: -30 if unavailable during voting window
*/
import { prisma } from '@/lib/prisma'
@@ -32,6 +34,7 @@ export interface ScoreBreakdown {
geoDiversityPenalty: number
previousRoundFamiliarity: number
coiPenalty: number
availabilityPenalty: number
}
export interface AssignmentScore {
@@ -65,6 +68,7 @@ const GEO_DIVERSITY_THRESHOLD = 2
const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
const AVAILABILITY_PENALTY = -30 // Heavy penalty for unavailable jurors
// Common words to exclude from bio matching
const STOP_WORDS = new Set([
@@ -224,6 +228,45 @@ export function calculateCountryMatchScore(
return 0
}
/**
* Check if a user is available during the round's voting window.
* availabilityJson is an array of { start, end } date-range objects
* representing when the user IS available.
* Returns 0 (available) or AVAILABILITY_PENALTY (unavailable).
*/
export function calculateAvailabilityPenalty(
availabilityJson: unknown,
votingStartAt: Date | null | undefined,
votingEndAt: Date | null | undefined
): number {
// If no availability windows set, user is always available
if (!availabilityJson || !Array.isArray(availabilityJson) || availabilityJson.length === 0) {
return 0
}
// If no voting window defined, can't check availability
if (!votingStartAt || !votingEndAt) {
return 0
}
// Check if any availability window overlaps with the voting window
for (const window of availabilityJson) {
if (!window || typeof window !== 'object') continue
const start = new Date((window as { start: string }).start)
const end = new Date((window as { end: string }).end)
if (isNaN(start.getTime()) || isNaN(end.getTime())) continue
// Check overlap: user available window overlaps with voting window
if (start <= votingEndAt && end >= votingStartAt) {
return 0 // Available during at least part of the voting window
}
}
// No availability window overlaps with voting window
return AVAILABILITY_PENALTY
}
// ─── Main Scoring Function ───────────────────────────────────────────────────
/**
@@ -275,6 +318,8 @@ export async function getSmartSuggestions(options: {
expertiseTags: true,
maxAssignments: true,
country: true,
availabilityJson: true,
preferredWorkload: true,
_count: {
select: {
assignments: {
@@ -289,6 +334,12 @@ export async function getSmartSuggestions(options: {
return []
}
// Get round voting window for availability checking
const roundForAvailability = await prisma.round.findUnique({
where: { id: roundId },
select: { votingStartAt: true, votingEndAt: true },
})
// Get existing assignments to avoid duplicates
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
@@ -399,9 +450,12 @@ export async function getSmartSuggestions(options: {
project.description
)
// Use preferredWorkload as a soft target when available, fallback to calculated target
const effectiveTarget = user.preferredWorkload ?? targetPerUser
const workloadScore = calculateWorkloadScore(
currentCount,
targetPerUser,
effectiveTarget,
user.maxAssignments
)
@@ -410,6 +464,13 @@ export async function getSmartSuggestions(options: {
? calculateCountryMatchScore(user.country, project.country)
: 0
// Availability check against the round's voting window
const availabilityPenalty = calculateAvailabilityPenalty(
user.availabilityJson,
roundForAvailability?.votingStartAt,
roundForAvailability?.votingEndAt
)
// ── New scoring factors ─────────────────────────────────────────────
// Geographic diversity penalty
@@ -437,7 +498,8 @@ export async function getSmartSuggestions(options: {
workloadScore +
countryScore +
geoDiversityPenalty +
previousRoundFamiliarity
previousRoundFamiliarity +
availabilityPenalty
// Build reasoning
const reasoning: string[] = []
@@ -452,6 +514,9 @@ export async function getSmartSuggestions(options: {
} else if (workloadScore > 0) {
reasoning.push('Moderate workload')
}
if (user.preferredWorkload) {
reasoning.push(`Preferred workload: ${user.preferredWorkload}`)
}
if (countryScore > 0) {
reasoning.push('Same country')
}
@@ -461,6 +526,9 @@ export async function getSmartSuggestions(options: {
if (previousRoundFamiliarity > 0) {
reasoning.push('Reviewed in previous round (+10)')
}
if (availabilityPenalty < 0) {
reasoning.push(`Unavailable during voting window (${availabilityPenalty})`)
}
suggestions.push({
userId: user.id,
@@ -477,6 +545,7 @@ export async function getSmartSuggestions(options: {
geoDiversityPenalty,
previousRoundFamiliarity,
coiPenalty: 0, // COI jurors are skipped entirely
availabilityPenalty,
},
reasoning,
matchingTags,
@@ -602,6 +671,7 @@ export async function getMentorSuggestionsForProject(
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty: 0,
},
reasoning,
matchingTags,