2899 lines
96 KiB
Markdown
2899 lines
96 KiB
Markdown
# 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.
|