merge(phase-11): wave 2 plan 11-03 backend plumbing
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
phase: 11-per-event-reminders
|
||||||
|
plan: "03"
|
||||||
|
subsystem: api/broker
|
||||||
|
tags: [valarm, reminderLeadMinutes, tdd, schema, outbox, sync, expand, cal-13, cal-14]
|
||||||
|
dependency_graph:
|
||||||
|
requires:
|
||||||
|
- "Plan 11-01 (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, NewEventParams extensions)"
|
||||||
|
provides:
|
||||||
|
- "reminderLeadMinutes in eventFieldsSchema (ingress validation)"
|
||||||
|
- "reminderLeadMinutes in outboxPayloadSchema (drain re-validation)"
|
||||||
|
- "hasExplicitReminder preserve-on-edit path in outboxWorker UPDATE branch"
|
||||||
|
- "VALARM wiring in buildVeventString calls (both UPDATE and CREATE branches)"
|
||||||
|
- "reminderLeadMinutesValue derivation + upsert in sync.ts"
|
||||||
|
- "reminderLeadMinutes on CalendarOccurrence (expand.ts)"
|
||||||
|
- "reminderLeadMinutes in GET /api/events select"
|
||||||
|
affects:
|
||||||
|
- "apps/api/src/routes/events.ts"
|
||||||
|
- "apps/api/src/broker/outboxWorker.ts"
|
||||||
|
- "apps/api/src/broker/sync.ts"
|
||||||
|
- "apps/api/src/broker/expand.ts"
|
||||||
|
- "Plan 11-02 (reminderScheduler — scheduler reads reminderLeadMinutes from DB)"
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "hasExplicitReminder sentinel mirrors hasExplicitRecurrence WR-01 pattern"
|
||||||
|
- "Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes') for absent-vs-null distinction (D-08)"
|
||||||
|
- "classifyValarms(rawVevent) for series-level reminderLeadMinutes derivation in expand.ts"
|
||||||
|
- "computeAlertInstantUtc(start, leadDays, tz) for all-day absolute DATE-TIME trigger"
|
||||||
|
- "extractValarms(rawVevent) preserve-on-edit re-attachment via addSubcomponent"
|
||||||
|
key_files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- apps/api/src/routes/events.ts
|
||||||
|
- apps/api/src/broker/outboxWorker.ts
|
||||||
|
- apps/api/src/broker/sync.ts
|
||||||
|
- apps/api/src/broker/expand.ts
|
||||||
|
- apps/api/tests/broker/outboxWorker.test.ts
|
||||||
|
- apps/api/tests/broker/sync.test.ts
|
||||||
|
- apps/api/tests/broker/expand.test.ts
|
||||||
|
decisions:
|
||||||
|
- "D-REMIND-ABSENT: absent field (not in payload) = no-change path (D-08); Object.prototype.hasOwnProperty.call distinguishes absent from null — mirrors WR-01 for VALARM preservation"
|
||||||
|
- "D-REMIND-EXPAND: reminderLeadMinutes derived inside expandOccurrences via classifyValarms(rawVevent) — self-contained; consistent with sync.ts derivation (both consume the same VEVENT source)"
|
||||||
|
- "D-REMIND-ALLDAY-LEADDAYS: all-day leadDays = reminderLeadMinutes / 1440 (consistent with D-05 mapping); computeAlertInstantUtc called at drain time (not enqueue) for correct DST"
|
||||||
|
metrics:
|
||||||
|
duration_minutes: 8
|
||||||
|
completed_date: "2026-06-14"
|
||||||
|
tasks_completed: 3
|
||||||
|
files_modified: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 11 Plan 03: reminderLeadMinutes End-to-End Plumbing Summary
|
||||||
|
|
||||||
|
`reminderLeadMinutes` round-trips end-to-end: `eventFieldsSchema` ingress validation → outbox payload drain → `buildVeventString` VALARM emission → Fastmail PUT; sync.ts parses native VALARMs into the DB column (scheduler ground truth); `CalendarOccurrence` surfaces the value for edit-mode picker pre-population.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Description | RED Commit | GREEN Commit |
|
||||||
|
|------|-------------|------------|--------------|
|
||||||
|
| 1 | Schema field + outbox worker preserve-on-edit + buildVeventString wiring | 79f6871 | 4f42b75 |
|
||||||
|
| 2 | sync.ts VALARM → reminderLeadMinutes upsert | cdca930 | 1cc0d72 |
|
||||||
|
| 3 | Surface reminderLeadMinutes on CalendarOccurrence + GET select | 7df11d2 | e171431 |
|
||||||
|
|
||||||
|
## Schema Field (eventFieldsSchema + outboxPayloadSchema)
|
||||||
|
|
||||||
|
Both schemas now have:
|
||||||
|
```typescript
|
||||||
|
reminderLeadMinutes: z.number().int().min(0).nullable().optional()
|
||||||
|
```
|
||||||
|
|
||||||
|
Four-state semantics (D-08):
|
||||||
|
- `absent` — field not present in payload; UPDATE branch preserves existing VALARM verbatim (D-08)
|
||||||
|
- `null` — explicit "None" → VALARM cleared on write-back
|
||||||
|
- `0` — same-day all-day (9 AM on event date); timed 0 = None (D-06)
|
||||||
|
- `positive` — N minutes before event start (timed) or N/1440 days before (all-day)
|
||||||
|
|
||||||
|
## hasExplicitReminder Preserve-on-Edit Path (CAL-14)
|
||||||
|
|
||||||
|
Pattern mirrors the WR-01 `hasExplicitRecurrence` + `_preservedRrule` preserve path:
|
||||||
|
|
||||||
|
UPDATE branch computes:
|
||||||
|
- `const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')`
|
||||||
|
- `!hasExplicitReminder` + rawVevent has VALARMs → `valarmsToPreserve = extractValarms(rawVevent)` (preserve verbatim via addSubcomponent)
|
||||||
|
- `hasExplicitReminder` + allDay + value → `allDayAlertInstantUtcUpdate = computeAlertInstantUtc(start, lead/1440, tz)`
|
||||||
|
- `hasExplicitReminder` + null → clear (no valarms, reminderLeadMinutes=null passed to buildVeventString)
|
||||||
|
|
||||||
|
buildVeventString call extended with: `reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined`, `valarms: valarmsToPreserve`, `allDayAlertInstantUtc: allDayAlertInstantUtcUpdate`.
|
||||||
|
|
||||||
|
CREATE branch: no preserve path (new event always carries explicit picker value). Computes `allDayAlertInstantUtcCreate` from `reminderLeadMinutes / 1440` when allDay.
|
||||||
|
|
||||||
|
## sync.ts Derivation Rule
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { classifyValarms } from './vevent.js';
|
||||||
|
const alarmClass = classifyValarms(obj.data as string);
|
||||||
|
const reminderLeadMinutesValue: number | null =
|
||||||
|
alarmClass.kind === 'preset' || alarmClass.kind === 'offlist'
|
||||||
|
? alarmClass.leadMinutes
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Written to both `.values({...})` and `.onDuplicateKeyUpdate({ set: {...} })`. Classification rules:
|
||||||
|
- `preset` or `offlist` → `leadMinutes` (scheduler ground truth)
|
||||||
|
- `custom` (absolute DATE-TIME or multiple VALARMs) → `null` (D-07/NOTIF-05)
|
||||||
|
- `none` → `null` (no VALARM)
|
||||||
|
|
||||||
|
No schema DDL change — `reminder_lead_minutes` column was added by Phase 10 migration.
|
||||||
|
|
||||||
|
## CalendarOccurrence Propagation (D-10)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CalendarOccurrence interface:
|
||||||
|
reminderLeadMinutes: number | null; // after hasRrule
|
||||||
|
```
|
||||||
|
|
||||||
|
Derived once per VEVENT in `expandOccurrences` via `classifyValarms(rawVevent)` (series-level, D-10). All occurrences inherit the master's value. Added to both non-recurring and recurring occurrence construction branches. GET `/api/events` select also includes `calendarEvents.reminderLeadMinutes` for edit-mode pre-population.
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
- `pnpm --filter @familysync/api exec vitest run tests/broker/ tests/broker/vevent.test.ts`: **132/132 PASS**
|
||||||
|
- `pnpm --filter @familysync/api exec tsc --noEmit`: **CLEAN (0 errors)**
|
||||||
|
- No `drizzle-kit push` introduced; no schema.ts DDL change
|
||||||
|
- 13 new tests added: 4 (Task 1 outboxWorker), 5 (Task 2 sync), 4 (Task 3 expand)
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None. All wiring is complete end-to-end. No placeholder values or TODO markers.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
No new threat surface. All new fields are bounded integers validated by Zod at both ingress (eventFieldsSchema) and drain (outboxPayloadSchema) — T-11-06 mitigated. `classifyValarms` is try/catch safe — T-11-07 mitigated. The VALARM preserve path (`extractValarms`) rides the existing CR-02 scoped query on the writing member's calendar — T-11-08 unchanged.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Files exist:
|
||||||
|
- FOUND: apps/api/src/routes/events.ts
|
||||||
|
- FOUND: apps/api/src/broker/outboxWorker.ts
|
||||||
|
- FOUND: apps/api/src/broker/sync.ts
|
||||||
|
- FOUND: apps/api/src/broker/expand.ts
|
||||||
|
- FOUND: apps/api/tests/broker/outboxWorker.test.ts
|
||||||
|
- FOUND: apps/api/tests/broker/sync.test.ts
|
||||||
|
- FOUND: apps/api/tests/broker/expand.test.ts
|
||||||
|
|
||||||
|
Commits exist:
|
||||||
|
- 79f6871: test(11-03) RED Task 1
|
||||||
|
- 4f42b75: feat(11-03) GREEN Task 1
|
||||||
|
- cdca930: test(11-03) RED Task 2
|
||||||
|
- 1cc0d72: feat(11-03) GREEN Task 2
|
||||||
|
- 7df11d2: test(11-03) RED Task 3
|
||||||
|
- e171431: feat(11-03) GREEN Task 3
|
||||||
|
|
||||||
|
Key exports verified:
|
||||||
|
- reminderLeadMinutes in eventFieldsSchema: CONFIRMED (grep: `reminderLeadMinutes: z.number()`)
|
||||||
|
- hasExplicitReminder in outboxWorker.ts: CONFIRMED
|
||||||
|
- reminderLeadMinutes on CalendarOccurrence: CONFIRMED
|
||||||
|
- classifyValarms import in sync.ts: CONFIRMED
|
||||||
|
- classifyValarms import in expand.ts: CONFIRMED
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import ICAL from 'ical.js';
|
import ICAL from 'ical.js';
|
||||||
|
import { classifyValarms } from './vevent.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A concrete calendar event occurrence ready for UI consumption.
|
* A concrete calendar event occurrence ready for UI consumption.
|
||||||
@@ -33,6 +34,11 @@ import ICAL from 'ical.js';
|
|||||||
* Color routing (D-06, CAL-02):
|
* Color routing (D-06, CAL-02):
|
||||||
* The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId)
|
* The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId)
|
||||||
* The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId.
|
* The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId.
|
||||||
|
*
|
||||||
|
* Phase 11 Plan 03 (D-10): reminderLeadMinutes is a series-level property — all occurrences
|
||||||
|
* of a recurring master inherit the master's value. Derived from the VEVENT's VALARM via
|
||||||
|
* classifyValarms (same logic as sync.ts so the scheduler and the GET response agree).
|
||||||
|
* NULL-vs-0 is preserved (D-06): null = no reminder; 0 = same-day all-day; positive = lead.
|
||||||
*/
|
*/
|
||||||
export interface CalendarOccurrence {
|
export interface CalendarOccurrence {
|
||||||
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
|
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
|
||||||
@@ -66,6 +72,14 @@ export interface CalendarOccurrence {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
/** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
|
/** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
|
||||||
hasRrule: boolean;
|
hasRrule: boolean;
|
||||||
|
/**
|
||||||
|
* Per-event reminder lead in minutes (Phase 11, CAL-13, D-06).
|
||||||
|
* Series-level: all occurrences inherit the master's value (D-10).
|
||||||
|
* null — no reminder / custom/absolute VALARM not reducible to a single lead
|
||||||
|
* 0 — same-day all-day (fire at 9 AM on event date)
|
||||||
|
* positive — N minutes before event start (timed) or N/1440 days before (all-day)
|
||||||
|
*/
|
||||||
|
reminderLeadMinutes: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -223,6 +237,16 @@ export function expandOccurrences(
|
|||||||
// Capture once — used in both the non-recurring and recurring branches to populate hasRrule.
|
// Capture once — used in both the non-recurring and recurring branches to populate hasRrule.
|
||||||
const isRecurring = event.isRecurring();
|
const isRecurring = event.isRecurring();
|
||||||
|
|
||||||
|
// Phase 11 Plan 03 (D-10): series-level reminderLeadMinutes — derived once from the master
|
||||||
|
// event's VALARM via classifyValarms. All occurrences inherit this value (series-level, D-10).
|
||||||
|
// preset/offlist → specific leadMinutes; custom/none → null (D-07/NOTIF-05).
|
||||||
|
// classifyValarms wraps ICAL.parse in try/catch (T-11-07 safe); safe on parse failure → null.
|
||||||
|
const alarmClass = classifyValarms(rawVevent);
|
||||||
|
const reminderLeadMinutes: number | null =
|
||||||
|
alarmClass.kind === 'preset' || alarmClass.kind === 'offlist'
|
||||||
|
? alarmClass.leadMinutes
|
||||||
|
: null;
|
||||||
|
|
||||||
// --- 4. Non-recurring event: single occurrence check ---
|
// --- 4. Non-recurring event: single occurrence check ---
|
||||||
if (!isRecurring) {
|
if (!isRecurring) {
|
||||||
if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
|
if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
|
||||||
@@ -258,6 +282,7 @@ export function expandOccurrences(
|
|||||||
location: event.location ?? null,
|
location: event.location ?? null,
|
||||||
description: event.description ?? null,
|
description: event.description ?? null,
|
||||||
hasRrule: isRecurring, // always false in the non-recurring branch
|
hasRrule: isRecurring, // always false in the non-recurring branch
|
||||||
|
reminderLeadMinutes, // series-level (D-10)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return occurrences;
|
return occurrences;
|
||||||
@@ -305,6 +330,7 @@ export function expandOccurrences(
|
|||||||
location: event.location ?? null,
|
location: event.location ?? null,
|
||||||
description: event.description ?? null,
|
description: event.description ?? null,
|
||||||
hasRrule: isRecurring, // always true in the recurring branch
|
hasRrule: isRecurring, // always true in the recurring branch
|
||||||
|
reminderLeadMinutes, // series-level — all occurrences inherit the master's value (D-10)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
* Source: poller.ts pattern (runPoll/startBrokerPoller)
|
* Source: poller.ts pattern (runPoll/startBrokerPoller)
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import ICAL from 'ical.js';
|
||||||
import { and, eq, lte } from 'drizzle-orm';
|
import { and, eq, lte } from 'drizzle-orm';
|
||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js';
|
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js';
|
||||||
@@ -33,7 +34,13 @@ import { createFastmailClient } from './client.js';
|
|||||||
import { decryptPassword } from './crypto.js';
|
import { decryptPassword } from './crypto.js';
|
||||||
import { syncCalendar } from './sync.js';
|
import { syncCalendar } from './sync.js';
|
||||||
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js';
|
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js';
|
||||||
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js';
|
import {
|
||||||
|
buildVeventString,
|
||||||
|
extractRruleString,
|
||||||
|
extractValarms,
|
||||||
|
computeAlertInstantUtc,
|
||||||
|
RRULE_PRESETS,
|
||||||
|
} from './vevent.js';
|
||||||
import type { FastmailClient } from './client.js';
|
import type { FastmailClient } from './client.js';
|
||||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
||||||
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
||||||
@@ -91,6 +98,12 @@ const outboxPayloadSchema = z
|
|||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||||
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
||||||
|
// Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08).
|
||||||
|
// absent — field not present; preserve existing VALARM verbatim (no-change path, D-08)
|
||||||
|
// null — explicit "None" → clear the VALARM on write-back
|
||||||
|
// 0 — same-day all-day reminder (fire 9 AM on event date); timed 0 = None (D-06)
|
||||||
|
// positive int — N minutes before event start (timed) or N/1440 days before (all-day)
|
||||||
|
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -423,6 +436,11 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
// deliberate user change. Read rawVevent in the same scoped query as the fresh etag.
|
// deliberate user change. Read rawVevent in the same scoped query as the fresh etag.
|
||||||
let preservedRrule: string | undefined;
|
let preservedRrule: string | undefined;
|
||||||
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
|
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
|
||||||
|
// CAL-14: mirrors the WR-01 hasExplicitRecurrence pattern for VALARM preservation.
|
||||||
|
// When the payload omits `reminderLeadMinutes` entirely (no-change, D-08), we preserve
|
||||||
|
// the existing VALARM verbatim from rawVevent via extractValarms. An explicit null clears
|
||||||
|
// the VALARM; an explicit value (timed or all-day) replaces it.
|
||||||
|
const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes');
|
||||||
const rruleFromPayload =
|
const rruleFromPayload =
|
||||||
fields.recurrence && fields.recurrence !== 'none'
|
fields.recurrence && fields.recurrence !== 'none'
|
||||||
? RRULE_PRESETS[fields.recurrence as string]
|
? RRULE_PRESETS[fields.recurrence as string]
|
||||||
@@ -465,6 +483,26 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent);
|
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAL-14: resolve VALARM for the UPDATE branch.
|
||||||
|
// absent (no hasExplicitReminder) + rawVevent has VALARMs → preserve verbatim (D-08)
|
||||||
|
// explicit null → clear (no VALARM emitted by buildVeventString)
|
||||||
|
// explicit value + allDay + valid start → compute 9 AM absolute DATE-TIME trigger (D-04)
|
||||||
|
// explicit value + timed → pass through to buildTimedValarm
|
||||||
|
let valarmsToPreserve: ICAL.Component[] | undefined;
|
||||||
|
let allDayAlertInstantUtcUpdate: Date | undefined;
|
||||||
|
if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
|
||||||
|
valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent);
|
||||||
|
} else if (
|
||||||
|
hasExplicitReminder &&
|
||||||
|
fields.reminderLeadMinutes != null &&
|
||||||
|
fields.allDay &&
|
||||||
|
fields.start
|
||||||
|
) {
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
|
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
|
}
|
||||||
|
|
||||||
// D-06: assemble the final RRULE string, combining the preset or preserved RRULE
|
// D-06: assemble the final RRULE string, combining the preset or preserved RRULE
|
||||||
// with an optional UNTIL/COUNT bound from the payload.
|
// with an optional UNTIL/COUNT bound from the payload.
|
||||||
// Pitfall 3: on series edit with bound change only (no new preset), parse the preserved
|
// Pitfall 3: on series edit with bound change only (no new preset), parse the preserved
|
||||||
@@ -489,6 +527,10 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
location: fields.location,
|
location: fields.location,
|
||||||
description: fields.description,
|
description: fields.description,
|
||||||
rruleString: finalRruleString,
|
rruleString: finalRruleString,
|
||||||
|
// CAL-13/CAL-14: VALARM wiring — absent preserves, null clears, value replaces
|
||||||
|
reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined,
|
||||||
|
valarms: valarmsToPreserve,
|
||||||
|
allDayAlertInstantUtc: allDayAlertInstantUtcUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut);
|
response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut);
|
||||||
@@ -558,6 +600,15 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
preservedRrule,
|
preservedRrule,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CAL-13: VALARM wiring for CREATE branch (no rawVevent source — new event always
|
||||||
|
// carries an explicit picker value or no reminder at all; no preserve path needed).
|
||||||
|
let allDayAlertInstantUtcCreate: Date | undefined;
|
||||||
|
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
|
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
|
}
|
||||||
|
|
||||||
const { icsString } = buildVeventString({
|
const { icsString } = buildVeventString({
|
||||||
uid: row.uid,
|
uid: row.uid,
|
||||||
summary: fields.title,
|
summary: fields.title,
|
||||||
@@ -567,6 +618,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
location: fields.location,
|
location: fields.location,
|
||||||
description: fields.description,
|
description: fields.description,
|
||||||
rruleString: finalRruleString,
|
rruleString: finalRruleString,
|
||||||
|
// CAL-13: per-event reminder — pass through; null=clear, value=set, absent=no VALARM
|
||||||
|
reminderLeadMinutes: fields.reminderLeadMinutes,
|
||||||
|
allDayAlertInstantUtc: allDayAlertInstantUtcCreate,
|
||||||
});
|
});
|
||||||
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
|
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
|
||||||
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1];
|
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1];
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { and, eq, notInArray } from 'drizzle-orm';
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { calendars, calendarEvents } from '../db/schema.js';
|
import { calendars, calendarEvents } from '../db/schema.js';
|
||||||
import type { EventChange } from '../lib/eventChangeDispatcher.js';
|
import type { EventChange } from '../lib/eventChangeDispatcher.js';
|
||||||
|
import { classifyValarms } from './vevent.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
|
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
|
||||||
@@ -128,6 +129,17 @@ export async function syncCalendar(
|
|||||||
const locationValue: string | null =
|
const locationValue: string | null =
|
||||||
(vevent.getFirstPropertyValue('location') as string | null) ?? null;
|
(vevent.getFirstPropertyValue('location') as string | null) ?? null;
|
||||||
|
|
||||||
|
// Phase 11: derive reminderLeadMinutes from the VALARM classification (D-07, NOTIF-05).
|
||||||
|
// classifyValarms wraps ICAL.parse in try/catch (T-11-07 mitigated).
|
||||||
|
// preset/offlist → single relative lead in minutes (scheduler ground truth).
|
||||||
|
// custom (absolute DATE-TIME or multiple VALARMs) → null (can't resolve a single lead).
|
||||||
|
// none → null (no VALARM present).
|
||||||
|
const alarmClass = classifyValarms(obj.data as string);
|
||||||
|
const reminderLeadMinutesValue: number | null =
|
||||||
|
alarmClass.kind === 'preset' || alarmClass.kind === 'offlist'
|
||||||
|
? alarmClass.leadMinutes
|
||||||
|
: null;
|
||||||
|
|
||||||
// NOTIF-03: look up the existing row so we can classify add vs update.
|
// NOTIF-03: look up the existing row so we can classify add vs update.
|
||||||
// One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid.
|
// One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid.
|
||||||
// D-13: this read is from MariaDB cache, not Fastmail.
|
// D-13: this read is from MariaDB cache, not Fastmail.
|
||||||
@@ -154,6 +166,8 @@ export async function syncCalendar(
|
|||||||
dtstartDate: dtstartDateValue,
|
dtstartDate: dtstartDateValue,
|
||||||
allDay,
|
allDay,
|
||||||
hasRrule: isRecurring,
|
hasRrule: isRecurring,
|
||||||
|
// Phase 11: ground-truth VALARM lead for the scheduler (D-07/NOTIF-05, T-11-07)
|
||||||
|
reminderLeadMinutes: reminderLeadMinutesValue,
|
||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({
|
.onDuplicateKeyUpdate({
|
||||||
set: {
|
set: {
|
||||||
@@ -165,6 +179,8 @@ export async function syncCalendar(
|
|||||||
dtstartDate: dtstartDateValue,
|
dtstartDate: dtstartDateValue,
|
||||||
allDay,
|
allDay,
|
||||||
hasRrule: isRecurring,
|
hasRrule: isRecurring,
|
||||||
|
// Phase 11: keep reminderLeadMinutes current on re-sync (native client may change VALARM)
|
||||||
|
reminderLeadMinutes: reminderLeadMinutesValue,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ const eventFieldsSchema = z.object({
|
|||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||||
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
||||||
|
// Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08).
|
||||||
|
// absent — field not present; outbox worker preserves existing VALARM (no-change, D-08)
|
||||||
|
// null — explicit "None" → clear the VALARM on write-back
|
||||||
|
// 0 — same-day all-day reminder (9 AM on event date); timed 0 = None (D-06)
|
||||||
|
// positive int — N minutes before event start (timed) or N/1440 days before (all-day)
|
||||||
|
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** sync-status query params. */
|
/** sync-status query params. */
|
||||||
@@ -171,6 +177,8 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
|||||||
userId: users.id,
|
userId: users.id,
|
||||||
userColor: users.color,
|
userColor: users.color,
|
||||||
ownerName: users.displayName,
|
ownerName: users.displayName,
|
||||||
|
// Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10)
|
||||||
|
reminderLeadMinutes: calendarEvents.reminderLeadMinutes,
|
||||||
})
|
})
|
||||||
.from(calendarEvents)
|
.from(calendarEvents)
|
||||||
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||||
|
|||||||
@@ -350,3 +350,144 @@ describe('expandOccurrences', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Phase 11 Plan 03 Task 3: reminderLeadMinutes on CalendarOccurrence ─────────
|
||||||
|
// D-06/D-10: reminderLeadMinutes is a series-level property — all occurrences of a
|
||||||
|
// recurring master inherit the master's lead. NULL-vs-0-vs-positive must survive
|
||||||
|
// through expansion.
|
||||||
|
|
||||||
|
describe('expandOccurrences — reminderLeadMinutes propagation (Phase 11 Plan 03 Task 3)', () => {
|
||||||
|
// Helper: minimal VCALENDAR/VEVENT string for tests
|
||||||
|
function makeVevent(overrides: {
|
||||||
|
uid?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
reminderMinutes?: number | 'absolute' | 'none';
|
||||||
|
rrule?: string;
|
||||||
|
}): string {
|
||||||
|
const uid = overrides.uid ?? 'test-uid@test';
|
||||||
|
const lines: string[] = ['BEGIN:VCALENDAR', 'VERSION:2.0'];
|
||||||
|
|
||||||
|
if (!overrides.allDay) {
|
||||||
|
lines.push('BEGIN:VEVENT');
|
||||||
|
lines.push(`UID:${uid}`);
|
||||||
|
lines.push('SUMMARY:Test event');
|
||||||
|
lines.push('DTSTART:20260615T140000Z');
|
||||||
|
lines.push('DTEND:20260615T150000Z');
|
||||||
|
} else {
|
||||||
|
lines.push('BEGIN:VEVENT');
|
||||||
|
lines.push(`UID:${uid}`);
|
||||||
|
lines.push('SUMMARY:All-day test');
|
||||||
|
lines.push('DTSTART;VALUE=DATE:20260615');
|
||||||
|
lines.push('DTEND;VALUE=DATE:20260616');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.rrule) {
|
||||||
|
lines.push(`RRULE:${overrides.rrule}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.reminderMinutes === 'absolute') {
|
||||||
|
lines.push('BEGIN:VALARM');
|
||||||
|
lines.push('ACTION:DISPLAY');
|
||||||
|
lines.push('DESCRIPTION:Reminder');
|
||||||
|
lines.push('TRIGGER;VALUE=DATE-TIME:20260615T120000Z');
|
||||||
|
lines.push('END:VALARM');
|
||||||
|
} else if (overrides.reminderMinutes !== 'none' && overrides.reminderMinutes !== undefined) {
|
||||||
|
lines.push('BEGIN:VALARM');
|
||||||
|
lines.push('ACTION:DISPLAY');
|
||||||
|
lines.push('DESCRIPTION:Reminder');
|
||||||
|
lines.push(`TRIGGER:-PT${overrides.reminderMinutes}M`);
|
||||||
|
lines.push('END:VALARM');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VEVENT');
|
||||||
|
lines.push('END:VCALENDAR');
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_START = new Date('2026-06-01T00:00:00Z');
|
||||||
|
const WINDOW_END = new Date('2026-07-01T00:00:00Z');
|
||||||
|
|
||||||
|
// Non-recurring event with reminderLeadMinutes=30 → occurrence carries 30
|
||||||
|
it('non-recurring event: occurrence carries reminderLeadMinutes from master (30 minutes)', () => {
|
||||||
|
const raw = makeVevent({ reminderMinutes: 30 });
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
raw,
|
||||||
|
WINDOW_START,
|
||||||
|
WINDOW_END,
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(occurrences).toHaveLength(1);
|
||||||
|
expect(occurrences[0].reminderLeadMinutes).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NULL-vs-0: master with 0-minute all-day lead → occurrence carries 0, not null
|
||||||
|
it('non-recurring all-day event: occurrence carries reminderLeadMinutes=0 (same-day, D-06 NULL-vs-0)', () => {
|
||||||
|
// Use a 0-minute trigger (same-day all-day)
|
||||||
|
const raw = makeVevent({ allDay: true, reminderMinutes: 0 });
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
raw,
|
||||||
|
new Date('2026-06-01T00:00:00Z'),
|
||||||
|
new Date('2026-07-01T00:00:00Z'),
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(occurrences).toHaveLength(1);
|
||||||
|
// 0-minute all-day trigger → preset 0 → reminderLeadMinutes=0 (not null)
|
||||||
|
expect(occurrences[0].reminderLeadMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NULL: no VALARM in master → occurrence carries null
|
||||||
|
it('non-recurring event with no VALARM: occurrence carries reminderLeadMinutes=null', () => {
|
||||||
|
const raw = makeVevent({ reminderMinutes: 'none' });
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
raw,
|
||||||
|
WINDOW_START,
|
||||||
|
WINDOW_END,
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(occurrences).toHaveLength(1);
|
||||||
|
expect(occurrences[0].reminderLeadMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// D-10 series-level: recurring event with reminderLeadMinutes=60 → all occurrences carry 60
|
||||||
|
it('D-10 series-level: all recurring occurrences inherit the master reminderLeadMinutes=60', () => {
|
||||||
|
const raw = makeVevent({ reminderMinutes: 60, rrule: 'FREQ=WEEKLY;COUNT=3' });
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
raw,
|
||||||
|
WINDOW_START,
|
||||||
|
WINDOW_END,
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(occurrences.length).toBeGreaterThan(0);
|
||||||
|
for (const occ of occurrences) {
|
||||||
|
expect(occ.reminderLeadMinutes).toBe(60);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -864,3 +864,157 @@ describe('scheduleOutboxDrain — trigger wiring (D-09)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Phase 11 Plan 03: reminderLeadMinutes schema + VALARM wiring ───────────────
|
||||||
|
// CAL-13: reminderLeadMinutes round-trips end-to-end through outbox payload →
|
||||||
|
// buildVeventString → emitted ICS.
|
||||||
|
// CAL-14: UPDATE row with no reminderLeadMinutes in payload preserves existing
|
||||||
|
// VALARM verbatim from rawVevent (mirrors WR-01 _preservedRrule pattern).
|
||||||
|
|
||||||
|
describe('runOutboxDrain — reminderLeadMinutes VALARM wiring (CAL-13/CAL-14, Phase 11 Plan 03)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
mockPendingRows = [];
|
||||||
|
wireMockChain();
|
||||||
|
});
|
||||||
|
|
||||||
|
// CAL-14: UPDATE row with NO reminderLeadMinutes field, but rawVevent has a VALARM →
|
||||||
|
// emitted ICS must still contain BEGIN:VALARM (preserve path, mirrors _preservedRrule WR-01).
|
||||||
|
it('CAL-14 preserve: UPDATE with no reminderLeadMinutes field preserves existing VALARM from rawVevent', async () => {
|
||||||
|
const { updateCalendarEvent } = await import('../../src/broker/write.js');
|
||||||
|
let capturedIcsString: unknown = null;
|
||||||
|
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => {
|
||||||
|
capturedIcsString = icsString;
|
||||||
|
return makeResponse(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// rawVevent that already has a VALARM (TRIGGER:-PT30M)
|
||||||
|
const rawVeventWithValarm = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:test-uid@familysync',
|
||||||
|
'SUMMARY:Team meeting',
|
||||||
|
'DTSTART:20260610T120000Z',
|
||||||
|
'DTEND:20260610T130000Z',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Reminder',
|
||||||
|
'TRIGGER:-PT30M',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
// Payload has NO reminderLeadMinutes key (absent = no-change, D-08)
|
||||||
|
const updatePayload = JSON.stringify({
|
||||||
|
title: 'Team meeting',
|
||||||
|
allDay: false,
|
||||||
|
start: '2026-06-10T12:00:00.000Z',
|
||||||
|
end: '2026-06-10T13:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPendingRows = [
|
||||||
|
makeRow({
|
||||||
|
operation: 'update',
|
||||||
|
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||||
|
payload: updatePayload,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simulate freshEtagRows returning rawVevent that has a VALARM
|
||||||
|
mockWhereCalEvents.mockResolvedValue([{ etag: '"fresh"', rawVevent: rawVeventWithValarm }]);
|
||||||
|
|
||||||
|
await runOutboxDrain();
|
||||||
|
|
||||||
|
expect(typeof capturedIcsString).toBe('string');
|
||||||
|
// The emitted ICS must contain the preserved VALARM
|
||||||
|
expect(capturedIcsString as string).toContain('BEGIN:VALARM');
|
||||||
|
expect(capturedIcsString as string).toContain('TRIGGER:-PT30M');
|
||||||
|
});
|
||||||
|
|
||||||
|
// CAL-13: CREATE row with reminderLeadMinutes=15 → emitted ICS contains TRIGGER:-PT15M
|
||||||
|
it('CAL-13 timed: CREATE row with reminderLeadMinutes=15 emits TRIGGER:-PT15M', async () => {
|
||||||
|
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||||
|
let capturedIcsString: unknown = null;
|
||||||
|
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||||
|
capturedIcsString = icsString;
|
||||||
|
return makeResponse(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Doctor appointment',
|
||||||
|
allDay: false,
|
||||||
|
start: '2026-06-15T14:00:00.000Z',
|
||||||
|
end: '2026-06-15T15:00:00.000Z',
|
||||||
|
reminderLeadMinutes: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPendingRows = [makeRow({ payload })];
|
||||||
|
|
||||||
|
await runOutboxDrain();
|
||||||
|
|
||||||
|
expect(typeof capturedIcsString).toBe('string');
|
||||||
|
expect(capturedIcsString as string).toContain('BEGIN:VALARM');
|
||||||
|
expect(capturedIcsString as string).toContain('TRIGGER:-PT15M');
|
||||||
|
});
|
||||||
|
|
||||||
|
// CAL-13 clear: UPDATE row with reminderLeadMinutes=null → emitted ICS has no VALARM
|
||||||
|
it('CAL-13 clear: UPDATE row with reminderLeadMinutes=null emits no VALARM (explicit clear)', async () => {
|
||||||
|
const { updateCalendarEvent } = await import('../../src/broker/write.js');
|
||||||
|
let capturedIcsString: unknown = null;
|
||||||
|
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => {
|
||||||
|
capturedIcsString = icsString;
|
||||||
|
return makeResponse(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePayload = JSON.stringify({
|
||||||
|
title: 'No reminder event',
|
||||||
|
allDay: false,
|
||||||
|
start: '2026-06-15T14:00:00.000Z',
|
||||||
|
end: '2026-06-15T15:00:00.000Z',
|
||||||
|
reminderLeadMinutes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPendingRows = [
|
||||||
|
makeRow({
|
||||||
|
operation: 'update',
|
||||||
|
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||||
|
payload: updatePayload,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await runOutboxDrain();
|
||||||
|
|
||||||
|
expect(typeof capturedIcsString).toBe('string');
|
||||||
|
expect(capturedIcsString as string).not.toContain('BEGIN:VALARM');
|
||||||
|
});
|
||||||
|
|
||||||
|
// CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 →
|
||||||
|
// emitted ICS contains VALUE=DATE-TIME absolute trigger (not DURATION trigger).
|
||||||
|
it('CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 emits VALUE=DATE-TIME trigger', async () => {
|
||||||
|
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||||
|
let capturedIcsString: unknown = null;
|
||||||
|
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||||
|
capturedIcsString = icsString;
|
||||||
|
return makeResponse(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Birthday party',
|
||||||
|
allDay: true,
|
||||||
|
start: '2026-06-20',
|
||||||
|
end: '2026-06-20',
|
||||||
|
reminderLeadMinutes: 1440, // 1 day before = leadDays = 1440/1440 = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPendingRows = [makeRow({ payload })];
|
||||||
|
|
||||||
|
await runOutboxDrain();
|
||||||
|
|
||||||
|
expect(typeof capturedIcsString).toBe('string');
|
||||||
|
expect(capturedIcsString as string).toContain('BEGIN:VALARM');
|
||||||
|
// Must use VALUE=DATE-TIME absolute trigger for all-day (not DURATION)
|
||||||
|
expect(capturedIcsString as string).toContain('VALUE=DATE-TIME');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -429,3 +429,207 @@ describe('syncCalendar', () => {
|
|||||||
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' });
|
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Phase 11 Plan 03 Task 2: reminderLeadMinutes derived from VALARM on sync ──
|
||||||
|
// CAL-13: sync.ts must derive reminderLeadMinutes from the native VALARM and write it
|
||||||
|
// to the DB so the scheduler has ground truth for native-client alarms (D-07/NOTIF-05).
|
||||||
|
|
||||||
|
describe('syncCalendar — reminderLeadMinutes from VALARM (Phase 11 Plan 03 Task 2)', () => {
|
||||||
|
const MOCK_DAV_CAL = {
|
||||||
|
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||||
|
displayName: 'Test Calendar',
|
||||||
|
ctag: 'ctag-v1',
|
||||||
|
syncToken: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
|
mockLimit.mockResolvedValue([{ id: 42 }]);
|
||||||
|
mockWhere.mockReturnValue({ limit: mockLimit });
|
||||||
|
mockFrom.mockReturnValue({ where: mockWhere });
|
||||||
|
mockSelect.mockReturnValue({ from: mockFrom });
|
||||||
|
mockDeleteWhere.mockResolvedValue([]);
|
||||||
|
mockDelete.mockReturnValue({ where: mockDeleteWhere });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A single preset TRIGGER:-PT30M → reminderLeadMinutes=30
|
||||||
|
it('writes reminderLeadMinutes=30 when VCALENDAR has a single TRIGGER:-PT30M VALARM', async () => {
|
||||||
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||||
|
|
||||||
|
const rawVeventWithValarm = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:uid-with-valarm@test',
|
||||||
|
'SUMMARY:Meeting with reminder',
|
||||||
|
'DTSTART:20260615T140000Z',
|
||||||
|
'DTEND:20260615T150000Z',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Reminder',
|
||||||
|
'TRIGGER:-PT30M',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||||
|
{ data: rawVeventWithValarm, etag: '"etag-valarm"', url: '/cal/valarm.ics' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
||||||
|
|
||||||
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||||
|
expect(eventValuesArg.reminderLeadMinutes).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// No VALARM → reminderLeadMinutes=null
|
||||||
|
it('writes reminderLeadMinutes=null when VCALENDAR has no VALARM', async () => {
|
||||||
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||||
|
|
||||||
|
const rawVeventNoValarm = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:uid-no-valarm@test',
|
||||||
|
'SUMMARY:Event without reminder',
|
||||||
|
'DTSTART:20260615T140000Z',
|
||||||
|
'DTEND:20260615T150000Z',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||||
|
{ data: rawVeventNoValarm, etag: '"etag-no-valarm"', url: '/cal/no-valarm.ics' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
||||||
|
|
||||||
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||||
|
expect(eventValuesArg.reminderLeadMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Absolute DATE-TIME trigger → reminderLeadMinutes=null (custom kind, D-07/NOTIF-05)
|
||||||
|
it('writes reminderLeadMinutes=null when VALARM has absolute DATE-TIME trigger (custom → null)', async () => {
|
||||||
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||||
|
|
||||||
|
const rawVeventAbsoluteValarm = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:uid-absolute-valarm@test',
|
||||||
|
'SUMMARY:Event with absolute VALARM',
|
||||||
|
'DTSTART:20260615T140000Z',
|
||||||
|
'DTEND:20260615T150000Z',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Reminder',
|
||||||
|
'TRIGGER;VALUE=DATE-TIME:20260615T120000Z',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
data: rawVeventAbsoluteValarm,
|
||||||
|
etag: '"etag-abs"',
|
||||||
|
url: '/cal/abs.ics',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
||||||
|
|
||||||
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||||
|
// Absolute DATE-TIME trigger → classifyValarms returns 'custom' → null
|
||||||
|
expect(eventValuesArg.reminderLeadMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two VALARMs → reminderLeadMinutes=null (custom, multiple alarms not resolvable to one lead)
|
||||||
|
it('writes reminderLeadMinutes=null when VCALENDAR has two VALARMs (multiple → custom → null)', async () => {
|
||||||
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||||
|
|
||||||
|
const rawVeventTwoValarms = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:uid-two-valarms@test',
|
||||||
|
'SUMMARY:Event with two alarms',
|
||||||
|
'DTSTART:20260615T140000Z',
|
||||||
|
'DTEND:20260615T150000Z',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:First Reminder',
|
||||||
|
'TRIGGER:-PT30M',
|
||||||
|
'END:VALARM',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Second Reminder',
|
||||||
|
'TRIGGER:-PT15M',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
data: rawVeventTwoValarms,
|
||||||
|
etag: '"etag-two"',
|
||||||
|
url: '/cal/two.ics',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
||||||
|
|
||||||
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||||
|
// Multiple VALARMs → classifyValarms returns 'custom' → null
|
||||||
|
expect(eventValuesArg.reminderLeadMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the onDuplicateKeyUpdate ALSO sets reminderLeadMinutes (upsert column must be current)
|
||||||
|
it('sets reminderLeadMinutes in onDuplicateKeyUpdate set (re-sync keeps column current)', async () => {
|
||||||
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||||
|
|
||||||
|
const rawVeventWithValarm = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:uid-upsert@test',
|
||||||
|
'SUMMARY:Recurring meeting',
|
||||||
|
'DTSTART:20260615T100000Z',
|
||||||
|
'DTEND:20260615T110000Z',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Reminder',
|
||||||
|
'TRIGGER:-PT15M',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||||
|
{ data: rawVeventWithValarm, etag: '"etag-upsert"', url: '/cal/upsert.ics' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
||||||
|
|
||||||
|
// The onDuplicateKeyUpdate `set` object must also contain reminderLeadMinutes
|
||||||
|
const upsertSetArg = mockOnDuplicateKeyUpdate.mock.calls[1]?.[0] as {
|
||||||
|
set?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(upsertSetArg?.set).toHaveProperty('reminderLeadMinutes', 15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user