merge(phase-11): wave 2 plan 11-03 backend plumbing
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import ICAL from 'ical.js';
|
||||
import { classifyValarms } from './vevent.js';
|
||||
|
||||
/**
|
||||
* A concrete calendar event occurrence ready for UI consumption.
|
||||
@@ -33,6 +34,11 @@ import ICAL from 'ical.js';
|
||||
* Color routing (D-06, CAL-02):
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
|
||||
@@ -66,6 +72,14 @@ export interface CalendarOccurrence {
|
||||
description: string | null;
|
||||
/** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
|
||||
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.
|
||||
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 ---
|
||||
if (!isRecurring) {
|
||||
if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
|
||||
@@ -258,6 +282,7 @@ export function expandOccurrences(
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
hasRrule: isRecurring, // always false in the non-recurring branch
|
||||
reminderLeadMinutes, // series-level (D-10)
|
||||
});
|
||||
}
|
||||
return occurrences;
|
||||
@@ -305,6 +330,7 @@ export function expandOccurrences(
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
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)
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import ICAL from 'ical.js';
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { db } from '../db/client.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 { syncCalendar } from './sync.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 { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
||||
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
||||
@@ -91,6 +98,12 @@ const outboxPayloadSchema = z
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
let preservedRrule: string | undefined;
|
||||
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 =
|
||||
fields.recurrence && fields.recurrence !== 'none'
|
||||
? RRULE_PRESETS[fields.recurrence as string]
|
||||
@@ -465,6 +483,26 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
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
|
||||
// with an optional UNTIL/COUNT bound from the payload.
|
||||
// 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,
|
||||
description: fields.description,
|
||||
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);
|
||||
@@ -558,6 +600,15 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
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({
|
||||
uid: row.uid,
|
||||
summary: fields.title,
|
||||
@@ -567,6 +618,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
location: fields.location,
|
||||
description: fields.description,
|
||||
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)
|
||||
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 { calendars, calendarEvents } from '../db/schema.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,
|
||||
@@ -128,6 +129,17 @@ export async function syncCalendar(
|
||||
const locationValue: string | 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.
|
||||
// One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid.
|
||||
// D-13: this read is from MariaDB cache, not Fastmail.
|
||||
@@ -154,6 +166,8 @@ export async function syncCalendar(
|
||||
dtstartDate: dtstartDateValue,
|
||||
allDay,
|
||||
hasRrule: isRecurring,
|
||||
// Phase 11: ground-truth VALARM lead for the scheduler (D-07/NOTIF-05, T-11-07)
|
||||
reminderLeadMinutes: reminderLeadMinutesValue,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
@@ -165,6 +179,8 @@ export async function syncCalendar(
|
||||
dtstartDate: dtstartDateValue,
|
||||
allDay,
|
||||
hasRrule: isRecurring,
|
||||
// Phase 11: keep reminderLeadMinutes current on re-sync (native client may change VALARM)
|
||||
reminderLeadMinutes: reminderLeadMinutesValue,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,6 +117,12 @@ const eventFieldsSchema = z.object({
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||
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. */
|
||||
@@ -171,6 +177,8 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
userId: users.id,
|
||||
userColor: users.color,
|
||||
ownerName: users.displayName,
|
||||
// Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10)
|
||||
reminderLeadMinutes: calendarEvents.reminderLeadMinutes,
|
||||
})
|
||||
.from(calendarEvents)
|
||||
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||
|
||||
Reference in New Issue
Block a user