diff --git a/__tests__/deadline-monitor/service.test.ts b/__tests__/deadline-monitor/service.test.ts new file mode 100644 index 0000000..76f2fc4 --- /dev/null +++ b/__tests__/deadline-monitor/service.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +vi.mock('@/lib/notifications', () => ({ + createNotification: vi.fn().mockResolvedValue(undefined), +})) + +import { sql } from '@/lib/db' +import { createNotification } from '@/lib/notifications' +import { DeadlineMonitorService } from '@/lib/deadline-monitor/service' + +type SqlMock = ReturnType + +function milestoneRow(overrides: Partial> = {}) { + return { + id: 'milestone-1', + title: 'Design mockups', + due_date: '2026-07-26T00:00:00.000Z', + contract_id: 'contract-1', + client_id: 'client-1', + freelancer_id: 'freelancer-1', + ...overrides, + } +} + +describe('DeadlineMonitorService', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends no reminders and flags nothing overdue when nothing is due', async () => { + ;(sql as unknown as SqlMock) + .mockResolvedValueOnce([]) // reminder sweep + .mockResolvedValueOnce([]) // overdue sweep + + const service = new DeadlineMonitorService() + const result = await service.runCheck() + + expect(result).toEqual({ remindersSent: 0, overdueFlagged: 0 }) + expect(createNotification).not.toHaveBeenCalled() + }) + + it('notifies both client and freelancer and marks the reminder as sent', async () => { + const row = milestoneRow() + ;(sql as unknown as SqlMock) + .mockResolvedValueOnce([row]) // reminder sweep finds one milestone + .mockResolvedValueOnce([]) // UPDATE reminder_sent_at + .mockResolvedValueOnce([]) // overdue sweep finds nothing + + const service = new DeadlineMonitorService() + const result = await service.runCheck() + + expect(result.remindersSent).toBe(1) + expect(createNotification).toHaveBeenCalledTimes(2) + expect(createNotification).toHaveBeenCalledWith( + 'client-1', + 'milestone_deadline_approaching', + expect.objectContaining({ milestoneId: 'milestone-1', milestoneName: 'Design mockups' }) + ) + expect(createNotification).toHaveBeenCalledWith( + 'freelancer-1', + 'milestone_deadline_approaching', + expect.objectContaining({ milestoneId: 'milestone-1' }) + ) + + // The UPDATE call for reminder_sent_at must have run. + expect(sql).toHaveBeenCalledTimes(3) + }) + + it('flags overdue milestones, notifies both parties, and returns the count', async () => { + const row = milestoneRow({ id: 'milestone-2' }) + ;(sql as unknown as SqlMock) + .mockResolvedValueOnce([]) // reminder sweep finds nothing + .mockResolvedValueOnce([row]) // overdue sweep finds one milestone + .mockResolvedValueOnce([]) // UPDATE is_overdue + + const service = new DeadlineMonitorService() + const result = await service.runCheck() + + expect(result.overdueFlagged).toBe(1) + expect(createNotification).toHaveBeenCalledTimes(2) + expect(createNotification).toHaveBeenCalledWith( + 'client-1', + 'milestone_overdue', + expect.objectContaining({ milestoneId: 'milestone-2' }) + ) + expect(createNotification).toHaveBeenCalledWith( + 'freelancer-1', + 'milestone_overdue', + expect.objectContaining({ milestoneId: 'milestone-2' }) + ) + }) + + it('handles reminders and overdue milestones in the same run independently', async () => { + const reminderRow = milestoneRow({ id: 'reminder-milestone' }) + const overdueRow = milestoneRow({ id: 'overdue-milestone' }) + + ;(sql as unknown as SqlMock) + .mockResolvedValueOnce([reminderRow]) + .mockResolvedValueOnce([]) // UPDATE reminder_sent_at + .mockResolvedValueOnce([overdueRow]) + .mockResolvedValueOnce([]) // UPDATE is_overdue + + const service = new DeadlineMonitorService() + const result = await service.runCheck() + + expect(result).toEqual({ remindersSent: 1, overdueFlagged: 1 }) + expect(createNotification).toHaveBeenCalledTimes(4) + }) +}) diff --git a/__tests__/deadline-monitor/types.test.ts b/__tests__/deadline-monitor/types.test.ts new file mode 100644 index 0000000..a3e1926 --- /dev/null +++ b/__tests__/deadline-monitor/types.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' +import { DEADLINE_EXEMPT_STATUSES, getReminderWindowMs } from '@/lib/deadline-monitor/types' + +describe('deadline-monitor types', () => { + it('exempts approved, paid, and rejected milestones from deadline tracking', () => { + expect(DEADLINE_EXEMPT_STATUSES).toEqual(['approved', 'paid', 'rejected']) + }) + + it('uses a 24 hour reminder window', () => { + expect(getReminderWindowMs()).toBe(24 * 60 * 60 * 1000) + }) +}) diff --git a/components/dashboard/notification-item.tsx b/components/dashboard/notification-item.tsx index dc24d5f..2d6628f 100644 --- a/components/dashboard/notification-item.tsx +++ b/components/dashboard/notification-item.tsx @@ -100,6 +100,21 @@ const NOTIFICATION_CONFIG: Record< description: (payload) => `Payment of ${(payload.amount as string) || "funds"} has been released`, }, + milestone_deadline_approaching: { + icon: "⏰", + color: + "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300", + label: "Deadline Approaching", + description: (payload) => + `Milestone "${(payload.milestoneName as string) || "Unnamed"}" is due soon`, + }, + milestone_overdue: { + icon: "⏱️", + color: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300", + label: "Milestone Overdue", + description: (payload) => + `Milestone "${(payload.milestoneName as string) || "Unnamed"}" has passed its due date`, + }, wallet_activity: { icon: "$", color: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300", diff --git a/docs/deadline-monitor.md b/docs/deadline-monitor.md new file mode 100644 index 0000000..5d7bfdc --- /dev/null +++ b/docs/deadline-monitor.md @@ -0,0 +1,102 @@ +# Scheduled Contract Deadline Monitor + +## Overview + +A scheduled service that sweeps milestones for approaching and missed due dates, keeping both clients and freelancers informed without anyone having to check manually. It runs independently of the Soroban contract-sync worker (see `docs/sync-flow.md`) — this monitor only cares about `due_date`, not on-chain events. + +## Architecture + +``` + ┌─────────────────────────┐ + every N min ─> │ DeadlineMonitorService │ + │ .runCheck() │ + └───────────┬─────────────┘ + │ + ┌─────────────┴──────────────┐ + │ │ + sendUpcomingDeadlineReminders flagOverdueMilestones + │ │ + milestones due within milestones past due_date, + 24h, not yet reminded not yet flagged overdue + │ │ + ▼ ▼ + reminder_sent_at = NOW() is_overdue = TRUE, overdue_at = NOW() + │ │ + └─────────────┬──────────────┘ + ▼ + createNotification(client, ...) + createNotification(freelancer, ...) + │ + ▼ + `notifications` table + (surfaced in the dashboard + notification panel) +``` + +## Key Components + +### 1. Service (`lib/deadline-monitor/service.ts`) + +`DeadlineMonitorService.runCheck()` performs two independent sweeps and returns `{ remindersSent, overdueFlagged }`: + +1. **Upcoming deadline reminders** — milestones with `due_date` in the next 24 hours, not already reminded, and not in a terminal status (`approved`, `paid`, `rejected`). Sends one notification to the client and one to the freelancer, then sets `reminder_sent_at` so it is never sent twice. +2. **Overdue flagging** — milestones whose `due_date` has passed, not already flagged, and not in a terminal status. Sets `is_overdue = TRUE` and `overdue_at = NOW()`, then notifies both parties. + +Both sweeps are idempotent by design: re-running `runCheck()` on a schedule only acts on milestones that haven't crossed that particular threshold yet, so it's safe to run as often as needed without spamming users. + +### 2. Types (`lib/deadline-monitor/types.ts`) + +- `DEADLINE_EXEMPT_STATUSES` — milestone statuses (`approved`, `paid`, `rejected`) that no longer need deadline tracking. +- `getReminderWindowMs()` — how far ahead of the due date to send the "approaching" reminder (24 hours). + +### 3. Notifications (`lib/notifications.ts`) + +Reminder and overdue events reuse the existing `notifications` table and API rather than introducing a parallel event log — `milestone_deadline_approaching` and `milestone_overdue` were added to `NotificationType`. This means they automatically get pagination, read/unread state, and UI rendering (`components/dashboard/notification-item.tsx`) for free. + +### 4. Database changes (`lib/db/migrations/008_deadline_monitor.sql`) + +```sql +ALTER TABLE milestones + ADD COLUMN is_overdue BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN overdue_at TIMESTAMPTZ, + ADD COLUMN reminder_sent_at TIMESTAMPTZ; +``` + +Plus a partial index (`idx_milestones_overdue_sweep`) on `due_date` for milestones not yet flagged overdue, so the sweep query stays fast as the table grows. + +## Running the Monitor + +### Prerequisites + +1. `DATABASE_URL` set in `.env`. +2. Migrations applied: `npm run migrate`. + +### Start + +```bash +npm run deadline-monitor +# or directly: +tsx scripts/deadline-monitor.ts +``` + +### Configuration + +- `DEADLINE_CHECK_INTERVAL_MS` — how often to sweep (default: `300000` = 5 minutes). + +The worker runs one check immediately on startup, then on the configured interval, logging a summary after each pass: + +``` +[DeadlineMonitor] Check complete — reminders sent: 2, newly overdue: 1 +``` + +## Testing + +```bash +npx vitest run __tests__/deadline-monitor +``` + +Unit tests cover: +- No-op behavior when nothing is due +- Reminder notifications + `reminder_sent_at` bookkeeping +- Overdue flagging + notifications +- Both sweeps running independently within the same `runCheck()` call diff --git a/lib/db/migrations/008_deadline_monitor.sql b/lib/db/migrations/008_deadline_monitor.sql new file mode 100644 index 0000000..afd749a --- /dev/null +++ b/lib/db/migrations/008_deadline_monitor.sql @@ -0,0 +1,13 @@ +-- Scheduled Contract Deadline Monitor: adds the bookkeeping columns needed +-- to detect overdue milestones and send each reminder/overdue notification +-- exactly once (re-running the sweep must not re-notify users). + +ALTER TABLE milestones + ADD COLUMN IF NOT EXISTS is_overdue BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS overdue_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMPTZ; + +-- Sweep for milestones that just became overdue and haven't been flagged yet. +CREATE INDEX IF NOT EXISTS idx_milestones_overdue_sweep + ON milestones (due_date) + WHERE due_date IS NOT NULL AND is_overdue = FALSE; diff --git a/lib/deadline-monitor/index.ts b/lib/deadline-monitor/index.ts new file mode 100644 index 0000000..49017aa --- /dev/null +++ b/lib/deadline-monitor/index.ts @@ -0,0 +1,3 @@ +export { DeadlineMonitorService } from './service' +export { getReminderWindowMs, DEADLINE_EXEMPT_STATUSES } from './types' +export type { DeadlineCheckResult } from './types' diff --git a/lib/deadline-monitor/service.ts b/lib/deadline-monitor/service.ts new file mode 100644 index 0000000..63f3679 --- /dev/null +++ b/lib/deadline-monitor/service.ts @@ -0,0 +1,96 @@ +import { sql } from '@/lib/db' +import { createNotification } from '@/lib/notifications' +import { DEADLINE_EXEMPT_STATUSES, getReminderWindowMs } from './types' +import type { DeadlineCheckResult } from './types' + +/** + * Detects milestones approaching or past their due date and keeps the + * backend in sync: sends a one-time "deadline approaching" reminder, flags + * milestones as overdue once their due date passes, and notifies both the + * client and the freelancer on each transition. Designed to be safe to run + * repeatedly on a schedule — every write path is guarded by a column + * (`reminder_sent_at` / `is_overdue`) so the same milestone is never + * notified twice for the same transition. + */ +export class DeadlineMonitorService { + async runCheck(): Promise { + const remindersSent = await this.sendUpcomingDeadlineReminders() + const overdueFlagged = await this.flagOverdueMilestones() + return { remindersSent, overdueFlagged } + } + + private async sendUpcomingDeadlineReminders(): Promise { + const windowMs = getReminderWindowMs() + + const rows = (await sql` + SELECT m.id, m.title, m.due_date, m.contract_id, + c.client_id, c.freelancer_id + FROM milestones m + JOIN contracts c ON c.id = m.contract_id + WHERE m.due_date IS NOT NULL + AND m.due_date <= NOW() + (${windowMs} * INTERVAL '1 millisecond') + AND m.due_date > NOW() + AND m.reminder_sent_at IS NULL + AND m.status != ALL(${[...DEADLINE_EXEMPT_STATUSES]}::milestone_status[]) + `) as RawMilestoneRow[] + + for (const row of rows) { + await this.notifyBoth(row, 'milestone_deadline_approaching') + await sql` + UPDATE milestones SET reminder_sent_at = NOW() WHERE id = ${row.id}::uuid + ` + } + + return rows.length + } + + private async flagOverdueMilestones(): Promise { + const rows = (await sql` + SELECT m.id, m.title, m.due_date, m.contract_id, + c.client_id, c.freelancer_id + FROM milestones m + JOIN contracts c ON c.id = m.contract_id + WHERE m.due_date IS NOT NULL + AND m.due_date <= NOW() + AND m.is_overdue = FALSE + AND m.status != ALL(${[...DEADLINE_EXEMPT_STATUSES]}::milestone_status[]) + `) as RawMilestoneRow[] + + for (const row of rows) { + await sql` + UPDATE milestones + SET is_overdue = TRUE, + overdue_at = NOW(), + updated_at = NOW() + WHERE id = ${row.id}::uuid + ` + await this.notifyBoth(row, 'milestone_overdue') + } + + return rows.length + } + + private async notifyBoth( + row: RawMilestoneRow, + type: 'milestone_deadline_approaching' | 'milestone_overdue' + ): Promise { + const payload = { + milestoneId: row.id, + milestoneName: row.title, + contractId: row.contract_id, + dueDate: row.due_date, + } + + await createNotification(row.client_id, type, payload) + await createNotification(row.freelancer_id, type, payload) + } +} + +interface RawMilestoneRow { + id: string + title: string + due_date: string + contract_id: string + client_id: string + freelancer_id: string +} diff --git a/lib/deadline-monitor/types.ts b/lib/deadline-monitor/types.ts new file mode 100644 index 0000000..415834d --- /dev/null +++ b/lib/deadline-monitor/types.ts @@ -0,0 +1,14 @@ +export interface DeadlineCheckResult { + /** Milestones for which an "upcoming deadline" reminder was sent this run. */ + remindersSent: number + /** Milestones newly flagged as overdue (and notified) this run. */ + overdueFlagged: number +} + +/** Milestone statuses that no longer need deadline tracking once reached. */ +export const DEADLINE_EXEMPT_STATUSES = ['approved', 'paid', 'rejected'] as const + +/** How far ahead of the due date to send a single "deadline approaching" reminder. */ +export function getReminderWindowMs(): number { + return 24 * 60 * 60 * 1000 // 24 hours +} diff --git a/lib/notifications.ts b/lib/notifications.ts index 9c0777b..7b486fa 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -22,6 +22,8 @@ export type NotificationType = | "escrow_refunded" | "payment_released" | "payment_received" + | "milestone_deadline_approaching" + | "milestone_overdue" | "wallet_activity"; export interface Notification { @@ -258,6 +260,14 @@ const CONTENT_BUILDERS: Record< title: "Escrow refunded", body: `Escrow funds of ${str(p, "amount") ?? "the contract"} were refunded to the client.`, }), + milestone_deadline_approaching: (p) => ({ + title: "Deadline approaching", + body: `Milestone "${str(p, "milestoneName") ?? "Unnamed"}" is due soon.`, + }), + milestone_overdue: (p) => ({ + title: "Milestone overdue", + body: `Milestone "${str(p, "milestoneName") ?? "Unnamed"}" has passed its due date.`, + }), wallet_activity: (p) => ({ title: "Wallet activity", body: `${str(p, "description") ?? "There was activity on your wallet"}${str(p, "amount") ? ` (${str(p, "amount")})` : ""}.`, diff --git a/package.json b/package.json index 916770d..67ed5dd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest", "worker": "tsx scripts/worker.ts", "sync-worker": "tsx scripts/sync-worker.ts", + "deadline-monitor": "tsx scripts/deadline-monitor.ts", "migrate": "tsx scripts/migrate.ts", "build:production": "npm run migrate && next build", "start:production": "next start" diff --git a/scripts/deadline-monitor.ts b/scripts/deadline-monitor.ts new file mode 100644 index 0000000..9c7fd6c --- /dev/null +++ b/scripts/deadline-monitor.ts @@ -0,0 +1,45 @@ +import { DeadlineMonitorService } from '@/lib/deadline-monitor' +import * as dotenv from 'dotenv' + +dotenv.config() + +if (!process.env.DATABASE_URL) { + console.error('FATAL: DATABASE_URL is not set. Deadline monitor cannot connect to the database.') + process.exit(1) +} + +const CHECK_INTERVAL_MS = Number(process.env.DEADLINE_CHECK_INTERVAL_MS) || 5 * 60_000 // 5 minutes + +async function startDeadlineMonitor() { + const service = new DeadlineMonitorService() + console.log(`[DeadlineMonitor] Starting scheduled deadline monitor (interval: ${CHECK_INTERVAL_MS}ms)...`) + + const runCheck = async () => { + try { + const result = await service.runCheck() + console.log( + `[DeadlineMonitor] Check complete — reminders sent: ${result.remindersSent}, newly overdue: ${result.overdueFlagged}` + ) + } catch (err) { + console.error('[DeadlineMonitor] Check failed:', err) + } + } + + await runCheck() + setInterval(runCheck, CHECK_INTERVAL_MS) +} + +process.on('SIGINT', () => { + console.log('[DeadlineMonitor] Gracefully shutting down...') + process.exit(0) +}) + +process.on('SIGTERM', () => { + console.log('[DeadlineMonitor] Gracefully shutting down...') + process.exit(0) +}) + +startDeadlineMonitor().catch((err) => { + console.error('[FATAL DeadlineMonitor ERROR]', err) + process.exit(1) +})