Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions __tests__/deadline-monitor/service.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>

function milestoneRow(overrides: Partial<Record<string, unknown>> = {}) {
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)
})
})
12 changes: 12 additions & 0 deletions __tests__/deadline-monitor/types.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
15 changes: 15 additions & 0 deletions components/dashboard/notification-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions docs/deadline-monitor.md
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions lib/db/migrations/008_deadline_monitor.sql
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions lib/deadline-monitor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { DeadlineMonitorService } from './service'
export { getReminderWindowMs, DEADLINE_EXEMPT_STATUSES } from './types'
export type { DeadlineCheckResult } from './types'
96 changes: 96 additions & 0 deletions lib/deadline-monitor/service.ts
Original file line number Diff line number Diff line change
@@ -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<DeadlineCheckResult> {
const remindersSent = await this.sendUpcomingDeadlineReminders()
const overdueFlagged = await this.flagOverdueMilestones()
return { remindersSent, overdueFlagged }
}

private async sendUpcomingDeadlineReminders(): Promise<number> {
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<number> {
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<void> {
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
}
14 changes: 14 additions & 0 deletions lib/deadline-monitor/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading