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

2899 lines
96 KiB
Markdown
Raw Permalink Normal View History

# 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.