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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,421 @@
/**
* AI-Powered Assignment Service
*
* Uses GPT to analyze juror expertise and project requirements
* to generate optimal assignment suggestions.
*/
import { getOpenAI, AI_MODELS } from '@/lib/openai'
import {
anonymizeForAI,
deanonymizeResults,
validateAnonymization,
type AnonymizationResult,
} from './anonymization'
// Types for AI assignment
export interface AIAssignmentSuggestion {
jurorId: string
projectId: string
confidenceScore: number // 0-1
reasoning: string
expertiseMatchScore: number // 0-1
}
export interface AIAssignmentResult {
success: boolean
suggestions: AIAssignmentSuggestion[]
error?: string
tokensUsed?: number
fallbackUsed?: boolean
}
interface JurorForAssignment {
id: string
name?: string | null
email: string
expertiseTags: string[]
maxAssignments?: number | null
_count?: {
assignments: number
}
}
interface ProjectForAssignment {
id: string
title: string
description?: string | null
tags: string[]
teamName?: string | null
_count?: {
assignments: number
}
}
interface AssignmentConstraints {
requiredReviewsPerProject: number
maxAssignmentsPerJuror?: number
existingAssignments: Array<{
jurorId: string
projectId: string
}>
}
/**
* System prompt for AI assignment
*/
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert at matching jury members to projects based on expertise alignment.
Your task is to suggest optimal juror-project assignments that:
1. Match juror expertise tags with project tags and content
2. Distribute workload fairly among jurors
3. Ensure each project gets the required number of reviews
4. Avoid assigning jurors who are already at their limit
For each suggestion, provide:
- A confidence score (0-1) based on how well the juror's expertise matches the project
- An expertise match score (0-1) based purely on tag/content alignment
- A brief reasoning explaining why this is a good match
Return your response as a JSON array of assignments.`
/**
* Generate AI-powered assignment suggestions
*/
export async function generateAIAssignments(
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints
): Promise<AIAssignmentResult> {
// Anonymize data before sending to AI
const anonymizedData = anonymizeForAI(jurors, projects)
// Validate anonymization
if (!validateAnonymization(anonymizedData)) {
console.error('Anonymization validation failed, falling back to algorithm')
return generateFallbackAssignments(jurors, projects, constraints)
}
try {
const openai = await getOpenAI()
if (!openai) {
console.log('OpenAI not configured, using fallback algorithm')
return generateFallbackAssignments(jurors, projects, constraints)
}
const suggestions = await callAIForAssignments(
openai,
anonymizedData,
constraints
)
// De-anonymize results
const deanonymizedSuggestions = deanonymizeResults(
suggestions.map((s) => ({
...s,
jurorId: s.jurorId,
projectId: s.projectId,
})),
anonymizedData.jurorMappings,
anonymizedData.projectMappings
).map((s) => ({
jurorId: s.realJurorId,
projectId: s.realProjectId,
confidenceScore: s.confidenceScore,
reasoning: s.reasoning,
expertiseMatchScore: s.expertiseMatchScore,
}))
return {
success: true,
suggestions: deanonymizedSuggestions,
fallbackUsed: false,
}
} catch (error) {
console.error('AI assignment failed, using fallback:', error)
return generateFallbackAssignments(jurors, projects, constraints)
}
}
/**
* Call OpenAI API for assignment suggestions
*/
async function callAIForAssignments(
openai: Awaited<ReturnType<typeof getOpenAI>>,
anonymizedData: AnonymizationResult,
constraints: AssignmentConstraints
): Promise<AIAssignmentSuggestion[]> {
if (!openai) {
throw new Error('OpenAI client not available')
}
// Build the user prompt
const userPrompt = buildAssignmentPrompt(anonymizedData, constraints)
const response = await openai.chat.completions.create({
model: AI_MODELS.ASSIGNMENT,
messages: [
{ role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
response_format: { type: 'json_object' },
temperature: 0.3, // Lower temperature for more consistent results
max_tokens: 4000,
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('No response from AI')
}
// Parse the response
const parsed = JSON.parse(content) as {
assignments: Array<{
juror_id: string
project_id: string
confidence_score: number
expertise_match_score: number
reasoning: string
}>
}
return (parsed.assignments || []).map((a) => ({
jurorId: a.juror_id,
projectId: a.project_id,
confidenceScore: Math.min(1, Math.max(0, a.confidence_score)),
expertiseMatchScore: Math.min(1, Math.max(0, a.expertise_match_score)),
reasoning: a.reasoning,
}))
}
/**
* Build the prompt for AI assignment
*/
function buildAssignmentPrompt(
data: AnonymizationResult,
constraints: AssignmentConstraints
): string {
const { jurors, projects } = data
// Map existing assignments to anonymous IDs
const jurorIdMap = new Map(
data.jurorMappings.map((m) => [m.realId, m.anonymousId])
)
const projectIdMap = new Map(
data.projectMappings.map((m) => [m.realId, m.anonymousId])
)
const anonymousExisting = constraints.existingAssignments
.map((a) => ({
jurorId: jurorIdMap.get(a.jurorId),
projectId: projectIdMap.get(a.projectId),
}))
.filter((a) => a.jurorId && a.projectId)
return `## Jurors Available
${JSON.stringify(jurors, null, 2)}
## Projects to Assign
${JSON.stringify(projects, null, 2)}
## Constraints
- Each project needs ${constraints.requiredReviewsPerProject} reviews
- Maximum assignments per juror: ${constraints.maxAssignmentsPerJuror || 'No limit'}
- Existing assignments to avoid duplicating:
${JSON.stringify(anonymousExisting, null, 2)}
## Instructions
Generate optimal juror-project assignments. Return a JSON object with an "assignments" array where each assignment has:
- juror_id: The anonymous juror ID
- project_id: The anonymous project ID
- confidence_score: 0-1 confidence in this match
- expertise_match_score: 0-1 expertise alignment score
- reasoning: Brief explanation (1-2 sentences)
Focus on matching expertise tags with project tags and descriptions. Distribute assignments fairly.`
}
/**
* Fallback algorithm-based assignment when AI is unavailable
*/
export function generateFallbackAssignments(
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints
): AIAssignmentResult {
const suggestions: AIAssignmentSuggestion[] = []
const existingSet = new Set(
constraints.existingAssignments.map((a) => `${a.jurorId}:${a.projectId}`)
)
// Track assignments per juror and project
const jurorAssignments = new Map<string, number>()
const projectAssignments = new Map<string, number>()
// Initialize counts from existing assignments
for (const assignment of constraints.existingAssignments) {
jurorAssignments.set(
assignment.jurorId,
(jurorAssignments.get(assignment.jurorId) || 0) + 1
)
projectAssignments.set(
assignment.projectId,
(projectAssignments.get(assignment.projectId) || 0) + 1
)
}
// Also include current assignment counts
for (const juror of jurors) {
const current = juror._count?.assignments || 0
jurorAssignments.set(
juror.id,
Math.max(jurorAssignments.get(juror.id) || 0, current)
)
}
for (const project of projects) {
const current = project._count?.assignments || 0
projectAssignments.set(
project.id,
Math.max(projectAssignments.get(project.id) || 0, current)
)
}
// Sort projects by need (fewest assignments first)
const sortedProjects = [...projects].sort((a, b) => {
const aCount = projectAssignments.get(a.id) || 0
const bCount = projectAssignments.get(b.id) || 0
return aCount - bCount
})
// For each project, find best matching jurors
for (const project of sortedProjects) {
const currentProjectAssignments = projectAssignments.get(project.id) || 0
const neededReviews = Math.max(
0,
constraints.requiredReviewsPerProject - currentProjectAssignments
)
if (neededReviews === 0) continue
// Score all available jurors
const scoredJurors = jurors
.filter((juror) => {
// Check not already assigned
if (existingSet.has(`${juror.id}:${project.id}`)) return false
// Check not at limit
const currentAssignments = jurorAssignments.get(juror.id) || 0
const maxAssignments =
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? Infinity
if (currentAssignments >= maxAssignments) return false
return true
})
.map((juror) => ({
juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
loadScore: calculateLoadScore(
jurorAssignments.get(juror.id) || 0,
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10
),
}))
.sort((a, b) => {
// Combined score: 60% expertise, 40% load balancing
const aTotal = a.score * 0.6 + a.loadScore * 0.4
const bTotal = b.score * 0.6 + b.loadScore * 0.4
return bTotal - aTotal
})
// Assign top jurors
for (let i = 0; i < Math.min(neededReviews, scoredJurors.length); i++) {
const { juror, score } = scoredJurors[i]
suggestions.push({
jurorId: juror.id,
projectId: project.id,
confidenceScore: score,
expertiseMatchScore: score,
reasoning: generateFallbackReasoning(
juror.expertiseTags,
project.tags,
score
),
})
// Update tracking
existingSet.add(`${juror.id}:${project.id}`)
jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
projectAssignments.set(
project.id,
(projectAssignments.get(project.id) || 0) + 1
)
}
}
return {
success: true,
suggestions,
fallbackUsed: true,
}
}
/**
* Calculate expertise match score based on tag overlap
*/
function calculateExpertiseScore(
jurorTags: string[],
projectTags: string[]
): number {
if (jurorTags.length === 0 || projectTags.length === 0) {
return 0.5 // Neutral score if no tags
}
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
// Score based on percentage of project tags matched
const matchRatio = matchingTags.length / projectTags.length
// Boost for having expertise, even if not all match
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
return Math.min(1, matchRatio * 0.8 + hasExpertise)
}
/**
* Calculate load balancing score (higher score = less loaded)
*/
function calculateLoadScore(currentLoad: number, maxLoad: number): number {
if (maxLoad === 0) return 0
const utilization = currentLoad / maxLoad
return Math.max(0, 1 - utilization)
}
/**
* Generate reasoning for fallback assignments
*/
function generateFallbackReasoning(
jurorTags: string[],
projectTags: string[],
score: number
): string {
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
if (matchingTags.length > 0) {
return `Expertise match: ${matchingTags.join(', ')}. Match score: ${(score * 100).toFixed(0)}%.`
}
if (score >= 0.5) {
return `Assigned for workload balance. No direct expertise match but available capacity.`
}
return `Assigned to ensure project coverage.`
}

View File

@@ -0,0 +1,211 @@
/**
* Data Anonymization Service
*
* Strips PII (names, emails, etc.) from data before sending to AI services.
* Returns ID mappings for de-anonymization of results.
*/
export interface AnonymizedJuror {
anonymousId: string
expertiseTags: string[]
currentAssignmentCount: number
maxAssignments: number | null
}
export interface AnonymizedProject {
anonymousId: string
title: string
description: string | null
tags: string[]
teamName: string | null
}
export interface JurorMapping {
anonymousId: string
realId: string
}
export interface ProjectMapping {
anonymousId: string
realId: string
}
export interface AnonymizationResult {
jurors: AnonymizedJuror[]
projects: AnonymizedProject[]
jurorMappings: JurorMapping[]
projectMappings: ProjectMapping[]
}
/**
* Juror data from database
*/
interface JurorInput {
id: string
name?: string | null
email: string
expertiseTags: string[]
maxAssignments?: number | null
_count?: {
assignments: number
}
}
/**
* Project data from database
*/
interface ProjectInput {
id: string
title: string
description?: string | null
tags: string[]
teamName?: string | null
}
/**
* Anonymize juror and project data for AI processing
*
* This function:
* 1. Strips all PII (names, emails) from juror data
* 2. Replaces real IDs with sequential anonymous IDs
* 3. Keeps only expertise tags and assignment counts
* 4. Returns mappings for de-anonymization
*/
export function anonymizeForAI(
jurors: JurorInput[],
projects: ProjectInput[]
): AnonymizationResult {
const jurorMappings: JurorMapping[] = []
const projectMappings: ProjectMapping[] = []
// Anonymize jurors
const anonymizedJurors: AnonymizedJuror[] = jurors.map((juror, index) => {
const anonymousId = `juror_${(index + 1).toString().padStart(3, '0')}`
jurorMappings.push({
anonymousId,
realId: juror.id,
})
return {
anonymousId,
expertiseTags: juror.expertiseTags,
currentAssignmentCount: juror._count?.assignments ?? 0,
maxAssignments: juror.maxAssignments ?? null,
}
})
// Anonymize projects (keep content but replace IDs)
const anonymizedProjects: AnonymizedProject[] = projects.map(
(project, index) => {
const anonymousId = `project_${(index + 1).toString().padStart(3, '0')}`
projectMappings.push({
anonymousId,
realId: project.id,
})
return {
anonymousId,
title: sanitizeText(project.title),
description: project.description
? sanitizeText(project.description)
: null,
tags: project.tags,
// Replace specific team names with generic identifier
teamName: project.teamName ? `Team ${index + 1}` : null,
}
}
)
return {
jurors: anonymizedJurors,
projects: anonymizedProjects,
jurorMappings,
projectMappings,
}
}
/**
* De-anonymize AI results back to real IDs
*/
export function deanonymizeResults<T extends { jurorId: string; projectId: string }>(
results: T[],
jurorMappings: JurorMapping[],
projectMappings: ProjectMapping[]
): (T & { realJurorId: string; realProjectId: string })[] {
const jurorMap = new Map(
jurorMappings.map((m) => [m.anonymousId, m.realId])
)
const projectMap = new Map(
projectMappings.map((m) => [m.anonymousId, m.realId])
)
return results.map((result) => ({
...result,
realJurorId: jurorMap.get(result.jurorId) || result.jurorId,
realProjectId: projectMap.get(result.projectId) || result.projectId,
}))
}
/**
* Sanitize text to remove potential PII patterns
* Removes emails, phone numbers, and URLs from text
*/
function sanitizeText(text: string): string {
// Remove email addresses
let sanitized = text.replace(
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
'[email removed]'
)
// Remove phone numbers (various formats)
sanitized = sanitized.replace(
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g,
'[phone removed]'
)
// Remove URLs
sanitized = sanitized.replace(
/https?:\/\/[^\s]+/g,
'[url removed]'
)
return sanitized
}
/**
* Validate that data has been properly anonymized
* Returns true if no PII patterns are detected
*/
export function validateAnonymization(data: AnonymizationResult): boolean {
const piiPatterns = [
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, // Email
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/, // Phone
]
const checkText = (text: string | null | undefined): boolean => {
if (!text) return true
return !piiPatterns.some((pattern) => pattern.test(text))
}
// Check jurors (they should only have expertise tags)
for (const juror of data.jurors) {
// Jurors should not have any text fields that could contain PII
// Only check expertiseTags
for (const tag of juror.expertiseTags) {
if (!checkText(tag)) return false
}
}
// Check projects
for (const project of data.projects) {
if (!checkText(project.title)) return false
if (!checkText(project.description)) return false
for (const tag of project.tags) {
if (!checkText(tag)) return false
}
}
return true
}

View File

@@ -0,0 +1,332 @@
import { PrismaClient, OceanIssue, CompetitionCategory } from '@prisma/client'
import OpenAI from 'openai'
// Lazy initialization to avoid errors when API key is not set
let openaiClient: OpenAI | null = null
function getOpenAIClient(): OpenAI | null {
if (!process.env.OPENAI_API_KEY) {
return null
}
if (!openaiClient) {
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
}
return openaiClient
}
interface ProjectInfo {
id: string
title: string
description: string | null
oceanIssue: OceanIssue | null
competitionCategory: CompetitionCategory | null
tags: string[]
}
interface MentorInfo {
id: string
name: string | null
email: string
expertiseTags: string[]
currentAssignments: number
maxAssignments: number | null
}
interface MentorMatch {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}
/**
* Get AI-suggested mentor matches for a project
*/
export async function getAIMentorSuggestions(
prisma: PrismaClient,
projectId: string,
limit: number = 5
): Promise<MentorMatch[]> {
// Get project details
const project = await prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: {
id: true,
title: true,
description: true,
oceanIssue: true,
competitionCategory: true,
tags: true,
},
})
// Get available mentors (users with expertise tags)
// In a full implementation, you'd have a MENTOR role
// For now, we use users with expertiseTags and consider them potential mentors
const mentors = await prisma.user.findMany({
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' }, // Jury members can also be mentors
],
status: 'ACTIVE',
},
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: {
select: { id: true },
},
},
})
// Filter mentors who haven't reached max assignments
const availableMentors: MentorInfo[] = mentors
.filter((m) => {
const currentAssignments = m.mentorAssignments.length
return !m.maxAssignments || currentAssignments < m.maxAssignments
})
.map((m) => ({
id: m.id,
name: m.name,
email: m.email,
expertiseTags: m.expertiseTags,
currentAssignments: m.mentorAssignments.length,
maxAssignments: m.maxAssignments,
}))
if (availableMentors.length === 0) {
return []
}
// Try AI matching if API key is configured
if (process.env.OPENAI_API_KEY) {
try {
return await getAIMatches(project, availableMentors, limit)
} catch (error) {
console.error('AI mentor matching failed, falling back to algorithm:', error)
}
}
// Fallback to algorithmic matching
return getAlgorithmicMatches(project, availableMentors, limit)
}
/**
* Use OpenAI to match mentors to projects
*/
async function getAIMatches(
project: ProjectInfo,
mentors: MentorInfo[],
limit: number
): Promise<MentorMatch[]> {
// Anonymize data before sending to AI
const anonymizedProject = {
description: project.description?.slice(0, 500) || 'No description',
category: project.competitionCategory,
oceanIssue: project.oceanIssue,
tags: project.tags,
}
const anonymizedMentors = mentors.map((m, index) => ({
index,
expertise: m.expertiseTags,
availability: m.maxAssignments
? `${m.currentAssignments}/${m.maxAssignments}`
: 'unlimited',
}))
const prompt = `You are matching mentors to an ocean protection project.
PROJECT:
- Category: ${anonymizedProject.category || 'Not specified'}
- Ocean Issue: ${anonymizedProject.oceanIssue || 'Not specified'}
- Tags: ${anonymizedProject.tags.join(', ') || 'None'}
- Description: ${anonymizedProject.description}
AVAILABLE MENTORS:
${anonymizedMentors.map((m) => `${m.index}: Expertise: [${m.expertise.join(', ')}], Availability: ${m.availability}`).join('\n')}
Rank the top ${limit} mentors by suitability. For each, provide:
1. Mentor index (0-based)
2. Confidence score (0-1)
3. Expertise match score (0-1)
4. Brief reasoning (1-2 sentences)
Respond in JSON format:
{
"matches": [
{
"mentorIndex": 0,
"confidenceScore": 0.85,
"expertiseMatchScore": 0.9,
"reasoning": "Strong expertise alignment..."
}
]
}`
const openai = getOpenAIClient()
if (!openai) {
throw new Error('OpenAI client not available')
}
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are an expert at matching mentors to projects based on expertise alignment. Always respond with valid JSON.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
max_tokens: 1000,
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('No response from AI')
}
const parsed = JSON.parse(content) as {
matches: Array<{
mentorIndex: number
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}>
}
return parsed.matches
.filter((m) => m.mentorIndex >= 0 && m.mentorIndex < mentors.length)
.map((m) => ({
mentorId: mentors[m.mentorIndex].id,
confidenceScore: m.confidenceScore,
expertiseMatchScore: m.expertiseMatchScore,
reasoning: m.reasoning,
}))
}
/**
* Algorithmic fallback for mentor matching
*/
function getAlgorithmicMatches(
project: ProjectInfo,
mentors: MentorInfo[],
limit: number
): MentorMatch[] {
// Build keyword set from project
const projectKeywords = new Set<string>()
if (project.oceanIssue) {
projectKeywords.add(project.oceanIssue.toLowerCase().replace(/_/g, ' '))
}
if (project.competitionCategory) {
projectKeywords.add(project.competitionCategory.toLowerCase().replace(/_/g, ' '))
}
project.tags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((word) => {
if (word.length > 3) projectKeywords.add(word)
})
})
if (project.description) {
// Extract key words from description
const words = project.description.toLowerCase().split(/\s+/)
words.forEach((word) => {
if (word.length > 4) projectKeywords.add(word.replace(/[^a-z]/g, ''))
})
}
// Score each mentor
const scored = mentors.map((mentor) => {
const mentorKeywords = new Set<string>()
mentor.expertiseTags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((word) => {
if (word.length > 2) mentorKeywords.add(word)
})
})
// Calculate overlap
let matchCount = 0
projectKeywords.forEach((keyword) => {
mentorKeywords.forEach((mentorKeyword) => {
if (keyword.includes(mentorKeyword) || mentorKeyword.includes(keyword)) {
matchCount++
}
})
})
const expertiseMatchScore = mentorKeywords.size > 0
? Math.min(1, matchCount / mentorKeywords.size)
: 0
// Factor in availability
const availabilityScore = mentor.maxAssignments
? 1 - (mentor.currentAssignments / mentor.maxAssignments)
: 1
const confidenceScore = (expertiseMatchScore * 0.7 + availabilityScore * 0.3)
return {
mentorId: mentor.id,
confidenceScore: Math.round(confidenceScore * 100) / 100,
expertiseMatchScore: Math.round(expertiseMatchScore * 100) / 100,
reasoning: `Matched ${matchCount} keyword(s) with mentor expertise. Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
}
})
// Sort by confidence and return top matches
return scored
.sort((a, b) => b.confidenceScore - a.confidenceScore)
.slice(0, limit)
}
/**
* Round-robin assignment for load balancing
*/
export async function getRoundRobinMentor(
prisma: PrismaClient,
excludeMentorIds: string[] = []
): Promise<string | null> {
const mentors = await prisma.user.findMany({
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' },
],
status: 'ACTIVE',
id: { notIn: excludeMentorIds },
},
select: {
id: true,
maxAssignments: true,
mentorAssignments: {
select: { id: true },
},
},
orderBy: {
mentorAssignments: {
_count: 'asc',
},
},
})
// Find mentor with fewest assignments who hasn't reached max
for (const mentor of mentors) {
const currentCount = mentor.mentorAssignments.length
if (!mentor.maxAssignments || currentCount < mentor.maxAssignments) {
return mentor.id
}
}
return null
}

View File

@@ -0,0 +1,321 @@
/**
* Unified Notification Service
*
* Handles sending notifications via multiple channels:
* - Email (via nodemailer)
* - WhatsApp (via Meta or Twilio)
*/
import { prisma } from '@/lib/prisma'
import {
sendMagicLinkEmail,
sendJuryInvitationEmail,
sendEvaluationReminderEmail,
sendAnnouncementEmail,
} from '@/lib/email'
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import type { NotificationChannel } from '@prisma/client'
export type NotificationType =
| 'MAGIC_LINK'
| 'JURY_INVITATION'
| 'EVALUATION_REMINDER'
| 'ANNOUNCEMENT'
interface NotificationResult {
success: boolean
channels: {
email?: { success: boolean; error?: string }
whatsapp?: { success: boolean; messageId?: string; error?: string }
}
}
/**
* Send a notification to a user based on their preferences
*/
export async function sendNotification(
userId: string,
type: NotificationType,
data: Record<string, string>
): Promise<NotificationResult> {
// Get user with notification preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
phoneNumber: true,
notificationPreference: true,
whatsappOptIn: true,
},
})
if (!user) {
return {
success: false,
channels: {},
}
}
const result: NotificationResult = {
success: true,
channels: {},
}
const preference = user.notificationPreference
// Determine which channels to use
const sendEmail = preference === 'EMAIL' || preference === 'BOTH'
const sendWhatsApp =
(preference === 'WHATSAPP' || preference === 'BOTH') &&
user.whatsappOptIn &&
user.phoneNumber
// Send via email
if (sendEmail) {
const emailResult = await sendEmailNotification(user.email, user.name, type, data)
result.channels.email = emailResult
// Log the notification
await logNotification(user.id, 'EMAIL', 'SMTP', type, emailResult)
}
// Send via WhatsApp
if (sendWhatsApp && user.phoneNumber) {
const whatsappResult = await sendWhatsAppNotification(
user.phoneNumber,
user.name,
type,
data
)
result.channels.whatsapp = whatsappResult
// Log the notification
const providerType = await getWhatsAppProviderType()
await logNotification(
user.id,
'WHATSAPP',
providerType || 'UNKNOWN',
type,
whatsappResult
)
}
// Overall success if at least one channel succeeded
result.success =
(result.channels.email?.success ?? true) ||
(result.channels.whatsapp?.success ?? true)
return result
}
/**
* Send email notification
*/
async function sendEmailNotification(
email: string,
name: string | null,
type: NotificationType,
data: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
try {
switch (type) {
case 'MAGIC_LINK':
await sendMagicLinkEmail(email, data.url)
return { success: true }
case 'JURY_INVITATION':
await sendJuryInvitationEmail(
email,
data.inviteUrl,
data.programName,
data.roundName
)
return { success: true }
case 'EVALUATION_REMINDER':
await sendEvaluationReminderEmail(
email,
name,
parseInt(data.pendingCount || '0'),
data.roundName || 'Current Round',
data.deadline || 'Soon',
data.assignmentsUrl || `${process.env.NEXTAUTH_URL}/jury/assignments`
)
return { success: true }
case 'ANNOUNCEMENT':
await sendAnnouncementEmail(
email,
name,
data.title || 'Announcement',
data.message || '',
data.ctaText,
data.ctaUrl
)
return { success: true }
default:
return { success: false, error: `Unknown notification type: ${type}` }
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Email send failed',
}
}
}
/**
* Send WhatsApp notification
*/
async function sendWhatsAppNotification(
phoneNumber: string,
name: string | null,
type: NotificationType,
data: Record<string, string>
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const provider = await getWhatsAppProvider()
if (!provider) {
return { success: false, error: 'WhatsApp not configured' }
}
try {
// Map notification types to templates
const templateMap: Record<NotificationType, string> = {
MAGIC_LINK: 'mopc_magic_link',
JURY_INVITATION: 'mopc_jury_invitation',
EVALUATION_REMINDER: 'mopc_evaluation_reminder',
ANNOUNCEMENT: 'mopc_announcement',
}
const template = templateMap[type]
// Build template params
const params: Record<string, string> = {
name: name || 'User',
...data,
}
const result = await provider.sendTemplate(phoneNumber, template, params)
return result
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'WhatsApp send failed',
}
}
}
/**
* Log notification to database
*/
async function logNotification(
userId: string,
channel: NotificationChannel,
provider: string,
type: NotificationType,
result: { success: boolean; messageId?: string; error?: string }
): Promise<void> {
try {
await prisma.notificationLog.create({
data: {
userId,
channel,
provider,
type,
status: result.success ? 'SENT' : 'FAILED',
externalId: result.messageId,
errorMsg: result.error,
},
})
} catch (error) {
console.error('Failed to log notification:', error)
}
}
/**
* Send bulk notifications to multiple users
*/
export async function sendBulkNotification(
userIds: string[],
type: NotificationType,
data: Record<string, string>
): Promise<{ sent: number; failed: number }> {
let sent = 0
let failed = 0
for (const userId of userIds) {
const result = await sendNotification(userId, type, data)
if (result.success) {
sent++
} else {
failed++
}
}
return { sent, failed }
}
/**
* Get notification statistics
*/
export async function getNotificationStats(options?: {
userId?: string
startDate?: Date
endDate?: Date
}): Promise<{
total: number
byChannel: Record<string, number>
byStatus: Record<string, number>
byType: Record<string, number>
}> {
const where: Record<string, unknown> = {}
if (options?.userId) {
where.userId = options.userId
}
if (options?.startDate || options?.endDate) {
where.createdAt = {}
if (options.startDate) {
(where.createdAt as Record<string, Date>).gte = options.startDate
}
if (options.endDate) {
(where.createdAt as Record<string, Date>).lte = options.endDate
}
}
const [total, byChannel, byStatus, byType] = await Promise.all([
prisma.notificationLog.count({ where }),
prisma.notificationLog.groupBy({
by: ['channel'],
where,
_count: true,
}),
prisma.notificationLog.groupBy({
by: ['status'],
where,
_count: true,
}),
prisma.notificationLog.groupBy({
by: ['type'],
where,
_count: true,
}),
])
return {
total,
byChannel: Object.fromEntries(
byChannel.map((r) => [r.channel, r._count])
),
byStatus: Object.fromEntries(
byStatus.map((r) => [r.status, r._count])
),
byType: Object.fromEntries(
byType.map((r) => [r.type, r._count])
),
}
}