MOPC-App/docs/claude-architecture-redesign/13-notifications-deadlines.md

96 KiB

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:

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<void>

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:

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:

export async function processEvaluationReminders(
  stageId?: string
): Promise<ReminderResult>

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:

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:

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:

// 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:

<CountdownTimer
  deadline={new Date(stage.windowCloseAt)}
  label="Time remaining"
/>

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:

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:

export async function sendStyledNotificationEmail(
  to: string,
  name: string,
  type: string,  // Template type
  data: {
    title: string
    message: string
    linkUrl?: string
    metadata?: Record<string, unknown>
  }
): Promise<void>

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:

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:

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:

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:

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:

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:

// 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:

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:

model SubmissionWindow {
  // ... existing fields ...

  deadlinePolicy  DeadlinePolicy @default(FLAG)
  graceHours      Int?           // For GRACE policy: hours after windowCloseAt
}

Evaluation rounds use Round.windowCloseAt + GracePeriod model:

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:

type SubmissionDeadlineCheck = {
  allowed: boolean
  isLate: boolean
  reason?: string
}

export async function checkSubmissionDeadline(
  submissionWindowId: string,
  userId: string,
  prisma: PrismaClient
): Promise<SubmissionDeadlineCheck> {
  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):

export async function checkEvaluationDeadline(
  roundId: string,
  userId: string,
  prisma: PrismaClient
): Promise<SubmissionDeadlineCheck> {
  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:

// 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:

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<typeof BaseEventSchema>

Intake events:

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:

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:

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:

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:

export interface EmailNotificationData {
  to: string          // Recipient email
  name: string        // Recipient name
  subject: string     // Email subject
  template: string    // Template ID
  variables: Record<string, unknown>  // Template variables
  replyTo?: string    // Custom reply-to
  priority?: 'low' | 'normal' | 'high' | 'urgent'
}

export async function sendNotificationEmail(
  data: EmailNotificationData
): Promise<void>

Email templates with variables:

// Template: evaluation-deadline-reminder
const template = `
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: Montserrat, Arial, sans-serif; }
    .header { background: #053d57; color: white; padding: 20px; }
    .content { padding: 20px; }
    .countdown { background: #fef5f5; border-left: 4px solid #de0f1e; padding: 15px; margin: 20px 0; }
    .cta { background: #de0f1e; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; }
  </style>
</head>
<body>
  <div class="header">
    <h1>{{competitionName}}</h1>
  </div>
  <div class="content">
    <p>Dear {{name}},</p>

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

    <div class="countdown">
      <h3>⏰ Deadline: {{deadline}}</h3>
      <p>{{timeRemaining}} remaining</p>
    </div>

    <p>Please complete your evaluations before the deadline.</p>

    <p style="text-align: center; margin: 30px 0;">
      <a href="{{linkUrl}}" class="cta">Complete Evaluations</a>
    </p>

    <p>If you need an extension, please contact the program administrator.</p>

    <p>Best regards,<br>{{competitionName}} Team</p>
  </div>
</body>
</html>
`

// 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:

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):

// 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 (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="relative">
          <Bell className="h-5 w-5" />
          {unreadCount > 0 && (
            <span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-600 text-xs text-white">
              {unreadCount > 9 ? '9+' : unreadCount}
            </span>
          )}
        </Button>
      </DropdownMenuTrigger>

      <DropdownMenuContent align="end" className="w-80">
        <div className="flex items-center justify-between px-3 py-2 border-b">
          <h3 className="font-semibold">Notifications</h3>
          {unreadCount > 0 && (
            <Button
              variant="ghost"
              size="sm"
              onClick={() => markAllRead.mutate()}
            >
              Mark all read
            </Button>
          )}
        </div>

        <div className="max-h-96 overflow-y-auto">
          {recent?.map((notification) => (
            <NotificationItem key={notification.id} notification={notification} />
          ))}

          {recent?.length === 0 && (
            <div className="p-6 text-center text-muted-foreground">
              No notifications
            </div>
          )}
        </div>

        <div className="border-t p-2">
          <Button variant="ghost" size="sm" className="w-full" asChild>
            <Link href="/notifications">View all notifications</Link>
          </Button>
        </div>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Notification Item:

// 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 (
    <div
      onClick={handleClick}
      className={cn(
        'flex gap-3 p-3 cursor-pointer hover:bg-accent transition-colors border-b last:border-0',
        !notification.isRead && 'bg-blue-50 dark:bg-blue-950/20'
      )}
    >
      {Icon && (
        <div className={cn(
          'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
          notification.priority === 'urgent' && 'bg-red-100 text-red-600',
          notification.priority === 'high' && 'bg-orange-100 text-orange-600',
          notification.priority === 'normal' && 'bg-blue-100 text-blue-600',
          notification.priority === 'low' && 'bg-gray-100 text-gray-600'
        )}>
          <Icon className="h-4 w-4" />
        </div>
      )}

      <div className="flex-1 min-w-0">
        <div className="flex items-center justify-between gap-2">
          <h4 className={cn(
            'text-sm font-medium truncate',
            !notification.isRead && 'font-semibold'
          )}>
            {notification.title}
          </h4>
          {!notification.isRead && (
            <div className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0" />
          )}
        </div>

        <p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">
          {notification.message}
        </p>

        <p className="text-xs text-muted-foreground mt-1">
          {formatDistanceToNow(notification.createdAt, { addSuffix: true })}
        </p>
      </div>
    </div>
  )
}

Notification Center Page:

// 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 (
    <div className="container max-w-4xl py-8">
      <h1 className="text-3xl font-bold mb-6">Notifications</h1>

      <Tabs value={filter} onValueChange={(v) => setFilter(v as any)}>
        <TabsList>
          <TabsTrigger value="all">All</TabsTrigger>
          <TabsTrigger value="unread">Unread</TabsTrigger>
        </TabsList>

        <TabsContent value={filter} className="mt-4">
          <div className="border rounded-lg divide-y">
            {notifications?.map((n) => (
              <NotificationItem key={n.id} notification={n} />
            ))}

            {notifications?.length === 0 && (
              <div className="p-8 text-center text-muted-foreground">
                No notifications to show
              </div>
            )}
          </div>
        </TabsContent>
      </Tabs>
    </div>
  )
}

3. Future: Webhook Channel

Webhook integration (future enhancement):

// Future: Webhook delivery for external integrations
export interface WebhookNotificationPayload {
  event: string
  timestamp: string
  data: Record<string, unknown>
  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):

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:

// 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<Urgency, string> = {
  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<TimeRemaining>(() => 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 (
    <div
      className={cn(
        'inline-flex items-center gap-1.5 rounded-md border font-medium',
        urgencyStyles[urgency],
        sizeStyles[size],
        className
      )}
    >
      {showIcon && <IconComponent className={cn(
        'shrink-0',
        size === 'sm' && 'h-3 w-3',
        size === 'md' && 'h-4 w-4',
        size === 'lg' && 'h-5 w-5'
      )} />}
      {label && <span className="hidden sm:inline">{label}</span>}
      <span className="tabular-nums">{displayText}</span>
    </div>
  )
}

Usage examples:

// Jury dashboard - evaluation deadline
<CountdownTimer
  deadline={round.windowCloseAt}
  label="Evaluation deadline:"
  size="lg"
  onExpire={() => toast.info('Deadline has passed')}
/>

// Applicant dashboard - submission deadline
<CountdownTimer
  deadline={submissionWindow.windowCloseAt}
  label="Submit by:"
  size="md"
/>

// Admin dashboard - compact view
<CountdownTimer
  deadline={round.windowCloseAt}
  size="sm"
  showIcon={false}
/>

Server-Side Time Sync

Prevent client clock drift:

// 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:

// 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<NextResponse> {
  // 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):

// 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<ReminderResult> {
  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<ReminderResult> {
  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<ReminderResult> {
  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<ReminderResult> {
  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<string, number>()
  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<ReminderResult> {
  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:

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:

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:

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:

// 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):                                                             │
│ ┌──────────────────────────────────────────────────────────────────┐   │
│ │ <!DOCTYPE html>                                                  │   │
│ │ <html>                                                           │   │
│ │ <head>                                                           │   │
│ │   <style>                                                        │   │
│ │     .header { background: #053d57; color: white; padding: 20px; }│   │
│ │     .content { padding: 20px; }                                  │   │
│ │     .cta { background: #de0f1e; color: white; ... }             │   │
│ │   </style>                                                       │   │
│ │ </head>                                                          │   │
│ │ <body>                                                           │   │
│ │   <div class="header">                                           │   │
│ │     <h1>{{competitionName}}</h1>                                 │   │
│ │   </div>                                                         │   │
│ │   <div class="content">                                          │   │
│ │     <p>Dear {{name}},</p>                                        │   │
│ │     <p>You have <strong>{{incompleteCount}}</strong> 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:

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<typeof TemplateVariableSchema>
export type MessageTemplateInput = z.infer<typeof MessageTemplateSchema>

Template validation function:

export function validateTemplate(
  template: string,
  variables: Record<string, unknown>
): { 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:

// 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:

// 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<string>()

        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:

// 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<string, unknown>,
  prisma: PrismaClient
): Promise<void>

/**
 * Convenience producers for common events.
 */
export async function onRoundOpened(
  roundId: string,
  actorId: string,
  prisma: PrismaClient
): Promise<void>

export async function onRoundClosed(
  roundId: string,
  actorId: string,
  prisma: PrismaClient
): Promise<void>

export async function onSubmissionReceived(
  projectId: string,
  submissionWindowId: string,
  submittedByUserId: string,
  fileCount: number,
  isComplete: boolean,
  prisma: PrismaClient
): Promise<void>

export async function onFilteringCompleted(
  jobId: string,
  roundId: string,
  total: number,
  passed: number,
  rejected: number,
  flagged: number,
  actorId: string,
  prisma: PrismaClient
): Promise<void>

export async function onAssignmentCreated(
  assignmentId: string,
  roundId: string,
  projectId: string,
  userId: string,
  actorId: string,
  prisma: PrismaClient
): Promise<void>

export async function onMentorFileUploaded(
  fileId: string,
  mentorAssignmentId: string,
  uploadedByUserId: string,
  prisma: PrismaClient
): Promise<void>

export async function onWinnerApprovalRequired(
  proposalId: string,
  category: string,
  requiredApprovers: Array<{ userId: string; role: string }>,
  actorId: string,
  prisma: PrismaClient
): Promise<void>

export async function onResultsFrozen(
  proposalId: string,
  category: string,
  frozenByUserId: string,
  prisma: PrismaClient
): Promise<void>

Deadline Reminder Service

Service interface:

// 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<ReminderResult>

/**
 * Process evaluation round reminders.
 */
export async function processEvaluationReminders(
  now: Date
): Promise<ReminderResult>

/**
 * Process submission window reminders.
 */
export async function processSubmissionReminders(
  now: Date
): Promise<ReminderResult>

/**
 * Send reminders for a specific round.
 */
export async function sendEvaluationReminders(
  round: any,
  reminderType: string,
  now: Date
): Promise<ReminderResult>

/**
 * Send reminders for a specific submission window.
 */
export async function sendSubmissionReminders(
  window: any,
  reminderType: string,
  now: Date
): Promise<ReminderResult>

/**
 * 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 → PASSEDfiltering.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.