# 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<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:**
```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<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:**
```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
<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:**
```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<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:**
```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<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):**
```typescript
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:**
```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<typeof BaseEventSchema>
```

**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<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:**
```typescript
// 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:**
```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 (
    <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:**
```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 (
    <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:**
```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 (
    <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):**
```typescript
// 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):**
```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<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:**
```tsx
// 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:**
```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<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):**
```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<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:**
```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):                                                             │
│ ┌──────────────────────────────────────────────────────────────────┐   │
│ │ <!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:**
```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<typeof TemplateVariableSchema>
export type MessageTemplateInput = z.infer<typeof MessageTemplateSchema>
```

**Template validation function:**
```typescript
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:**
```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<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:**
```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<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:**
```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<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 → 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.
