# Notifications & Deadlines ## Overview The Notifications & Deadlines system is a cross-cutting concern that supports every round type in the MOPC platform. It provides: 1. **Event-driven notifications** — In-app and email notifications triggered by pipeline events 2. **Deadline management** — Configurable deadline policies with grace periods 3. **Reminder scheduling** — Automated reminders at configurable intervals before deadlines 4. **Countdown timers** — Real-time visual countdowns for participants 5. **Admin controls** — Competition-wide and per-round notification configuration 6. **Multi-channel delivery** — Email, in-app, and future webhook integrations This system ensures that all participants (jury, applicants, mentors, admins) are informed about critical events and approaching deadlines across all round types. --- ## Current System Analysis ### How Notifications Work Today The current notification system (Phase 6 — Pipeline/Track/Stage architecture) has these components: #### 1. Event-Driven Notifications (stage-notifications.ts) **Core function:** ```typescript export async function emitStageEvent( eventType: string, // e.g., "stage.transitioned" entityType: string, // e.g., "ProjectStageState" entityId: string, // Entity ID actorId: string, // User who triggered the event details: StageEventDetails, prisma: PrismaClient ): Promise ``` **Process:** 1. Creates `DecisionAuditLog` entry (immutable event record) 2. Checks `NotificationPolicy` for the event type 3. Resolves recipients based on event type (admins, jury, etc.) 4. Creates `InAppNotification` records 5. Optionally sends emails via `sendStyledNotificationEmail()` 6. Never throws — all errors caught and logged **Current event types:** ```typescript const EVENT_TYPES = { STAGE_TRANSITIONED: 'stage.transitioned', FILTERING_COMPLETED: 'filtering.completed', ASSIGNMENT_GENERATED: 'assignment.generated', CURSOR_UPDATED: 'live.cursor_updated', DECISION_OVERRIDDEN: 'decision.overridden', } ``` **Notification flow:** ``` Pipeline Event ↓ stage-notifications.emitStageEvent() ↓ 1. DecisionAuditLog.create() 2. NotificationPolicy.findUnique(eventType) 3. resolveRecipients() → [NotificationTarget[]] 4. InAppNotification.createMany() 5. sendStyledNotificationEmail() (if channel = EMAIL or BOTH) ``` #### 2. Deadline Reminders (evaluation-reminders.ts) **Core function:** ```typescript export async function processEvaluationReminders( stageId?: string ): Promise ``` **Process:** 1. Finds active stages with `windowCloseAt` in the future 2. Calculates `msUntilDeadline` for each stage 3. Checks which reminder types apply (3_DAYS, 24H, 1H) 4. Finds jurors with incomplete assignments 5. Checks `ReminderLog` to avoid duplicates 6. Sends email via `sendStyledNotificationEmail()` 7. Creates `ReminderLog` entry **Reminder thresholds:** ```typescript const REMINDER_TYPES = [ { type: '3_DAYS', thresholdMs: 3 * 24 * 60 * 60 * 1000 }, { type: '24H', thresholdMs: 24 * 60 * 60 * 1000 }, { type: '1H', thresholdMs: 60 * 60 * 1000 }, ] ``` **Cron integration:** ``` External cron job (every 15 min) ↓ GET /api/cron/reminders ↓ Header: x-cron-secret = CRON_SECRET ↓ processEvaluationReminders() ↓ Returns: { ok: true, sent: number, errors: number } ``` #### 3. Grace Periods (GracePeriod model + gracePeriod router) **Model:** ```prisma model GracePeriod { id String @id @default(cuid()) stageId String userId String projectId String? // Optional: specific project or all in stage extendedUntil DateTime reason String? @db.Text grantedById String createdAt DateTime @default(now()) } ``` **Admin operations:** - `gracePeriod.grant({ stageId, userId, extendedUntil, reason })` — Grant to single user - `gracePeriod.bulkGrant({ stageId, userIds[], extendedUntil })` — Bulk grant - `gracePeriod.update({ id, extendedUntil, reason })` — Modify existing - `gracePeriod.revoke({ id })` — Delete grace period - `gracePeriod.listByStage({ stageId })` — View all for stage - `gracePeriod.listActiveByStage({ stageId })` — View active (extendedUntil >= now) **Usage in code:** Grace periods are checked during submission and evaluation deadline enforcement: ```typescript // Check if user has active grace period const gracePeriod = await prisma.gracePeriod.findFirst({ where: { stageId, userId, extendedUntil: { gte: new Date() }, }, }) const effectiveDeadline = gracePeriod?.extendedUntil ?? stage.windowCloseAt ``` #### 4. Countdown Timer Component (countdown-timer.tsx) **Client-side countdown:** ```tsx ``` **Features:** - Real-time countdown (updates every 1 second) - Color-coded urgency: - `expired`: Gray (deadline passed) - `critical`: Red (< 1 hour remaining) - `warning`: Amber (< 24 hours remaining) - `normal`: Green (> 24 hours remaining) - Adaptive display: - < 1 hour: "15m 30s" - < 24 hours: "5h 15m 30s" - > 24 hours: "3d 5h 15m" - Icons: Clock (normal/warning) or AlertTriangle (critical) #### 5. In-App Notification Center **Model:** ```prisma model InAppNotification { id String @id @default(cuid()) userId String type String // Event type priority String @default("normal") // low, normal, high, urgent icon String? // lucide icon name title String message String @db.Text linkUrl String? // Where to navigate when clicked linkLabel String? // CTA text metadata Json? @db.JsonB groupKey String? // For batching similar notifications isRead Boolean @default(false) readAt DateTime? expiresAt DateTime? // Auto-dismiss after date createdAt DateTime @default(now()) } ``` **Notification bell UI:** - Shows unread count badge - Dropdown with recent notifications (last 50) - "Mark all as read" action - Click notification → navigate to `linkUrl` - Auto-refresh via tRPC subscription or polling #### 6. Email Delivery (Nodemailer + Poste.io) **Email function:** ```typescript export async function sendStyledNotificationEmail( to: string, name: string, type: string, // Template type data: { title: string message: string linkUrl?: string metadata?: Record } ): Promise ``` **Email service:** - SMTP via Poste.io (`:587`) - HTML templates with brand colors (Red `#de0f1e`, Dark Blue `#053d57`) - Support for template variables: `{{name}}`, `{{title}}`, `{{message}}`, `{{linkUrl}}` - Retry logic (3 attempts) - Logs sent emails to `NotificationLog` table **Current template types:** ```typescript const EMAIL_TEMPLATES = { MAGIC_LINK: 'magic-link', REMINDER_24H: '24-hour-reminder', REMINDER_1H: '1-hour-reminder', ASSIGNMENT_CREATED: 'assignment-created', FILTERING_COMPLETE: 'filtering-complete', STAGE_TRANSITIONED: 'stage-transitioned', DECISION_OVERRIDDEN: 'decision-overridden', } ``` #### 7. Notification Policy Configuration **Model:** ```prisma model NotificationPolicy { id String @id @default(cuid()) eventType String @unique // e.g., "stage.transitioned" channel String @default("EMAIL") // EMAIL | IN_APP | BOTH | NONE templateId String? // Optional reference to MessageTemplate isActive Boolean @default(true) configJson Json? @db.JsonB // Additional config createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` **Admin UI (future):** ``` ┌─ Notification Settings ──────────────────────────────┐ │ │ │ Event Type: stage.transitioned │ │ Channel: [x] Email [x] In-App [ ] Webhook │ │ Active: [x] Enabled │ │ Template: [Stage Transition Email ▼] │ │ │ │ Recipients: [ ] All Admins [x] Program Admins Only │ │ │ │ [Save] [Test Notification] │ └───────────────────────────────────────────────────────┘ ``` --- ## Redesigned Notification Architecture ### Key Changes from Current System | Aspect | Current (Stage-based) | Redesigned (Round-based) | |--------|----------------------|--------------------------| | **Event naming** | `stage.transitioned` | `round.transitioned` | | **Entity references** | `stageId`, `trackId` | `roundId` only (no trackId) | | **Deadline config** | Per-stage in `Stage.configJson` | Per-SubmissionWindow + per-Round | | **Reminder targets** | Jury only | Jury, applicants, mentors (role-based) | | **Competition-wide settings** | None (per-stage only) | `Competition.notifyOn*` fields | | **Grace periods** | Stage-level only | Round-level + window-level | | **Template system** | Hardcoded types | Zod-validated templates with variables | ### Enhanced Notification Event Model **New/renamed events:** ```typescript export const ROUND_EVENT_TYPES = { // Core round events ROUND_OPENED: 'round.opened', ROUND_CLOSED: 'round.closed', ROUND_TRANSITIONED: 'round.transitioned', // INTAKE events SUBMISSION_RECEIVED: 'intake.submission_received', SUBMISSION_DEADLINE_APPROACHING: 'intake.deadline_approaching', SUBMISSION_DEADLINE_PASSED: 'intake.deadline_passed', // FILTERING events FILTERING_STARTED: 'filtering.started', FILTERING_COMPLETED: 'filtering.completed', FILTERING_FLAGGED: 'filtering.flagged_for_review', PROJECT_ADVANCED: 'filtering.project_advanced', PROJECT_REJECTED: 'filtering.project_rejected', // EVALUATION events ASSIGNMENT_CREATED: 'evaluation.assignment_created', EVALUATION_DEADLINE_APPROACHING: 'evaluation.deadline_approaching', EVALUATION_SUBMITTED: 'evaluation.submitted', EVALUATION_ROUND_COMPLETE: 'evaluation.round_complete', // SUBMISSION events (round 2+ docs) SUBMISSION_WINDOW_OPENED: 'submission.window_opened', NEW_DOCS_REQUIRED: 'submission.new_docs_required', DOCS_SUBMITTED: 'submission.docs_submitted', DOCS_DEADLINE_APPROACHING: 'submission.deadline_approaching', // MENTORING events MENTOR_ASSIGNED: 'mentoring.assigned', MENTOR_MESSAGE_RECEIVED: 'mentoring.message_received', MENTOR_FILE_UPLOADED: 'mentoring.file_uploaded', MENTOR_FILE_PROMOTED: 'mentoring.file_promoted', // LIVE_FINAL events CEREMONY_STARTING: 'live_final.ceremony_starting', VOTE_REQUIRED: 'live_final.vote_required', DELIBERATION_STARTED: 'live_final.deliberation_started', RESULTS_READY: 'live_final.results_ready', // CONFIRMATION events WINNER_APPROVAL_REQUIRED: 'confirmation.approval_required', WINNER_APPROVED: 'confirmation.approved', RESULTS_FROZEN: 'confirmation.frozen', // Admin events DECISION_OVERRIDDEN: 'admin.decision_overridden', GRACE_PERIOD_GRANTED: 'admin.grace_period_granted', DEADLINE_EXTENDED: 'admin.deadline_extended', } ``` ### Competition-Wide Notification Settings **Enhanced Competition model:** ```prisma model Competition { // ... existing fields ... // Global notification preferences notifyOnRoundAdvance Boolean @default(true) notifyOnDeadlineApproach Boolean @default(true) notifyOnAssignmentCreated Boolean @default(true) notifyOnSubmissionReceived Boolean @default(true) notifyOnFilteringComplete Boolean @default(true) // Reminder configuration deadlineReminderDays Int[] @default([7, 3, 1]) // Days before deadline deadlineReminderHours Int[] @default([24, 3, 1]) // Hours before deadline (final stretch) // Email templates notificationEmailFromName String? // Override default "MOPC Platform" notificationEmailReplyTo String? // Custom reply-to for this competition // Advanced settings batchNotifications Boolean @default(false) // Group similar notifications batchIntervalMinutes Int @default(30) // Batch every 30 min notificationTimezone String @default("UTC") // For deadline display } ``` ### Per-Round Notification Overrides **Round-level overrides:** ```prisma model Round { // ... existing fields ... // Notification overrides (null = use competition defaults) notifyOnRoundOpen Boolean? // Override competition.notifyOnRoundAdvance notifyOnDeadline Boolean? // Override competition.notifyOnDeadlineApproach customReminderSchedule Json? @db.JsonB // { days: [7, 3, 1], hours: [24, 6, 1] } // Email template overrides openEmailTemplateId String? // Custom template for round open reminderEmailTemplateId String? // Custom template for reminders } ``` **Example custom schedule:** ```typescript // For Live Finals round — aggressive reminders { days: [7, 3], // 7 days before, 3 days before hours: [24, 12, 6, 1] // 24h, 12h, 6h, 1h before ceremony } ``` --- ## Deadline Management System ### DeadlinePolicy Enum **Three deadline modes:** ```prisma enum DeadlinePolicy { HARD // Submissions rejected after windowCloseAt (strict cutoff) FLAG // Submissions accepted but marked late (isLate = true) GRACE // Grace period after windowCloseAt, then hard cutoff } ``` **Applied at SubmissionWindow level:** ```prisma model SubmissionWindow { // ... existing fields ... deadlinePolicy DeadlinePolicy @default(FLAG) graceHours Int? // For GRACE policy: hours after windowCloseAt } ``` **Evaluation rounds use Round.windowCloseAt + GracePeriod model:** ```prisma model Round { windowOpenAt DateTime? windowCloseAt DateTime? // Jury evaluation deadline behavior evaluationDeadlinePolicy String @default("FLAG") // "HARD" | "FLAG" | "GRACE" evaluationGraceHours Int? } ``` ### Deadline Enforcement Logic **Submission deadline check:** ```typescript type SubmissionDeadlineCheck = { allowed: boolean isLate: boolean reason?: string } export async function checkSubmissionDeadline( submissionWindowId: string, userId: string, prisma: PrismaClient ): Promise { const window = await prisma.submissionWindow.findUnique({ where: { id: submissionWindowId }, select: { windowOpenAt: true, windowCloseAt: true, deadlinePolicy: true, graceHours: true, }, }) const now = new Date() // Before window opens if (window.windowOpenAt && now < window.windowOpenAt) { return { allowed: false, reason: 'Window not yet open' } } // Within normal window if (!window.windowCloseAt || now <= window.windowCloseAt) { return { allowed: true, isLate: false } } // After window close switch (window.deadlinePolicy) { case 'HARD': return { allowed: false, reason: 'Deadline passed (hard cutoff)' } case 'FLAG': return { allowed: true, isLate: true } case 'GRACE': { if (!window.graceHours) { return { allowed: false, reason: 'Grace period not configured' } } const graceEnd = new Date( window.windowCloseAt.getTime() + window.graceHours * 60 * 60 * 1000 ) if (now <= graceEnd) { return { allowed: true, isLate: true } } else { return { allowed: false, reason: 'Grace period expired' } } } } } ``` **Evaluation deadline check (with per-user grace periods):** ```typescript export async function checkEvaluationDeadline( roundId: string, userId: string, prisma: PrismaClient ): Promise { const round = await prisma.round.findUnique({ where: { id: roundId }, select: { windowCloseAt: true, evaluationDeadlinePolicy: true, evaluationGraceHours: true, }, }) // Check for user-specific grace period (highest priority) const userGrace = await prisma.gracePeriod.findFirst({ where: { roundId, userId, extendedUntil: { gte: new Date() }, }, orderBy: { extendedUntil: 'desc' }, }) const effectiveDeadline = userGrace?.extendedUntil ?? round.windowCloseAt const now = new Date() if (!effectiveDeadline || now <= effectiveDeadline) { return { allowed: true, isLate: false } } // Past effective deadline switch (round.evaluationDeadlinePolicy) { case 'HARD': return { allowed: false, reason: 'Evaluation deadline passed' } case 'FLAG': return { allowed: true, isLate: true } case 'GRACE': { if (!round.evaluationGraceHours) { return { allowed: false, reason: 'Grace period not configured' } } const graceEnd = new Date( effectiveDeadline.getTime() + round.evaluationGraceHours * 60 * 60 * 1000 ) if (now <= graceEnd) { return { allowed: true, isLate: true } } else { return { allowed: false, reason: 'Grace period expired' } } } } } ``` ### Deadline Extension by Admin **Admin deadline extension workflow:** ```typescript // Option 1: Extend round window (affects everyone) trpc.round.extendDeadline.useMutation({ roundId: 'round-123', newWindowCloseAt: new Date('2026-03-15T23:59:59Z'), reason: 'Extended due to technical issues', notifyParticipants: true, // Send email to all affected users }) // Option 2: Grant individual grace periods (granular) trpc.gracePeriod.grant.useMutation({ roundId: 'round-123', userId: 'jury-456', extendedUntil: new Date('2026-03-15T23:59:59Z'), reason: 'Medical emergency', }) // Option 3: Bulk grace periods for entire jury trpc.gracePeriod.bulkGrant.useMutation({ roundId: 'round-123', userIds: ['jury-1', 'jury-2', 'jury-3'], // All Jury 1 members extendedUntil: new Date('2026-03-15T23:59:59Z'), reason: 'Extended for Jury 1', }) ``` **Audit trail:** All deadline changes create: 1. `DecisionAuditLog` entry with `eventType: "admin.deadline_extended"` 2. `OverrideAction` entry (if overriding automated behavior) 3. In-app notification to affected users 4. Email notification (if `notifyParticipants: true`) --- ## Notification Events by Round Type ### Complete Event Matrix | Round Type | Event | Triggered When | Recipients | Channel | |------------|-------|----------------|------------|---------| | **INTAKE** | `intake.window_opened` | Round status → ACTIVE | All applicants | Email + In-app | | | `intake.submission_received` | ProjectFile.create() | Applicant (team lead) | Email + In-app | | | `intake.deadline_approaching` | Cron: 7d, 3d, 1d before close | Applicants without submission | Email + In-app | | | `intake.deadline_passed` | windowCloseAt reached | All applicants | In-app only | | | `intake.window_extended` | Admin extends deadline | All applicants | Email + In-app | | **FILTERING** | `filtering.started` | FilteringJob.create() | Admins | In-app only | | | `filtering.completed` | FilteringJob.status → COMPLETED | Admins | Email + In-app | | | `filtering.flagged_for_review` | FilteringResult.outcome → FLAGGED | Admins | Email (high priority) | | | `filtering.project_advanced` | ProjectRoundState.state → PASSED | Applicant (team lead) | Email + In-app | | | `filtering.project_rejected` | ProjectRoundState.state → REJECTED | Applicant (team lead) | Email + In-app | | **EVALUATION** | `evaluation.assignment_created` | Assignment.create() | Assigned juror | Email + In-app | | | `evaluation.deadline_approaching` | Cron: 7d, 3d, 1d, 24h, 3h, 1h before close | Jurors with incomplete assignments | Email + In-app | | | `evaluation.submitted` | Evaluation.status → SUBMITTED | Admin, jury lead | In-app only | | | `evaluation.round_complete` | All assignments completed | Admins | Email + In-app | | | `evaluation.summary_generated` | EvaluationSummary.create() | Admins | In-app only | | **SUBMISSION** | `submission.window_opened` | SubmissionWindow opens | Eligible teams (PASSED from prev round) | Email + In-app | | | `submission.new_docs_required` | Round status → ACTIVE | Eligible teams | Email (high priority) | | | `submission.docs_submitted` | ProjectFile.create() for window | Applicant (team lead) | Email + In-app | | | `submission.deadline_approaching` | Cron: 7d, 3d, 1d before close | Teams without complete submission | Email + In-app | | | `submission.docs_reviewed` | Admin marks review complete | Applicant (team lead) | Email + In-app | | **MENTORING** | `mentoring.assigned` | MentorAssignment.create() | Mentor + Team | Email + In-app | | | `mentoring.workspace_opened` | Round status → ACTIVE | Mentor + Team | Email + In-app | | | `mentoring.message_received` | MentorMessage.create() | Recipient (mentor or team) | Email + In-app | | | `mentoring.file_uploaded` | MentorFile.create() | Other party (mentor or team) | In-app + Email | | | `mentoring.file_promoted` | MentorFile.isPromoted → true | Team lead | Email + In-app | | | `mentoring.milestone_completed` | MentorMilestoneCompletion.create() | Mentor + Team | In-app only | | **LIVE_FINAL** | `live_final.ceremony_starting` | LiveVotingSession.status → IN_PROGRESS | Jury + Audience | Email + In-app | | | `live_final.vote_required` | LiveProgressCursor updated | Jury members | In-app only (real-time) | | | `live_final.deliberation_started` | Deliberation period begins | Jury | In-app only | | | `live_final.results_ready` | All votes cast | Admins | In-app only | | **CONFIRMATION** | `confirmation.approval_required` | WinnerProposal.create() | Jury members + Admins | Email (urgent) + In-app | | | `confirmation.approval_received` | WinnerApproval.approved → true | Admins | In-app only | | | `confirmation.approved` | All approvals received | Admins + Jury | Email + In-app | | | `confirmation.frozen` | WinnerProposal.frozenAt set | All participants | Email + In-app | | **ADMIN** | `admin.decision_overridden` | OverrideAction.create() | Admins | Email (audit alert) | | | `admin.grace_period_granted` | GracePeriod.create() | Affected user | Email + In-app | | | `admin.deadline_extended` | Round.windowCloseAt updated | All affected users | Email + In-app | ### Event Payload Schemas (Zod) **Base event schema:** ```typescript import { z } from 'zod' export const BaseEventSchema = z.object({ eventType: z.string(), entityType: z.string(), entityId: z.string(), actorId: z.string(), timestamp: z.date(), metadata: z.record(z.unknown()).optional(), }) export type BaseEvent = z.infer ``` **Intake events:** ```typescript export const IntakeWindowOpenedSchema = BaseEventSchema.extend({ eventType: z.literal('intake.window_opened'), entityType: z.literal('Round'), roundId: z.string(), roundName: z.string(), windowOpenAt: z.date(), windowCloseAt: z.date(), competitionId: z.string(), }) export const SubmissionReceivedSchema = BaseEventSchema.extend({ eventType: z.literal('intake.submission_received'), entityType: z.literal('ProjectFile'), projectId: z.string(), projectTitle: z.string(), submittedByUserId: z.string(), fileCount: z.number(), isComplete: z.boolean(), // All required files uploaded }) export const DeadlineApproachingSchema = BaseEventSchema.extend({ eventType: z.literal('intake.deadline_approaching'), entityType: z.literal('SubmissionWindow'), roundId: z.string(), roundName: z.string(), windowCloseAt: z.date(), daysRemaining: z.number(), hoursRemaining: z.number().optional(), }) ``` **Evaluation events:** ```typescript export const AssignmentCreatedSchema = BaseEventSchema.extend({ eventType: z.literal('evaluation.assignment_created'), entityType: z.literal('Assignment'), assignmentId: z.string(), roundId: z.string(), roundName: z.string(), projectId: z.string(), projectTitle: z.string(), juryGroupId: z.string(), juryGroupName: z.string(), deadline: z.date(), }) export const EvaluationDeadlineApproachingSchema = BaseEventSchema.extend({ eventType: z.literal('evaluation.deadline_approaching'), entityType: z.literal('Round'), roundId: z.string(), roundName: z.string(), incompleteCount: z.number(), totalCount: z.number(), deadline: z.date(), reminderType: z.enum(['7d', '3d', '1d', '24h', '3h', '1h']), }) ``` **Mentoring events:** ```typescript export const MentorAssignedSchema = BaseEventSchema.extend({ eventType: z.literal('mentoring.assigned'), entityType: z.literal('MentorAssignment'), mentorId: z.string(), mentorName: z.string(), projectId: z.string(), projectTitle: z.string(), teamLeadId: z.string(), teamLeadName: z.string(), workspaceOpenAt: z.date().optional(), }) export const MentorFileUploadedSchema = BaseEventSchema.extend({ eventType: z.literal('mentoring.file_uploaded'), entityType: z.literal('MentorFile'), fileId: z.string(), fileName: z.string(), uploadedByUserId: z.string(), uploadedByRole: z.enum(['MENTOR', 'APPLICANT']), mentorAssignmentId: z.string(), projectId: z.string(), }) ``` **Confirmation events:** ```typescript export const WinnerApprovalRequiredSchema = BaseEventSchema.extend({ eventType: z.literal('confirmation.approval_required'), entityType: z.literal('WinnerProposal'), proposalId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']), rankedProjectIds: z.array(z.string()), requiredApprovers: z.array(z.object({ userId: z.string(), role: z.enum(['JURY_MEMBER', 'ADMIN']), })), deadline: z.date().optional(), }) export const ResultsFrozenSchema = BaseEventSchema.extend({ eventType: z.literal('confirmation.frozen'), entityType: z.literal('WinnerProposal'), proposalId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']), frozenAt: z.date(), frozenByUserId: z.string(), rankedProjectIds: z.array(z.string()), }) ``` --- ## Notification Channels ### 1. Email Channel **Email service interface:** ```typescript export interface EmailNotificationData { to: string // Recipient email name: string // Recipient name subject: string // Email subject template: string // Template ID variables: Record // Template variables replyTo?: string // Custom reply-to priority?: 'low' | 'normal' | 'high' | 'urgent' } export async function sendNotificationEmail( data: EmailNotificationData ): Promise ``` **Email templates with variables:** ```typescript // Template: evaluation-deadline-reminder const template = `

{{competitionName}}

Dear {{name}},

This is a reminder that you have {{incompleteCount}} pending evaluation{{incompleteCount > 1 ? 's' : ''}} for {{roundName}}.

⏰ Deadline: {{deadline}}

{{timeRemaining}} remaining

Please complete your evaluations before the deadline.

Complete Evaluations

If you need an extension, please contact the program administrator.

Best regards,
{{competitionName}} Team

` // Variables passed to template: { name: "Dr. Smith", competitionName: "MOPC 2026", roundName: "Jury 1 - Semi-finalist Selection", incompleteCount: 5, deadline: "March 15, 2026 at 11:59 PM CET", timeRemaining: "2 days 5 hours", linkUrl: "https://monaco-opc.com/jury/rounds/round-123/assignments" } ``` **Email delivery tracking:** ```prisma model NotificationLog { id String @id @default(cuid()) userId String channel NotificationChannel // EMAIL, WHATSAPP, BOTH, NONE provider String? // SMTP, META, TWILIO type String // Event type status String // PENDING, SENT, DELIVERED, FAILED externalId String? // Message ID from provider errorMsg String? @db.Text createdAt DateTime @default(now()) } ``` ### 2. In-App Notification Center **UI components:** **Notification Bell (Header):** ```tsx // src/components/layout/notification-bell.tsx 'use client' import { Bell } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { trpc } from '@/lib/trpc/client' import { NotificationItem } from './notification-item' export function NotificationBell() { const { data: unreadCount } = trpc.notification.getUnreadCount.useQuery() const { data: recent } = trpc.notification.getRecent.useQuery({ limit: 10 }) const markAllRead = trpc.notification.markAllRead.useMutation() return (

Notifications

{unreadCount > 0 && ( )}
{recent?.map((notification) => ( ))} {recent?.length === 0 && (
No notifications
)}
) } ``` **Notification Item:** ```tsx // src/components/layout/notification-item.tsx 'use client' import { useRouter } from 'next/navigation' import { formatDistanceToNow } from 'date-fns' import { cn } from '@/lib/utils' import { trpc } from '@/lib/trpc/client' import * as Icons from 'lucide-react' type Notification = { id: string type: string title: string message: string icon?: string priority: string linkUrl?: string isRead: boolean createdAt: Date } export function NotificationItem({ notification }: { notification: Notification }) { const router = useRouter() const markRead = trpc.notification.markRead.useMutation() const Icon = notification.icon ? Icons[notification.icon] : Icons.Bell const handleClick = () => { if (!notification.isRead) { markRead.mutate({ id: notification.id }) } if (notification.linkUrl) { router.push(notification.linkUrl) } } return (
{Icon && (
)}

{notification.title}

{!notification.isRead && (
)}

{notification.message}

{formatDistanceToNow(notification.createdAt, { addSuffix: true })}

) } ``` **Notification Center Page:** ```tsx // src/app/(authenticated)/notifications/page.tsx 'use client' import { useState } from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { trpc } from '@/lib/trpc/client' import { NotificationItem } from '@/components/layout/notification-item' export default function NotificationsPage() { const [filter, setFilter] = useState<'all' | 'unread'>('all') const { data: notifications } = trpc.notification.getAll.useQuery({ filter, limit: 100, }) return (

Notifications

setFilter(v as any)}> All Unread
{notifications?.map((n) => ( ))} {notifications?.length === 0 && (
No notifications to show
)}
) } ``` ### 3. Future: Webhook Channel **Webhook integration (future enhancement):** ```typescript // Future: Webhook delivery for external integrations export interface WebhookNotificationPayload { event: string timestamp: string data: Record signature: string // HMAC signature for verification } // Example webhook payload { event: "evaluation.deadline_approaching", timestamp: "2026-03-10T15:00:00Z", data: { competitionId: "comp-123", roundId: "round-456", roundName: "Jury 1 - Semi-finalist Selection", deadline: "2026-03-15T23:59:59Z", daysRemaining: 5, incompleteAssignments: 12, totalAssignments: 60 }, signature: "sha256=..." } ``` **Webhook configuration (future):** ```prisma model Webhook { id String @id @default(cuid()) name String url String secret String // For HMAC signature events String[] // Event types to subscribe to headers Json? @db.JsonB maxRetries Int @default(3) isActive Boolean @default(true) createdById String } ``` --- ## Countdown Timer System ### Real-Time Countdown Component **Enhanced countdown timer:** ```tsx // src/components/shared/countdown-timer.tsx 'use client' import { useState, useEffect } from 'react' import { cn } from '@/lib/utils' import { Clock, AlertTriangle, CheckCircle } from 'lucide-react' export interface CountdownTimerProps { deadline: Date label?: string className?: string showIcon?: boolean size?: 'sm' | 'md' | 'lg' onExpire?: () => void } interface TimeRemaining { days: number hours: number minutes: number seconds: number totalMs: number } function getTimeRemaining(deadline: Date): TimeRemaining { const totalMs = deadline.getTime() - Date.now() if (totalMs <= 0) { return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 } } const seconds = Math.floor((totalMs / 1000) % 60) const minutes = Math.floor((totalMs / 1000 / 60) % 60) const hours = Math.floor((totalMs / (1000 * 60 * 60)) % 24) const days = Math.floor(totalMs / (1000 * 60 * 60 * 24)) return { days, hours, minutes, seconds, totalMs } } function formatCountdown(time: TimeRemaining, size: 'sm' | 'md' | 'lg'): string { if (time.totalMs <= 0) return 'Deadline passed' const { days, hours, minutes, seconds } = time // Small size: compact format if (size === 'sm') { if (days === 0 && hours === 0) return `${minutes}m ${seconds}s` if (days === 0) return `${hours}h ${minutes}m` return `${days}d ${hours}h` } // Medium/large: more detail if (days === 0 && hours === 0) { return `${minutes} minutes ${seconds} seconds` } if (days === 0) { return `${hours} hours ${minutes} minutes` } return `${days} days ${hours} hours ${minutes} minutes` } type Urgency = 'expired' | 'critical' | 'warning' | 'normal' function getUrgency(totalMs: number): Urgency { if (totalMs <= 0) return 'expired' if (totalMs < 60 * 60 * 1000) return 'critical' // < 1 hour if (totalMs < 24 * 60 * 60 * 1000) return 'warning' // < 24 hours if (totalMs < 7 * 24 * 60 * 60 * 1000) return 'normal' // < 7 days return 'normal' } const urgencyStyles: Record = { expired: 'text-muted-foreground bg-muted border-muted', critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900', warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900', normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900', } const sizeStyles = { sm: 'text-xs px-2 py-0.5', md: 'text-sm px-2.5 py-1', lg: 'text-base px-3 py-1.5', } export function CountdownTimer({ deadline, label, className, showIcon = true, size = 'md', onExpire, }: CountdownTimerProps) { const [time, setTime] = useState(() => getTimeRemaining(deadline)) const [hasExpired, setHasExpired] = useState(false) useEffect(() => { const timer = setInterval(() => { const remaining = getTimeRemaining(deadline) setTime(remaining) if (remaining.totalMs <= 0 && !hasExpired) { clearInterval(timer) setHasExpired(true) onExpire?.() } }, 1000) return () => clearInterval(timer) }, [deadline, hasExpired, onExpire]) const urgency = getUrgency(time.totalMs) const displayText = formatCountdown(time, size) const IconComponent = urgency === 'expired' ? CheckCircle : urgency === 'critical' ? AlertTriangle : Clock return (
{showIcon && } {label && {label}} {displayText}
) } ``` **Usage examples:** ```tsx // Jury dashboard - evaluation deadline toast.info('Deadline has passed')} /> // Applicant dashboard - submission deadline // Admin dashboard - compact view ``` ### Server-Side Time Sync **Prevent client clock drift:** ```typescript // src/server/routers/time.ts import { router, publicProcedure } from '../trpc' export const timeRouter = router({ /** * Get server time (for clock sync) */ getServerTime: publicProcedure.query(() => { return { serverTime: new Date().toISOString(), timezone: 'UTC', } }), }) // Client-side sync // src/hooks/use-server-time-sync.ts 'use client' import { useEffect, useState } from 'use' import { trpc } from '@/lib/trpc/client' export function useServerTimeSync() { const [timeOffset, setTimeOffset] = useState(0) const { data } = trpc.time.getServerTime.useQuery() useEffect(() => { if (data) { const serverTime = new Date(data.serverTime).getTime() const clientTime = Date.now() setTimeOffset(serverTime - clientTime) } }, [data]) const getSyncedTime = () => Date.now() + timeOffset return { getSyncedTime, timeOffset } } // Use in countdown function getTimeRemaining(deadline: Date): TimeRemaining { const { getSyncedTime } = useServerTimeSync() const totalMs = deadline.getTime() - getSyncedTime() // ... rest of logic } ``` --- ## Reminder Scheduling ### Cron-Based Reminder System **Cron endpoint:** ```typescript // src/app/api/cron/reminders/route.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { processDeadlineReminders } from '@/server/services/deadline-reminders' export async function GET(request: NextRequest): Promise { // Verify cron secret const cronSecret = request.headers.get('x-cron-secret') if (!cronSecret || cronSecret !== process.env.CRON_SECRET) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } try { const result = await processDeadlineReminders() return NextResponse.json({ ok: true, sent: result.sent, errors: result.errors, timestamp: new Date().toISOString(), }) } catch (error) { console.error('Cron reminder processing failed:', error) return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ) } } ``` **Reminder processor (redesigned):** ```typescript // src/server/services/deadline-reminders.ts import { prisma } from '@/lib/prisma' import { sendNotificationEmail } from '@/lib/email' import { emitRoundEvent } from './round-notifications' interface ReminderResult { sent: number errors: number } /** * Process deadline reminders for all active rounds and submission windows. * Called by cron job every 15 minutes. */ export async function processDeadlineReminders(): Promise { const now = new Date() let totalSent = 0 let totalErrors = 0 // Process evaluation round reminders const evalResults = await processEvaluationReminders(now) totalSent += evalResults.sent totalErrors += evalResults.errors // Process submission window reminders const submissionResults = await processSubmissionReminders(now) totalSent += submissionResults.sent totalErrors += submissionResults.errors return { sent: totalSent, errors: totalErrors } } /** * Send reminders to jurors with incomplete evaluations. */ async function processEvaluationReminders(now: Date): Promise { let sent = 0 let errors = 0 // Find active evaluation rounds with upcoming deadlines const rounds = await prisma.round.findMany({ where: { roundType: 'EVALUATION', status: 'ROUND_ACTIVE', windowCloseAt: { gt: now }, windowOpenAt: { lte: now }, }, select: { id: true, name: true, windowCloseAt: true, competition: { select: { id: true, name: true, deadlineReminderDays: true, deadlineReminderHours: true, }, }, }, }) for (const round of rounds) { if (!round.windowCloseAt) continue const msUntilDeadline = round.windowCloseAt.getTime() - now.getTime() // Determine which reminders should fire const reminderTypes = getReminderTypesForDeadline( msUntilDeadline, round.competition.deadlineReminderDays, round.competition.deadlineReminderHours ) if (reminderTypes.length === 0) continue for (const reminderType of reminderTypes) { const result = await sendEvaluationReminders(round, reminderType, now) sent += result.sent errors += result.errors } } return { sent, errors } } /** * Send reminders to applicants with incomplete submissions. */ async function processSubmissionReminders(now: Date): Promise { let sent = 0 let errors = 0 // Find active submission windows with upcoming deadlines const windows = await prisma.submissionWindow.findMany({ where: { windowCloseAt: { gt: now }, windowOpenAt: { lte: now }, }, select: { id: true, name: true, windowCloseAt: true, competition: { select: { id: true, name: true, deadlineReminderDays: true, deadlineReminderHours: true, }, }, fileRequirements: { select: { id: true, isRequired: true }, }, rounds: { select: { id: true, name: true }, where: { roundType: { in: ['INTAKE', 'SUBMISSION'] } }, }, }, }) for (const window of windows) { if (!window.windowCloseAt) continue const msUntilDeadline = window.windowCloseAt.getTime() - now.getTime() const reminderTypes = getReminderTypesForDeadline( msUntilDeadline, window.competition.deadlineReminderDays, window.competition.deadlineReminderHours ) if (reminderTypes.length === 0) continue for (const reminderType of reminderTypes) { const result = await sendSubmissionReminders(window, reminderType, now) sent += result.sent errors += result.errors } } return { sent, errors } } /** * Determine which reminder types should fire based on time until deadline. */ function getReminderTypesForDeadline( msUntilDeadline: number, reminderDays: number[], reminderHours: number[] ): string[] { const types: string[] = [] // Check day-based reminders for (const days of reminderDays) { const thresholdMs = days * 24 * 60 * 60 * 1000 const windowMs = 15 * 60 * 1000 // 15-minute cron window if (Math.abs(msUntilDeadline - thresholdMs) < windowMs) { types.push(`${days}d`) } } // Check hour-based reminders (only if < 48 hours remaining) if (msUntilDeadline < 48 * 60 * 60 * 1000) { for (const hours of reminderHours) { const thresholdMs = hours * 60 * 60 * 1000 const windowMs = 15 * 60 * 1000 if (Math.abs(msUntilDeadline - thresholdMs) < windowMs) { types.push(`${hours}h`) } } } return types } /** * Send evaluation deadline reminders to jurors. */ async function sendEvaluationReminders( round: any, reminderType: string, now: Date ): Promise { let sent = 0 let errors = 0 // Find jurors with incomplete assignments const incompleteAssignments = await prisma.assignment.findMany({ where: { roundId: round.id, isCompleted: false, }, select: { userId: true, user: { select: { id: true, name: true, email: true, }, }, }, }) // Group by user const userAssignments = new Map() for (const assignment of incompleteAssignments) { userAssignments.set( assignment.userId, (userAssignments.get(assignment.userId) || 0) + 1 ) } // Check who already received this reminder const existingReminders = await prisma.reminderLog.findMany({ where: { roundId: round.id, type: reminderType, }, select: { userId: true }, }) const alreadySent = new Set(existingReminders.map((r) => r.userId)) // Send reminders for (const [userId, incompleteCount] of userAssignments.entries()) { if (alreadySent.has(userId)) continue const assignment = incompleteAssignments.find((a) => a.userId === userId) if (!assignment) continue try { await sendNotificationEmail({ to: assignment.user.email, name: assignment.user.name || '', subject: `Reminder: ${incompleteCount} pending evaluation${incompleteCount > 1 ? 's' : ''}`, template: 'evaluation-deadline-reminder', variables: { name: assignment.user.name, competitionName: round.competition.name, roundName: round.name, incompleteCount, totalCount: userAssignments.get(userId), deadline: round.windowCloseAt.toISOString(), reminderType, linkUrl: `${process.env.NEXTAUTH_URL}/jury/rounds/${round.id}/assignments`, }, priority: reminderType.includes('h') ? 'high' : 'normal', }) // Log reminder await prisma.reminderLog.create({ data: { roundId: round.id, userId, type: reminderType, }, }) // Emit event await emitRoundEvent( 'evaluation.deadline_approaching', 'Round', round.id, 'system', { roundId: round.id, roundName: round.name, userId, incompleteCount, reminderType, deadline: round.windowCloseAt, }, prisma ) sent++ } catch (error) { console.error( `Failed to send ${reminderType} reminder to ${assignment.user.email}:`, error ) errors++ } } return { sent, errors } } /** * Send submission deadline reminders to applicants. */ async function sendSubmissionReminders( window: any, reminderType: string, now: Date ): Promise { let sent = 0 let errors = 0 // Find projects in this submission window's rounds const roundIds = window.rounds.map((r: any) => r.id) const eligibleProjects = await prisma.projectRoundState.findMany({ where: { roundId: { in: roundIds }, state: { in: ['PENDING', 'IN_PROGRESS'] }, }, select: { projectId: true, project: { select: { id: true, title: true, submittedByUserId: true, submittedBy: { select: { id: true, name: true, email: true, }, }, }, }, }, }) // Check which projects have incomplete submissions const requiredFileCount = window.fileRequirements.filter( (r: any) => r.isRequired ).length for (const state of eligibleProjects) { if (!state.project.submittedBy) continue // Count uploaded files for this window const uploadedFiles = await prisma.projectFile.count({ where: { projectId: state.projectId, submissionWindowId: window.id, }, }) const isIncomplete = uploadedFiles < requiredFileCount if (!isIncomplete) continue // Check if already sent this reminder const existing = await prisma.reminderLog.findUnique({ where: { roundId_userId_type: { roundId: window.rounds[0].id, userId: state.project.submittedByUserId, type: reminderType, }, }, }) if (existing) continue try { await sendNotificationEmail({ to: state.project.submittedBy.email, name: state.project.submittedBy.name || '', subject: `Reminder: Complete your submission for ${window.name}`, template: 'submission-deadline-reminder', variables: { name: state.project.submittedBy.name, competitionName: window.competition.name, windowName: window.name, projectTitle: state.project.title, uploadedCount: uploadedFiles, requiredCount: requiredFileCount, deadline: window.windowCloseAt.toISOString(), reminderType, linkUrl: `${process.env.NEXTAUTH_URL}/applicant/submissions/${window.id}`, }, priority: reminderType.includes('h') ? 'high' : 'normal', }) // Log reminder await prisma.reminderLog.create({ data: { roundId: window.rounds[0].id, userId: state.project.submittedByUserId, type: reminderType, }, }) sent++ } catch (error) { console.error( `Failed to send ${reminderType} reminder to ${state.project.submittedBy.email}:`, error ) errors++ } } return { sent, errors } } ``` ### Deduplication Strategy **ReminderLog unique constraint:** ```prisma model ReminderLog { id String @id @default(cuid()) roundId String userId String type String // "7d", "3d", "1d", "24h", "3h", "1h" sentAt DateTime @default(now()) @@unique([roundId, userId, type]) // Prevent duplicate reminders @@index([roundId]) @@index([userId]) } ``` **Logic:** 1. Cron runs every 15 minutes 2. For each active round/window with deadline, calculate `msUntilDeadline` 3. Check if any reminder threshold matches (within 15-min window) 4. For each matching reminder type, query `ReminderLog` to see if already sent 5. Send only to users without existing log entry 6. Create `ReminderLog` entry after successful send **Example timeline:** ``` Deadline: March 15, 2026 at 11:59 PM Now: March 8, 2026 at 12:00 PM (7 days before) Cron check at 12:00 PM: - msUntilDeadline = 7 * 24 * 60 * 60 * 1000 = 604,800,000 ms - Match "7d" reminder (within 15-min window) - Query ReminderLog for (roundId, userId, type="7d") - If not exists → send email + create log - If exists → skip Cron check at 12:15 PM: - msUntilDeadline = 604,800,000 ms - 900,000 ms = slightly less - No longer within 15-min window of "7d" threshold - No reminders sent Cron check at 3:00 PM (3 days before): - msUntilDeadline = 3 * 24 * 60 * 60 * 1000 - Match "3d" reminder - Send to users without "3d" log ``` --- ## Admin Notification Controls ### Competition-Wide Settings UI **ASCII mockup:** ``` ┌─ Competition Settings: MOPC 2026 ─────────────────────────────────────┐ │ │ │ ┌─ Notification Preferences ──────────────────────────────────────┐ │ │ │ │ │ │ │ Global Toggles: │ │ │ │ [x] Notify on round advancement │ │ │ │ [x] Notify on deadline approaching │ │ │ │ [x] Notify on assignment created │ │ │ │ [x] Notify on submission received │ │ │ │ [x] Notify on filtering complete │ │ │ │ │ │ │ │ Reminder Schedule: │ │ │ │ Days before deadline: [7] [3] [1] [+ Add] │ │ │ │ Hours before deadline (final 48h): [24] [3] [1] [+ Add] │ │ │ │ │ │ │ │ Email Settings: │ │ │ │ From name: [MOPC 2026 Platform ] │ │ │ │ Reply-to: [admin@monaco-opc.com ] │ │ │ │ │ │ │ │ Advanced: │ │ │ │ [ ] Batch similar notifications (group within 30 min) │ │ │ │ Timezone: [UTC ▼] │ │ │ │ │ │ │ │ [Save Changes] [Reset to Defaults] │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─ Notification History ─────────────────────────────────────────┐ │ │ │ │ │ │ │ Last 24 hours: │ │ │ │ ✉ 156 emails sent │ │ │ │ 📱 243 in-app notifications created │ │ │ │ ⚠ 2 delivery failures │ │ │ │ │ │ │ │ [View Full Log] [Download Report] │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Per-Round Notification Overrides **ASCII mockup:** ``` ┌─ Round Settings: Jury 1 - Semi-finalist Selection ────────────────────┐ │ │ │ ┌─ Notification Overrides ────────────────────────────────────────┐ │ │ │ │ │ │ │ Inherit from competition: │ │ │ │ [x] Use competition reminder schedule │ │ │ │ [x] Use competition email templates │ │ │ │ │ │ │ │ Custom Reminder Schedule (overrides competition): │ │ │ │ [ ] Enable custom schedule │ │ │ │ Days before: [ ] [ ] [ ] │ │ │ │ Hours before: [ ] [ ] [ ] │ │ │ │ │ │ │ │ Email Templates: │ │ │ │ Round open: [Default Template ▼] │ │ │ │ Reminder: [Default Template ▼] │ │ │ │ Deadline passed:[Default Template ▼] │ │ │ │ │ │ │ │ Notification Toggles: │ │ │ │ [x] Notify jurors when round opens │ │ │ │ [x] Send deadline reminders │ │ │ │ [ ] Notify admins on each evaluation submission (high volume) │ │ │ │ │ │ │ │ [Save Overrides] [Clear All Overrides] │ │ │ └───────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Manual Notification Sending **Broadcast notification UI:** ``` ┌─ Send Manual Notification ──────────────────────────────────────────┐ │ │ │ Target Audience: │ │ ( ) All jury members in this competition │ │ ( ) All applicants with active projects │ │ ( ) Specific jury group: [Jury 1 ▼] │ │ ( ) Specific round participants: [Round 3 ▼] │ │ (•) Custom user list │ │ │ │ ┌─ Custom Users ───────────────────────────────────────────────┐ │ │ │ [Search users by name or email... ] [+ Add] │ │ │ │ │ │ │ │ Selected (3): │ │ │ │ • Dr. Alice Smith (alice@example.com) [Remove] │ │ │ │ • Prof. Bob Johnson (bob@example.com) [Remove] │ │ │ │ • Dr. Carol White (carol@example.com) [Remove] │ │ │ └───────────────────────────────────────────────────────────────┘ │ │ │ │ Subject: │ │ [Important: Competition Schedule Update ] │ │ │ │ Message: │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Dear {{name}}, │ │ │ │ │ │ │ │ Due to unforeseen circumstances, the evaluation deadline │ │ │ │ for Jury 1 has been extended to March 20, 2026. │ │ │ │ │ │ │ │ Please complete your evaluations by the new deadline. │ │ │ │ │ │ │ │ Best regards, │ │ │ │ MOPC Team │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ Delivery: │ │ [x] Send email │ │ [x] Create in-app notification │ │ Priority: [Normal ▼] │ │ │ │ [Preview] [Send Now] [Schedule for Later] │ └───────────────────────────────────────────────────────────────────────┘ ``` ### Notification Log/History **Admin notification log:** ``` ┌─ Notification Log ─────────────────────────────────────────────────────┐ │ │ │ Filters: │ │ Date range: [Last 7 days ▼] Type: [All types ▼] │ │ Channel: [All ▼] Status: [All ▼] User: [ ] │ │ │ │ ┌────────────────────────────────────────────────────────────────────┐ │ │ │ Timestamp │ Type │ User │ Ch.│ Status│ │ │ ├────────────────────────────────────────────────────────────────────┤ │ │ │ 2026-03-10 15:30 │ evaluation.deadline │ Dr. Smith │ ✉ │ ✓ Sent│ │ │ │ 2026-03-10 15:30 │ evaluation.deadline │ Prof. Lee │ ✉ │ ✓ Sent│ │ │ │ 2026-03-10 15:30 │ evaluation.deadline │ Dr. Garcia │ ✉ │ ✗ Fail│ │ │ │ 2026-03-10 14:00 │ filtering.completed │ Admin │ ✉📱│ ✓ Sent│ │ │ │ 2026-03-10 12:15 │ intake.submission │ Team Alpha │ ✉ │ ✓ Sent│ │ │ │ 2026-03-09 18:00 │ mentoring.message │ Team Beta │ 📱 │ ✓ Sent│ │ │ │ 2026-03-09 16:45 │ assignment.created │ Dr. Kim │ ✉📱│ ✓ Sent│ │ │ └────────────────────────────────────────────────────────────────────┘ │ │ │ │ Showing 7 of 482 notifications │ │ [Previous] [1] [2] [3] ... [69] [Next] │ │ │ │ [Export CSV] [Download Full Report] │ └──────────────────────────────────────────────────────────────────────────┘ ``` --- ## Template System ### Email Template Model **Enhanced MessageTemplate model:** ```prisma model MessageTemplate { id String @id @default(cuid()) name String category String // 'SYSTEM', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL' eventType String? // Optional: link to specific event type subject String body String @db.Text // HTML template variables Json? @db.JsonB // Available template variables isActive Boolean @default(true) isSystem Boolean @default(false) // System templates can't be deleted createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt creator User @relation("MessageTemplateCreator", fields: [createdBy], references: [id]) messages Message[] @@index([category]) @@index([isActive]) @@index([eventType]) } ``` ### Template Variables **Standard variables available in all templates:** ```typescript export const STANDARD_VARIABLES = { // User variables name: 'Recipient name', email: 'Recipient email', role: 'User role', // Competition variables competitionName: 'Competition name', competitionYear: 'Competition year', // Round variables roundName: 'Round name', roundType: 'Round type', deadline: 'Deadline (formatted)', windowOpenAt: 'Window open date', windowCloseAt: 'Window close date', // Link variables linkUrl: 'Call-to-action link', baseUrl: 'Platform base URL', // Meta currentDate: 'Current date', timestamp: 'Current timestamp', } ``` **Event-specific variables:** ```typescript // evaluation.deadline_approaching export const EVALUATION_DEADLINE_VARIABLES = { ...STANDARD_VARIABLES, incompleteCount: 'Number of incomplete evaluations', totalCount: 'Total assigned evaluations', daysRemaining: 'Days until deadline', hoursRemaining: 'Hours until deadline', reminderType: 'Reminder type (7d, 3d, 1d, etc.)', } // intake.submission_received export const SUBMISSION_RECEIVED_VARIABLES = { ...STANDARD_VARIABLES, projectTitle: 'Project title', projectId: 'Project ID', fileCount: 'Number of files uploaded', requiredFileCount: 'Number of required files', isComplete: 'Whether submission is complete', } // mentoring.file_uploaded export const MENTOR_FILE_VARIABLES = { ...STANDARD_VARIABLES, fileName: 'Uploaded file name', uploadedByName: 'Name of uploader', uploadedByRole: 'Role of uploader (MENTOR or APPLICANT)', projectTitle: 'Project title', } ``` ### Template Editor UI **ASCII mockup:** ``` ┌─ Email Template Editor ────────────────────────────────────────────────┐ │ │ │ Template Name: [Evaluation Deadline Reminder ] │ │ Category: [Evaluation ▼] │ │ Event Type: [evaluation.deadline_approaching ▼] │ │ │ │ Subject: │ │ [Reminder: {{incompleteCount}} pending evaluation(s) ] │ │ │ │ ┌─ Available Variables ──────────────────────────────────────────────┐ │ │ │ Click to insert: │ │ │ │ {{name}} {{competitionName}} {{roundName}} {{deadline}} │ │ │ │ {{incompleteCount}} {{totalCount}} {{linkUrl}} [View all...] │ │ │ └────────────────────────────────────────────────────────────────────┘ │ │ │ │ Body (HTML): │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │

{{competitionName}}

│ │ │ │
│ │ │ │
│ │ │ │

Dear {{name}},

│ │ │ │

You have {{incompleteCount}} pending ... │ │ │ │ ... │ │ │ │ [40 more lines] │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ │ Preview with sample data: [Generate Preview] │ │ │ │ [Save Draft] [Save & Activate] [Send Test Email] [Cancel] │ └──────────────────────────────────────────────────────────────────────────┘ ``` ### Template Validation **Zod schema for template validation:** ```typescript import { z } from 'zod' export const TemplateVariableSchema = z.object({ key: z.string(), label: z.string(), type: z.enum(['string', 'number', 'date', 'boolean', 'url']), required: z.boolean().default(false), description: z.string().optional(), }) export const MessageTemplateSchema = z.object({ name: z.string().min(1, 'Name is required'), category: z.enum(['SYSTEM', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL']), eventType: z.string().optional(), subject: z.string().min(1, 'Subject is required'), body: z.string().min(1, 'Body is required'), variables: z.array(TemplateVariableSchema).optional(), isActive: z.boolean().default(true), }) export type TemplateVariable = z.infer export type MessageTemplateInput = z.infer ``` **Template validation function:** ```typescript export function validateTemplate( template: string, variables: Record ): { valid: boolean; errors: string[] } { const errors: string[] = [] // Extract all {{variable}} references const variableRefs = template.match(/\{\{(\w+)\}\}/g) || [] for (const ref of variableRefs) { const varName = ref.replace(/\{\{|\}\}/g, '') if (!(varName in variables)) { errors.push(`Unknown variable: ${varName}`) } } // Check for malformed syntax const malformed = template.match(/\{[^{]|\}[^}]/g) if (malformed) { errors.push('Malformed variable syntax detected') } return { valid: errors.length === 0, errors, } } ``` --- ## API Changes (tRPC) ### Notification Router **Complete notification router:** ```typescript // src/server/routers/notification.ts import { z } from 'zod' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' export const notificationRouter = router({ /** * Get unread count for current user */ getUnreadCount: protectedProcedure.query(async ({ ctx }) => { return ctx.prisma.inAppNotification.count({ where: { userId: ctx.user.id, isRead: false, }, }) }), /** * Get recent notifications */ getRecent: protectedProcedure .input(z.object({ limit: z.number().default(10) })) .query(async ({ ctx, input }) => { return ctx.prisma.inAppNotification.findMany({ where: { userId: ctx.user.id }, orderBy: { createdAt: 'desc' }, take: input.limit, }) }), /** * Get all notifications with filtering */ getAll: protectedProcedure .input( z.object({ filter: z.enum(['all', 'unread']).default('all'), limit: z.number().default(100), offset: z.number().default(0), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.inAppNotification.findMany({ where: { userId: ctx.user.id, ...(input.filter === 'unread' && { isRead: false }), }, orderBy: { createdAt: 'desc' }, take: input.limit, skip: input.offset, }) }), /** * Mark notification as read */ markRead: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.inAppNotification.update({ where: { id: input.id, userId: ctx.user.id }, data: { isRead: true, readAt: new Date() }, }) }), /** * Mark all notifications as read */ markAllRead: protectedProcedure.mutation(async ({ ctx }) => { return ctx.prisma.inAppNotification.updateMany({ where: { userId: ctx.user.id, isRead: false }, data: { isRead: true, readAt: new Date() }, }) }), /** * Delete notification */ delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.inAppNotification.delete({ where: { id: input.id, userId: ctx.user.id }, }) }), /** * Get notification policies (admin) */ listPolicies: adminProcedure.query(async ({ ctx }) => { return ctx.prisma.notificationPolicy.findMany({ orderBy: { eventType: 'asc' }, }) }), /** * Update notification policy (admin) */ updatePolicy: adminProcedure .input( z.object({ id: z.string(), channel: z.enum(['EMAIL', 'IN_APP', 'BOTH', 'NONE']).optional(), isActive: z.boolean().optional(), templateId: z.string().optional(), configJson: z.record(z.unknown()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const policy = await ctx.prisma.notificationPolicy.update({ where: { id }, data, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE_NOTIFICATION_POLICY', entityType: 'NotificationPolicy', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return policy }), /** * Send manual notification (admin) */ sendManual: adminProcedure .input( z.object({ userIds: z.array(z.string()), subject: z.string(), message: z.string(), linkUrl: z.string().optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'), channels: z.array(z.enum(['EMAIL', 'IN_APP'])), }) ) .mutation(async ({ ctx, input }) => { const { userIds, channels, ...notificationData } = input // Create in-app notifications if (channels.includes('IN_APP')) { await ctx.prisma.inAppNotification.createMany({ data: userIds.map((userId) => ({ userId, type: 'admin.manual_broadcast', title: notificationData.subject, message: notificationData.message, linkUrl: notificationData.linkUrl, priority: notificationData.priority, icon: 'Bell', })), }) } // Send emails if (channels.includes('EMAIL')) { const users = await ctx.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, name: true }, }) const { sendNotificationEmail } = await import('@/lib/email') for (const user of users) { try { await sendNotificationEmail({ to: user.email, name: user.name || '', subject: notificationData.subject, template: 'admin-manual-notification', variables: { name: user.name, message: notificationData.message, linkUrl: notificationData.linkUrl, }, priority: notificationData.priority, }) } catch (error) { console.error(`Failed to send email to ${user.email}:`, error) } } } // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'SEND_MANUAL_NOTIFICATION', entityType: 'InAppNotification', detailsJson: { recipientCount: userIds.length, channels, subject: notificationData.subject, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { sent: userIds.length } }), /** * Get notification log (admin) */ getLog: adminProcedure .input( z.object({ startDate: z.date().optional(), endDate: z.date().optional(), channel: z.enum(['EMAIL', 'IN_APP', 'ALL']).default('ALL'), status: z.enum(['SENT', 'FAILED', 'ALL']).default('ALL'), limit: z.number().default(100), offset: z.number().default(0), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.notificationLog.findMany({ where: { ...(input.startDate && { createdAt: { gte: input.startDate } }), ...(input.endDate && { createdAt: { lte: input.endDate } }), ...(input.channel !== 'ALL' && { channel: input.channel }), ...(input.status !== 'ALL' && { status: input.status }), }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: 'desc' }, take: input.limit, skip: input.offset, }) }), }) ``` ### Deadline Router **Deadline management router:** ```typescript // src/server/routers/deadline.ts import { z } from 'zod' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { emitRoundEvent } from '../services/round-notifications' export const deadlineRouter = router({ /** * Extend round deadline (affects all participants) */ extendRound: adminProcedure .input( z.object({ roundId: z.string(), newWindowCloseAt: z.date(), reason: z.string(), notifyParticipants: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.update({ where: { id: input.roundId }, data: { windowCloseAt: input.newWindowCloseAt }, include: { competition: true, juryGroup: { include: { members: true } }, }, }) // Create override action await ctx.prisma.overrideAction.create({ data: { entityType: 'Round', entityId: input.roundId, previousValue: { windowCloseAt: round.windowCloseAt }, newValueJson: { windowCloseAt: input.newWindowCloseAt }, reasonCode: 'ADMIN_DISCRETION', reasonText: input.reason, actorId: ctx.user.id, }, }) // Emit event await emitRoundEvent( 'admin.deadline_extended', 'Round', input.roundId, ctx.user.id, { roundId: input.roundId, previousDeadline: round.windowCloseAt, newDeadline: input.newWindowCloseAt, reason: input.reason, }, ctx.prisma ) // Notify participants if requested if (input.notifyParticipants) { // Get affected users (jury members for this round) const affectedUserIds = round.juryGroup?.members.map((m) => m.userId) || [] if (affectedUserIds.length > 0) { await ctx.prisma.inAppNotification.createMany({ data: affectedUserIds.map((userId) => ({ userId, type: 'admin.deadline_extended', title: 'Deadline Extended', message: `The deadline for ${round.name} has been extended to ${input.newWindowCloseAt.toLocaleString()}. Reason: ${input.reason}`, linkUrl: `/jury/rounds/${input.roundId}`, priority: 'high', icon: 'Clock', })), }) } } // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'EXTEND_ROUND_DEADLINE', entityType: 'Round', entityId: input.roundId, detailsJson: { previousDeadline: round.windowCloseAt?.toISOString(), newDeadline: input.newWindowCloseAt.toISOString(), reason: input.reason, notified: input.notifyParticipants, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return round }), /** * Extend submission window deadline */ extendSubmissionWindow: adminProcedure .input( z.object({ windowId: z.string(), newWindowCloseAt: z.date(), reason: z.string(), notifyApplicants: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) => { const window = await ctx.prisma.submissionWindow.update({ where: { id: input.windowId }, data: { windowCloseAt: input.newWindowCloseAt }, include: { competition: true, rounds: { include: { projectRoundStates: { include: { project: { include: { submittedBy: true }, }, }, }, }, }, }, }) // Notify affected applicants if (input.notifyApplicants) { const affectedUserIds = new Set() for (const round of window.rounds) { for (const state of round.projectRoundStates) { if (state.project.submittedByUserId) { affectedUserIds.add(state.project.submittedByUserId) } } } if (affectedUserIds.size > 0) { await ctx.prisma.inAppNotification.createMany({ data: Array.from(affectedUserIds).map((userId) => ({ userId, type: 'admin.deadline_extended', title: 'Submission Deadline Extended', message: `The submission deadline for ${window.name} has been extended to ${input.newWindowCloseAt.toLocaleString()}. Reason: ${input.reason}`, linkUrl: `/applicant/submissions/${input.windowId}`, priority: 'high', icon: 'Clock', })), }) } } // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'EXTEND_SUBMISSION_DEADLINE', entityType: 'SubmissionWindow', entityId: input.windowId, detailsJson: { previousDeadline: window.windowCloseAt?.toISOString(), newDeadline: input.newWindowCloseAt.toISOString(), reason: input.reason, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return window }), }) ``` --- ## Service Functions ### Round Notifications Service **Complete service interface:** ```typescript // src/server/services/round-notifications.ts import type { PrismaClient } from '@prisma/client' /** * Emit a round event with notification support. */ export async function emitRoundEvent( eventType: string, entityType: string, entityId: string, actorId: string, details: Record, prisma: PrismaClient ): Promise /** * Convenience producers for common events. */ export async function onRoundOpened( roundId: string, actorId: string, prisma: PrismaClient ): Promise export async function onRoundClosed( roundId: string, actorId: string, prisma: PrismaClient ): Promise export async function onSubmissionReceived( projectId: string, submissionWindowId: string, submittedByUserId: string, fileCount: number, isComplete: boolean, prisma: PrismaClient ): Promise export async function onFilteringCompleted( jobId: string, roundId: string, total: number, passed: number, rejected: number, flagged: number, actorId: string, prisma: PrismaClient ): Promise export async function onAssignmentCreated( assignmentId: string, roundId: string, projectId: string, userId: string, actorId: string, prisma: PrismaClient ): Promise export async function onMentorFileUploaded( fileId: string, mentorAssignmentId: string, uploadedByUserId: string, prisma: PrismaClient ): Promise export async function onWinnerApprovalRequired( proposalId: string, category: string, requiredApprovers: Array<{ userId: string; role: string }>, actorId: string, prisma: PrismaClient ): Promise export async function onResultsFrozen( proposalId: string, category: string, frozenByUserId: string, prisma: PrismaClient ): Promise ``` ### Deadline Reminder Service **Service interface:** ```typescript // src/server/services/deadline-reminders.ts export interface ReminderResult { sent: number errors: number } /** * Process all deadline reminders (called by cron). */ export async function processDeadlineReminders(): Promise /** * Process evaluation round reminders. */ export async function processEvaluationReminders( now: Date ): Promise /** * Process submission window reminders. */ export async function processSubmissionReminders( now: Date ): Promise /** * Send reminders for a specific round. */ export async function sendEvaluationReminders( round: any, reminderType: string, now: Date ): Promise /** * Send reminders for a specific submission window. */ export async function sendSubmissionReminders( window: any, reminderType: string, now: Date ): Promise /** * Determine which reminder types should fire. */ export function getReminderTypesForDeadline( msUntilDeadline: number, reminderDays: number[], reminderHours: number[] ): string[] ``` --- ## Edge Cases | Edge Case | Scenario | Handling | |-----------|----------|----------| | **Midnight deadline** | Round closes at 23:59:59, cron runs at 00:00 | Grace period calculations account for timezone. Use `<=` comparison for windowCloseAt. | | **Clock drift** | Client clock is 5 minutes fast | Server-side time sync via `trpc.time.getServerTime()`. Countdown uses synced time. | | **Duplicate reminders** | Cron runs twice (overlapping) | `ReminderLog` unique constraint prevents duplicates. Idempotent reminder sending. | | **Grace period overlap** | User has grace period + round is extended | Use whichever deadline is later: `max(userGrace, round.windowCloseAt)`. | | **Notification flood** | 100 users receive reminder at once | Batch email sending (max 50/min). Use queue for large batches. | | **Timezone confusion** | User in PST, server in UTC | All deadlines stored in UTC. Display in user's timezone (future enhancement). | | **Email delivery failure** | SMTP server down | Retry 3 times with exponential backoff. Log failure in `NotificationLog`. In-app notification still created. | | **Cron secret leak** | CRON_SECRET exposed | Rotate immediately. Use header validation. Log all cron requests. | | **Reminder after deadline** | Cron delayed, now > deadline | Skip reminder. No reminders sent after deadline passes. | | **User opts out of emails** | User sets `notificationPreference: NONE` | Respect preference. Still create in-app notifications. | | **Template variable missing** | Template uses {{foo}} but data has no foo | Replace with empty string. Log warning. Don't fail email send. | | **Round extended mid-reminder** | Deadline extended after 3d reminder sent | 1d reminder will still fire (based on new deadline). No duplicate 3d reminder. | | **Bulk notification failure** | 1 out of 50 emails fails | Log failure, continue with remaining. Return `{ sent: 49, errors: 1 }`. | --- ## Integration Points ### How Notifications Connect to Every Round Type **INTAKE Round:** - `intake.window_opened` → Triggered when round status changes to ACTIVE - `intake.submission_received` → Triggered by `ProjectFile.create()` via `trpc.application.uploadFile` - `intake.deadline_approaching` → Triggered by cron checking `SubmissionWindow.windowCloseAt` - Email sent to: All applicants (window opened), team lead (submission received), incomplete applicants (deadline reminder) **FILTERING Round:** - `filtering.started` → Triggered by `trpc.filtering.runStageFiltering` creating `FilteringJob` - `filtering.completed` → Triggered when `FilteringJob.status → COMPLETED` - `filtering.project_advanced` → Triggered when `ProjectRoundState.state → PASSED` - `filtering.project_rejected` → Triggered when `ProjectRoundState.state → REJECTED` - Email sent to: Admins (started/completed), team lead (advanced/rejected) **EVALUATION Round:** - `evaluation.assignment_created` → Triggered by `trpc.assignment.create` or AI assignment - `evaluation.deadline_approaching` → Triggered by cron checking `Round.windowCloseAt` - `evaluation.submitted` → Triggered when `Evaluation.status → SUBMITTED` - `evaluation.round_complete` → Triggered when all assignments completed - Email sent to: Assigned juror (created, deadline), admins (round complete) **SUBMISSION Round:** - `submission.window_opened` → Triggered when SubmissionWindow opens + round ACTIVE - `submission.new_docs_required` → Triggered when eligible projects enter round - `submission.docs_submitted` → Triggered by `ProjectFile.create()` for window - `submission.deadline_approaching` → Triggered by cron - Email sent to: Eligible teams (window opened, new docs), team lead (submitted, deadline) **MENTORING Round:** - `mentoring.assigned` → Triggered by `MentorAssignment.create()` - `mentoring.workspace_opened` → Triggered when round ACTIVE - `mentoring.message_received` → Triggered by `MentorMessage.create()` - `mentoring.file_uploaded` → Triggered by `MentorFile.create()` - `mentoring.file_promoted` → Triggered when `MentorFile.isPromoted → true` - Email sent to: Mentor + team (assigned, workspace), recipient (message), other party (file) **LIVE_FINAL Round:** - `live_final.ceremony_starting` → Triggered when `LiveVotingSession.status → IN_PROGRESS` - `live_final.vote_required` → Triggered when `LiveProgressCursor` updated - `live_final.deliberation_started` → Triggered when deliberation period begins - `live_final.results_ready` → Triggered when all votes cast - Email sent to: Jury + audience (ceremony starting), jury (vote required), admins (results ready) - In-app only: Real-time vote required notifications **CONFIRMATION Round:** - `confirmation.approval_required` → Triggered by `WinnerProposal.create()` - `confirmation.approval_received` → Triggered when `WinnerApproval.approved → true` - `confirmation.approved` → Triggered when all approvals received - `confirmation.frozen` → Triggered when `WinnerProposal.frozenAt` set - Email sent to: Jury + admins (approval required), admins + jury (approved, frozen) ### Cross-Round Notification Scenarios **Scenario 1: Project advances from Filtering to Evaluation** 1. `FilteringJob` completes → `filtering.completed` (admin email) 2. Admin reviews flagged projects → Manual override 3. `ProjectRoundState.state → PASSED` → `filtering.project_advanced` (team email) 4. Admin clicks "Advance to Evaluation" → Projects moved to Evaluation round 5. `Round.status → ACTIVE` for Evaluation round → `round.opened` (admin in-app) 6. AI assignment runs → Creates assignments 7. `Assignment.create()` → `evaluation.assignment_created` (each juror email) **Scenario 2: Deadline approaching with grace periods** 1. Cron runs 3 days before deadline 2. Finds 5 jurors with incomplete evaluations 3. Checks `ReminderLog` — 2 already received "3d" reminder 4. Checks `GracePeriod` — 1 juror has extended deadline (no reminder) 5. Sends "3d" reminder to 2 jurors 6. Creates `ReminderLog` entries for those 2 7. Emits `evaluation.deadline_approaching` event 8. Creates in-app notifications for all 5 jurors (including grace period) **Scenario 3: Round deadline extended mid-cycle** 1. Admin extends deadline from March 15 to March 20 2. `Round.windowCloseAt` updated 3. `admin.deadline_extended` event emitted 4. In-app + email notifications sent to all assigned jurors 5. Existing `ReminderLog` entries remain (prevent duplicate "7d", "3d") 6. New reminders fire based on new deadline: - If extension happens after "3d" reminder: "1d" and "24h" reminders still fire - If extension happens before "3d" reminder: All reminders fire normally --- ## Summary This Notifications & Deadlines system provides: 1. **Event-driven architecture** — All significant round events trigger notifications 2. **Multi-channel delivery** — Email, in-app, and future webhook support 3. **Flexible deadline policies** — HARD, FLAG, GRACE modes per window/round 4. **Automated reminders** — Configurable intervals (days and hours) before deadlines 5. **Grace period management** — Individual and bulk extensions with audit trail 6. **Real-time countdowns** — Client-side timers with server time sync 7. **Admin controls** — Competition-wide and per-round configuration 8. **Template system** — Reusable email templates with variable substitution 9. **Deduplication** — Unique constraints prevent duplicate reminders 10. **Integration** — Deep connections to all round types and pipeline events This system ensures participants are always informed, deadlines are clear, and admins have full control over notification behavior across the entire competition lifecycle.