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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user