feat(11-03): reminderLeadMinutes schema + VALARM wiring in outbox worker (CAL-13/CAL-14)
- Add reminderLeadMinutes to eventFieldsSchema (ingress validation, T-11-06) - Add reminderLeadMinutes to outboxPayloadSchema (drain re-validation, IN-03 defense-in-depth) - Import extractValarms + computeAlertInstantUtc from vevent.ts - UPDATE branch: hasExplicitReminder gate mirrors hasExplicitRecurrence (WR-01 pattern) - absent field → extractValarms(rawVevent) preserved verbatim (CAL-14, D-08) - explicit null → clear VALARM (no valarmsToPreserve, null passed to buildVeventString) - explicit value + allDay → computeAlertInstantUtc at 9 AM local (D-04) - explicit value + timed → passed through to buildTimedValarm via buildVeventString - CREATE branch: always explicit picker value; compute allDayAlertInstantUtc when allDay
This commit is contained in:
@@ -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];
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user