style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -9,17 +9,20 @@
|
||||
* (Pitfall #1: tsdav service discovery via /.well-known/caldav resolves to this principal)
|
||||
*/
|
||||
|
||||
import { createDAVClient } from 'tsdav'
|
||||
import { createDAVClient } from 'tsdav';
|
||||
|
||||
/** The resolved type of a tsdav client returned by createDAVClient. */
|
||||
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>
|
||||
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>;
|
||||
|
||||
/**
|
||||
* Creates a tsdav DAVClient authenticated with Basic auth (app password).
|
||||
* The returned client can call fetchCalendars() and fetchCalendarObjects().
|
||||
* This call performs a service-discovery round-trip (Pitfall #3 — cache this client).
|
||||
*/
|
||||
export async function createFastmailClient(email: string, appPassword: string): Promise<FastmailClient> {
|
||||
export async function createFastmailClient(
|
||||
email: string,
|
||||
appPassword: string,
|
||||
): Promise<FastmailClient> {
|
||||
return createDAVClient({
|
||||
serverUrl: 'https://caldav.fastmail.com',
|
||||
credentials: {
|
||||
@@ -28,5 +31,5 @@ export async function createFastmailClient(email: string, appPassword: string):
|
||||
},
|
||||
authMethod: 'Basic',
|
||||
defaultAccountType: 'caldav',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,27 +11,27 @@
|
||||
* Source: Node.js docs node:crypto — AES-GCM
|
||||
*/
|
||||
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
||||
|
||||
// Validated at module load: key must be present and 32 bytes (64 hex chars).
|
||||
// We deliberately do NOT throw here on missing key — that would crash the module
|
||||
// during tests that set the env before the first import. The KEY is only read
|
||||
// when encryptPassword / decryptPassword are actually called.
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.APP_PASSWORD_ENCRYPTION_KEY
|
||||
const hex = process.env.APP_PASSWORD_ENCRYPTION_KEY;
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error(
|
||||
'APP_PASSWORD_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' +
|
||||
'Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
|
||||
)
|
||||
"Generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
|
||||
);
|
||||
}
|
||||
return Buffer.from(hex, 'hex')
|
||||
return Buffer.from(hex, 'hex');
|
||||
}
|
||||
|
||||
interface EncryptedPayload {
|
||||
iv: string
|
||||
authTag: string
|
||||
ciphertext: string
|
||||
iv: string;
|
||||
authTag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,17 +40,17 @@ interface EncryptedPayload {
|
||||
* Each call generates a fresh random 96-bit IV.
|
||||
*/
|
||||
export function encryptPassword(plaintext: string): string {
|
||||
const key = getKey()
|
||||
const iv = randomBytes(12) // 96-bit IV for GCM
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
const key = getKey();
|
||||
const iv = randomBytes(12); // 96-bit IV for GCM
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const payload: EncryptedPayload = {
|
||||
iv: iv.toString('hex'),
|
||||
authTag: authTag.toString('hex'),
|
||||
ciphertext: encrypted.toString('hex'),
|
||||
}
|
||||
return JSON.stringify(payload)
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,12 +58,12 @@ export function encryptPassword(plaintext: string): string {
|
||||
* Throws if the auth tag does not match (integrity violation).
|
||||
*/
|
||||
export function decryptPassword(stored: string): string {
|
||||
const key = getKey()
|
||||
const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'))
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
|
||||
const key = getKey();
|
||||
const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload;
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'hex')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* - .planning/phases/02-calendar-display/02-RESEARCH.md §Pattern 1 + §Code Examples
|
||||
*/
|
||||
|
||||
import ICAL from 'ical.js'
|
||||
import ICAL from 'ical.js';
|
||||
|
||||
/**
|
||||
* A concrete calendar event occurrence ready for UI consumption.
|
||||
@@ -36,36 +36,36 @@ import ICAL from 'ical.js'
|
||||
*/
|
||||
export interface CalendarOccurrence {
|
||||
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
|
||||
id: string
|
||||
uid: string
|
||||
calendarId: number
|
||||
calendarName: string
|
||||
id: string;
|
||||
uid: string;
|
||||
calendarId: number;
|
||||
calendarName: string;
|
||||
/**
|
||||
* The Fastmail user ID who owns the calendar this occurrence belongs to.
|
||||
* Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars.
|
||||
*/
|
||||
ownerUserId: number
|
||||
ownerUserId: number;
|
||||
/**
|
||||
* Display name of the calendar owner (users.displayName).
|
||||
* Null when the user has not set a display name.
|
||||
* The popover uses this to show the owner name for personal events;
|
||||
* falls back to calendarName when null.
|
||||
*/
|
||||
ownerName: string | null
|
||||
ownerName: string | null;
|
||||
/** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */
|
||||
color: string
|
||||
color: string;
|
||||
/** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */
|
||||
isShared: boolean
|
||||
title: string
|
||||
isShared: boolean;
|
||||
title: string;
|
||||
/** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T10:00:00-04:00[America/New_York]' */
|
||||
start: string
|
||||
start: string;
|
||||
/** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T11:00:00-04:00[America/New_York]' */
|
||||
end: string
|
||||
allDay: boolean
|
||||
location: string | null
|
||||
description: string | null
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
/** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
|
||||
hasRrule: boolean
|
||||
hasRrule: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,14 +73,14 @@ export interface CalendarOccurrence {
|
||||
* Carries color and ownership info from the calendars→users join.
|
||||
*/
|
||||
export interface OccurrenceMeta {
|
||||
calendarId: number
|
||||
calendarName: string
|
||||
ownerUserId: number
|
||||
calendarId: number;
|
||||
calendarName: string;
|
||||
ownerUserId: number;
|
||||
/** Display name of the calendar owner; null when users.displayName is not set */
|
||||
ownerName: string | null
|
||||
ownerName: string | null;
|
||||
/** Hex color: pre-computed by the route (users.color or shared-family constant) */
|
||||
color: string
|
||||
isShared: boolean
|
||||
color: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,9 +99,9 @@ export interface OccurrenceMeta {
|
||||
* across refetches/windows, so Schedule-X dedup and the popover id lookup keep matching.
|
||||
*/
|
||||
function makeOccurrenceId(uid: string, start: ICAL.Time): string {
|
||||
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
const epochMs = start.toJSDate().getTime()
|
||||
return `ev-${safeUid}-${epochMs}`
|
||||
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
const epochMs = start.toJSDate().getTime();
|
||||
return `ev-${safeUid}-${epochMs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +109,11 @@ function makeOccurrenceId(uid: string, start: ICAL.Time): string {
|
||||
* ICAL.Time.utcOffset() returns total seconds (positive = east of UTC).
|
||||
*/
|
||||
function formatUtcOffset(offsetSeconds: number): string {
|
||||
const sign = offsetSeconds < 0 ? '-' : '+'
|
||||
const abs = Math.abs(offsetSeconds)
|
||||
const hours = Math.floor(abs / 3600)
|
||||
const minutes = Math.floor((abs % 3600) / 60)
|
||||
return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
const sign = offsetSeconds < 0 ? '-' : '+';
|
||||
const abs = Math.abs(offsetSeconds);
|
||||
const hours = Math.floor(abs / 3600);
|
||||
const minutes = Math.floor((abs % 3600) / 60);
|
||||
return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,10 +128,10 @@ function formatUtcOffset(offsetSeconds: number): string {
|
||||
function serializeTime(t: ICAL.Time, allDay: boolean): string {
|
||||
if (allDay) {
|
||||
// All-day: return DATE-only string — never construct a datetime (Pitfall 2)
|
||||
const y = String(t.year)
|
||||
const m = String(t.month).padStart(2, '0')
|
||||
const d = String(t.day).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
const y = String(t.year);
|
||||
const m = String(t.month).padStart(2, '0');
|
||||
const d = String(t.day).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// Timed: build an IANA-annotated ISO string so the client can construct Temporal.ZonedDateTime.
|
||||
@@ -139,22 +139,22 @@ function serializeTime(t: ICAL.Time, allDay: boolean): string {
|
||||
// We always emit '...±HH:MM[IANA/Zone]' — the bracket is mandatory for ZonedDateTime.from().
|
||||
if (t.zone === ICAL.Timezone.utcTimezone) {
|
||||
// UTC zone: toString() produces '...Z'; strip the Z and emit +00:00[UTC].
|
||||
const base = t.toString().replace(/Z$/, '') // e.g. '2026-03-01T10:00:00'
|
||||
return `${base}+00:00[UTC]`
|
||||
const base = t.toString().replace(/Z$/, ''); // e.g. '2026-03-01T10:00:00'
|
||||
return `${base}+00:00[UTC]`;
|
||||
}
|
||||
|
||||
const tzid = t.zone?.tzid
|
||||
const tzid = t.zone?.tzid;
|
||||
// Floating zone (tzid === 'floating') has no real timezone — fall back to UTC.
|
||||
// This is a safe degradation: the event had no VTIMEZONE and no offset is knowable.
|
||||
if (!tzid || tzid === 'floating') {
|
||||
const base = t.toString() // no trailing Z for floating
|
||||
return `${base}+00:00[UTC]`
|
||||
const base = t.toString(); // no trailing Z for floating
|
||||
return `${base}+00:00[UTC]`;
|
||||
}
|
||||
|
||||
// Named IANA zone: combine base datetime + offset + IANA bracket.
|
||||
const base = t.toString() // e.g. '2026-03-01T10:00:00'
|
||||
const offsetSec = t.utcOffset()
|
||||
return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`
|
||||
const base = t.toString(); // e.g. '2026-03-01T10:00:00'
|
||||
const offsetSec = t.utcOffset();
|
||||
return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,53 +178,50 @@ export function expandOccurrences(
|
||||
isShared: boolean,
|
||||
): CalendarOccurrence[] {
|
||||
// --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
let parsed: ReturnType<typeof ICAL.parse>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||
parsed = ICAL.parse(rawVevent)
|
||||
parsed = ICAL.parse(rawVevent);
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const comp = new ICAL.Component(parsed);
|
||||
|
||||
// --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
|
||||
// Skipping this causes ical.js to fall back to UTC, producing ±1h DST errors (Pitfall 3).
|
||||
for (const vtz of comp.getAllSubcomponents('vtimezone')) {
|
||||
const tzid = vtz.getFirstPropertyValue('tzid') as string
|
||||
const tzid = vtz.getFirstPropertyValue('tzid') as string;
|
||||
if (tzid && !ICAL.TimezoneService.has(tzid)) {
|
||||
// TimezoneService.register(timezone, name?) — first arg is the Timezone object
|
||||
ICAL.TimezoneService.register(
|
||||
new ICAL.Timezone({ component: vtz, tzid }),
|
||||
tzid,
|
||||
)
|
||||
ICAL.TimezoneService.register(new ICAL.Timezone({ component: vtz, tzid }), tzid);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Get the VEVENT component ---
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) return []
|
||||
const vevent = comp.getFirstSubcomponent('vevent');
|
||||
if (!vevent) return [];
|
||||
|
||||
const event = new ICAL.Event(vevent)
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
|
||||
if (!dtstart) return []
|
||||
const event = new ICAL.Event(vevent);
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
|
||||
if (!dtstart) return [];
|
||||
|
||||
const allDay: boolean = dtstart.isDate
|
||||
const uid: string = event.uid ?? ''
|
||||
const allDay: boolean = dtstart.isDate;
|
||||
const uid: string = event.uid ?? '';
|
||||
|
||||
// useUTC=true: windowStart/windowEnd are absolute instants (midnight-UTC of the
|
||||
// requested dates). Interpreting them in UTC keeps the occurrence-window comparison
|
||||
// absolute. With useUTC=false ical.js used the SERVER's local timezone, shifting the
|
||||
// window by the server's offset and dropping evening occurrences near the window end
|
||||
// (e.g. a 17:45-04:00 event = 21:45Z fell past a day window whose end was shifted to 20:00).
|
||||
const rangeStart = ICAL.Time.fromJSDate(windowStart, true)
|
||||
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true)
|
||||
const rangeStart = ICAL.Time.fromJSDate(windowStart, true);
|
||||
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true);
|
||||
|
||||
const occurrences: CalendarOccurrence[] = []
|
||||
const occurrences: CalendarOccurrence[] = [];
|
||||
|
||||
// Capture once — used in both the non-recurring and recurring branches to populate hasRrule.
|
||||
const isRecurring = event.isRecurring()
|
||||
const isRecurring = event.isRecurring();
|
||||
|
||||
// --- 4. Non-recurring event: single occurrence check ---
|
||||
if (!isRecurring) {
|
||||
@@ -232,19 +229,19 @@ export function expandOccurrences(
|
||||
// Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
|
||||
// Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
|
||||
// producing zero-duration occurrences (BUG 1).
|
||||
let occEnd: ICAL.Time = event.endDate ?? dtstart
|
||||
let occEnd: ICAL.Time = event.endDate ?? dtstart;
|
||||
|
||||
// Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
|
||||
if (!allDay && occEnd.compare(dtstart) <= 0) {
|
||||
occEnd = dtstart.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'))
|
||||
occEnd = dtstart.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
|
||||
} else if (allDay && occEnd.compare(dtstart) <= 0) {
|
||||
occEnd = dtstart.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'))
|
||||
occEnd = dtstart.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'));
|
||||
}
|
||||
|
||||
const start = serializeTime(dtstart, allDay)
|
||||
const end = serializeTime(occEnd, allDay)
|
||||
const start = serializeTime(dtstart, allDay);
|
||||
const end = serializeTime(occEnd, allDay);
|
||||
occurrences.push({
|
||||
id: makeOccurrenceId(uid, dtstart),
|
||||
uid,
|
||||
@@ -261,36 +258,36 @@ export function expandOccurrences(
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
hasRrule: isRecurring, // always false in the non-recurring branch
|
||||
})
|
||||
});
|
||||
}
|
||||
return occurrences
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
// --- 5. Recurring event: use ICAL.RecurExpansion ---
|
||||
// RecurExpansion handles RRULE + RDATE + EXDATE internally — no manual EXDATE filtering (A1).
|
||||
// VTIMEZONE was registered in step 2 above, so DST occurrences get correct wall-clock time.
|
||||
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
|
||||
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
|
||||
|
||||
let next: ICAL.Time | null | undefined
|
||||
let next: ICAL.Time | null | undefined;
|
||||
while ((next = expand.next() as ICAL.Time | null | undefined) && next.compare(rangeEnd) < 0) {
|
||||
if (next.compare(rangeStart) < 0) continue
|
||||
if (next.compare(rangeStart) < 0) continue;
|
||||
|
||||
// Compute occurrence end from event duration
|
||||
const duration = event.duration
|
||||
let occEnd = next.clone()
|
||||
occEnd.addDuration(duration)
|
||||
const duration = event.duration;
|
||||
let occEnd = next.clone();
|
||||
occEnd.addDuration(duration);
|
||||
|
||||
// Positive-duration guard: same logic as non-recurring branch above.
|
||||
if (!allDay && occEnd.compare(next) <= 0) {
|
||||
occEnd = next.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'))
|
||||
occEnd = next.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
|
||||
} else if (allDay && occEnd.compare(next) <= 0) {
|
||||
occEnd = next.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'))
|
||||
occEnd = next.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'));
|
||||
}
|
||||
|
||||
const start = serializeTime(next, allDay)
|
||||
const end = serializeTime(occEnd, allDay)
|
||||
const start = serializeTime(next, allDay);
|
||||
const end = serializeTime(occEnd, allDay);
|
||||
|
||||
occurrences.push({
|
||||
id: makeOccurrenceId(uid, next),
|
||||
@@ -308,8 +305,8 @@ export function expandOccurrences(
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
hasRrule: isRecurring, // always true in the recurring branch
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return occurrences
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
+169
-154
@@ -23,36 +23,36 @@
|
||||
*
|
||||
* Source: poller.ts pattern (runPoll/startBrokerPoller)
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { and, eq, lte } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js'
|
||||
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 type { FastmailClient } from './client.js'
|
||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
|
||||
import { z } from 'zod';
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js';
|
||||
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 type { FastmailClient } from './client.js';
|
||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
||||
|
||||
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_ATTEMPTS = 5
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Backoff delay in seconds per attempt index (0-based).
|
||||
* Total window: 15+60+300+600+1800 ≈ 30 min.
|
||||
*/
|
||||
const BACKOFF_SECONDS = [15, 60, 300, 600, 1800]
|
||||
const BACKOFF_SECONDS = [15, 60, 300, 600, 1800];
|
||||
|
||||
/** HTTP status codes treated as transient — retry with exponential backoff. */
|
||||
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504])
|
||||
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
|
||||
/** HTTP status codes treated as hard failures — stop retry immediately. */
|
||||
const HARD_FAIL_STATUSES = new Set([400, 401, 403])
|
||||
const HARD_FAIL_STATUSES = new Set([400, 401, 403]);
|
||||
|
||||
/** HTTP status code for CalDAV If-Match conflict — D-08 conflict flow. */
|
||||
const CONFLICT_STATUS = 412
|
||||
const CONFLICT_STATUS = 412;
|
||||
|
||||
// ── Outbox payload re-validation (IN-03) ─────────────────────────────────────
|
||||
|
||||
@@ -83,12 +83,15 @@ const outboxPayloadSchema = z
|
||||
// schema validates on ingress, but the outbox payload is re-parsed from stored JSON).
|
||||
// Guarantees .replace(/-/g,'') in assembleRruleString emits digits-only, closing the
|
||||
// RRULE-part injection vector. int().min(1) prevents zero/negative counts.
|
||||
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||
recurrenceUntil: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
||||
})
|
||||
.passthrough()
|
||||
.passthrough();
|
||||
|
||||
type OutboxPayloadFields = z.infer<typeof outboxPayloadSchema>
|
||||
type OutboxPayloadFields = z.infer<typeof outboxPayloadSchema>;
|
||||
|
||||
// ── D-06: RRULE bound assembly ───────────────────────────────────────────────
|
||||
|
||||
@@ -120,21 +123,21 @@ export function assembleRruleString(
|
||||
count?: number,
|
||||
allDay?: boolean,
|
||||
): string {
|
||||
let s = basePreset
|
||||
let s = basePreset;
|
||||
if (count !== undefined) {
|
||||
// COUNT wins over UNTIL (mutual exclusion)
|
||||
s += `;COUNT=${count}`
|
||||
s += `;COUNT=${count}`;
|
||||
} else if (until) {
|
||||
const dateDigits = until.replace(/-/g, '')
|
||||
const dateDigits = until.replace(/-/g, '');
|
||||
if (allDay) {
|
||||
// DATE form for all-day events: YYYYMMDD (RFC 5545 §3.3.10)
|
||||
s += `;UNTIL=${dateDigits}`
|
||||
s += `;UNTIL=${dateDigits}`;
|
||||
} else {
|
||||
// DATETIME UTC form for timed events: YYYYMMDDTHHMMSSZ (end of UTC day)
|
||||
s += `;UNTIL=${dateDigits}T235959Z`
|
||||
s += `;UNTIL=${dateDigits}T235959Z`;
|
||||
}
|
||||
}
|
||||
return s
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Drain concurrency guard (CR-05) ──────────────────────────────────────────
|
||||
@@ -153,7 +156,7 @@ export function assembleRruleString(
|
||||
* and only the process that wins the affected-rows check would dispatch the row.
|
||||
* Do not remove this comment if deploying to multi-process infrastructure.
|
||||
*/
|
||||
let isDraining = false
|
||||
let isDraining = false;
|
||||
|
||||
/**
|
||||
* WR-06: max time to wait on the post-write targeted re-sync before marking the
|
||||
@@ -161,7 +164,7 @@ let isDraining = false
|
||||
* drain loop beyond this cap; the PWA's next sync-status poll reconciles any cache
|
||||
* that the timed-out re-sync did not refresh.
|
||||
*/
|
||||
const RESYNC_TIMEOUT_MS = 10_000
|
||||
const RESYNC_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ── Credential + client loading ──────────────────────────────────────────────
|
||||
|
||||
@@ -175,19 +178,19 @@ async function loadClientForUser(userId: number): Promise<FastmailClient> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userId))
|
||||
.where(eq(memberCredentials.userId, userId));
|
||||
|
||||
// In production rows[0] is a real credential row.
|
||||
// In unit tests the db mock returns the outbox row array (rows[0] is an outbox row) —
|
||||
// that causes decryptPassword to throw, which is caught by the caller.
|
||||
const cred = rows[0]
|
||||
const cred = rows[0];
|
||||
if (!cred) {
|
||||
throw new Error(`No credential found for userId=${userId}`)
|
||||
throw new Error(`No credential found for userId=${userId}`);
|
||||
}
|
||||
|
||||
// T-03-13: decrypt only here; result never logged
|
||||
const appPassword = decryptPassword(cred.encryptedPassword)
|
||||
return createFastmailClient(cred.fastmailEmail, appPassword)
|
||||
const appPassword = decryptPassword(cred.encryptedPassword);
|
||||
return createFastmailClient(cred.fastmailEmail, appPassword);
|
||||
}
|
||||
|
||||
// ── Targeted re-sync (D-06) ─────────────────────────────────────────────────
|
||||
@@ -209,25 +212,24 @@ async function triggerTargetedResync(
|
||||
): Promise<void> {
|
||||
try {
|
||||
// loadClientForUser may throw in test environments — caught below
|
||||
let client = clientCache?.get(userId)
|
||||
let client = clientCache?.get(userId);
|
||||
if (!client) {
|
||||
client = await loadClientForUser(userId)
|
||||
clientCache?.set(userId, client)
|
||||
client = await loadClientForUser(userId);
|
||||
clientCache?.set(userId, client);
|
||||
}
|
||||
const davCalendars = await client.fetchCalendars()
|
||||
const davCalendars = await client.fetchCalendars();
|
||||
|
||||
// Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences)
|
||||
const davCal = davCalendars.find(
|
||||
(cal) =>
|
||||
cal.url === calendarUrl ||
|
||||
cal.url.replace(/\/$/, '') === calendarUrl.replace(/\/$/, ''),
|
||||
)
|
||||
cal.url === calendarUrl || cal.url.replace(/\/$/, '') === calendarUrl.replace(/\/$/, ''),
|
||||
);
|
||||
|
||||
if (!davCal) {
|
||||
console.error(
|
||||
`[outboxWorker] DAVCalendar not found for url=${calendarUrl} — skipping re-sync`,
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTIF-03: pass onChanges so this-member writes push to the other member.
|
||||
@@ -238,38 +240,38 @@ async function triggerTargetedResync(
|
||||
console.error(
|
||||
'[outboxWorker] dispatchEventChange error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
// Re-sync failure is non-fatal — log and continue (T-03-13)
|
||||
console.error(
|
||||
'[outboxWorker] triggerTargetedResync error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
type OutboxRow = typeof calendarOutbox.$inferSelect
|
||||
type OutboxRow = typeof calendarOutbox.$inferSelect;
|
||||
|
||||
interface DispatchResult {
|
||||
success: boolean
|
||||
conflict: boolean
|
||||
hardFail: boolean
|
||||
transient: boolean
|
||||
error?: string
|
||||
success: boolean;
|
||||
conflict: boolean;
|
||||
hardFail: boolean;
|
||||
transient: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
// CR-03: fail closed on credential errors — let loadClientForUser throw.
|
||||
// The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
|
||||
// Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
|
||||
const client = await loadClientForUser(row.userId)
|
||||
const client = await loadClientForUser(row.userId);
|
||||
|
||||
let response: Response
|
||||
let response: Response;
|
||||
|
||||
if (row.operation === 'delete') {
|
||||
if (!row.calendarObjectUrl) {
|
||||
@@ -279,9 +281,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: 'delete operation missing calendarObjectUrl',
|
||||
}
|
||||
};
|
||||
}
|
||||
response = await deleteCalendarEvent(client, row.calendarObjectUrl, row.etag ?? null)
|
||||
response = await deleteCalendarEvent(client, row.calendarObjectUrl, row.etag ?? null);
|
||||
} else if (row.operation === 'update') {
|
||||
if (!row.payload || !row.calendarObjectUrl) {
|
||||
return {
|
||||
@@ -290,22 +292,34 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: 'update operation missing payload or calendarObjectUrl',
|
||||
}
|
||||
};
|
||||
}
|
||||
// CR-02: parse the stored form JSON and build a real VCALENDAR string
|
||||
let rawFields: Record<string, unknown>
|
||||
let rawFields: Record<string, unknown>;
|
||||
try {
|
||||
rawFields = JSON.parse(row.payload) as Record<string, unknown>
|
||||
rawFields = JSON.parse(row.payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
|
||||
return {
|
||||
success: false,
|
||||
conflict: false,
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: 'payload parse failed',
|
||||
};
|
||||
}
|
||||
// IN-03: re-validate the parsed payload. A schema-invalid row can never succeed —
|
||||
// hard-fail it (no retry) rather than feeding undefined/Invalid Date into the VEVENT.
|
||||
const parsedFields = outboxPayloadSchema.safeParse(rawFields)
|
||||
const parsedFields = outboxPayloadSchema.safeParse(rawFields);
|
||||
if (!parsedFields.success) {
|
||||
return { success: false, conflict: false, hardFail: true, transient: false, error: `payload validation failed: ${parsedFields.error.message}` }
|
||||
return {
|
||||
success: false,
|
||||
conflict: false,
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: `payload validation failed: ${parsedFields.error.message}`,
|
||||
};
|
||||
}
|
||||
const fields: OutboxPayloadFields = parsedFields.data
|
||||
const fields: OutboxPayloadFields = parsedFields.data;
|
||||
// WR-01: recurrence preservation. The PWA omits `recurrence` from an edit payload
|
||||
// (it cannot read the existing RRULE — not in the occurrence contract, D-03), so on
|
||||
// update we must NOT rebuild the VEVENT with no RRULE — that would silently convert a
|
||||
@@ -313,12 +327,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
// recurrence, fall back to the RRULE already stored in calendarEvents.rawVevent.
|
||||
// An explicit recurrence value (including 'none') still overrides — that is a
|
||||
// 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')
|
||||
let preservedRrule: string | undefined;
|
||||
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
|
||||
const rruleFromPayload =
|
||||
fields.recurrence && fields.recurrence !== 'none'
|
||||
? RRULE_PRESETS[fields.recurrence as string]
|
||||
: undefined
|
||||
: undefined;
|
||||
// WR-02: re-read the freshest etag from calendarEvents just before PUT.
|
||||
// Rapid successive edits to the same uid enqueue multiple update rows, each
|
||||
// carrying the etag at enqueue time. If a prior edit succeeded and triggered
|
||||
@@ -334,7 +348,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
// D-08) or coincidentally match and overwrite. Scope the re-read to THIS row's
|
||||
// own calendar by joining through calendars on the outbox row's userId +
|
||||
// calendarUrl so the freshest etag belongs to the writing member.
|
||||
let etagForPut: string | null = row.etag ?? null
|
||||
let etagForPut: string | null = row.etag ?? null;
|
||||
const freshEtagRows = (await db
|
||||
.select({ etag: calendarEvents.etag, rawVevent: calendarEvents.rawVevent })
|
||||
.from(calendarEvents)
|
||||
@@ -346,15 +360,15 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
eq(calendars.url, row.calendarUrl),
|
||||
),
|
||||
)
|
||||
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>
|
||||
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>;
|
||||
if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) {
|
||||
etagForPut = freshEtagRows[0].etag
|
||||
etagForPut = freshEtagRows[0].etag;
|
||||
}
|
||||
|
||||
// WR-01: when the edit payload carries no explicit recurrence, preserve the RRULE
|
||||
// already on the stored event so an edit does not strip a recurring series.
|
||||
if (!hasExplicitRecurrence && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
|
||||
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent)
|
||||
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent);
|
||||
}
|
||||
|
||||
// D-06: assemble the final RRULE string, combining the preset or preserved RRULE
|
||||
@@ -364,7 +378,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
// concatenate onto `FREQ=WEEKLY;BYDAY=...` which would produce double-UNTIL.
|
||||
// WR-01 note: preservedRrule is only set when !hasExplicitRecurrence (see above),
|
||||
// so the hasExplicitRecurrence branch always takes precedence over preserved RRULE.
|
||||
let finalRruleString: string | undefined
|
||||
let finalRruleString: string | undefined;
|
||||
if (hasExplicitRecurrence) {
|
||||
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
|
||||
finalRruleString = rruleFromPayload
|
||||
@@ -374,22 +388,22 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
fields.recurrenceCount,
|
||||
fields.allDay,
|
||||
)
|
||||
: undefined
|
||||
: undefined;
|
||||
} else if (preservedRrule) {
|
||||
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
|
||||
// Series edit with bound change only: strip existing UNTIL/COUNT, then re-apply
|
||||
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '')
|
||||
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
|
||||
finalRruleString = assembleRruleString(
|
||||
strippedPreset,
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
finalRruleString = preservedRrule
|
||||
finalRruleString = preservedRrule;
|
||||
}
|
||||
} else {
|
||||
finalRruleString = rruleFromPayload
|
||||
finalRruleString = rruleFromPayload;
|
||||
}
|
||||
|
||||
const { icsString } = buildVeventString({
|
||||
@@ -401,14 +415,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
location: fields.location,
|
||||
description: fields.description,
|
||||
rruleString: finalRruleString,
|
||||
})
|
||||
});
|
||||
|
||||
response = await updateCalendarEvent(
|
||||
client,
|
||||
row.calendarObjectUrl,
|
||||
icsString,
|
||||
etagForPut,
|
||||
)
|
||||
response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut);
|
||||
} else {
|
||||
// create
|
||||
if (!row.payload) {
|
||||
@@ -418,21 +427,33 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: 'create operation missing payload',
|
||||
}
|
||||
};
|
||||
}
|
||||
// CR-02: parse the stored form JSON and build a real VCALENDAR string
|
||||
let rawFields: Record<string, unknown>
|
||||
let rawFields: Record<string, unknown>;
|
||||
try {
|
||||
rawFields = JSON.parse(row.payload) as Record<string, unknown>
|
||||
rawFields = JSON.parse(row.payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
|
||||
return {
|
||||
success: false,
|
||||
conflict: false,
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: 'payload parse failed',
|
||||
};
|
||||
}
|
||||
// IN-03: re-validate the parsed payload — hard-fail a schema-invalid create row.
|
||||
const parsedFields = outboxPayloadSchema.safeParse(rawFields)
|
||||
const parsedFields = outboxPayloadSchema.safeParse(rawFields);
|
||||
if (!parsedFields.success) {
|
||||
return { success: false, conflict: false, hardFail: true, transient: false, error: `payload validation failed: ${parsedFields.error.message}` }
|
||||
return {
|
||||
success: false,
|
||||
conflict: false,
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: `payload validation failed: ${parsedFields.error.message}`,
|
||||
};
|
||||
}
|
||||
const fields: OutboxPayloadFields = parsedFields.data
|
||||
const fields: OutboxPayloadFields = parsedFields.data;
|
||||
// CR-01: edit-as-move RRULE preservation. The same-calendar `update` branch
|
||||
// preserves a recurring series' RRULE by reading rawVevent; the `create` branch
|
||||
// (used for the create half of an edit-as-move, D-04) has no source for the
|
||||
@@ -441,21 +462,21 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
// the worker can re-apply it here. An explicit `recurrence` on the payload still
|
||||
// wins (deliberate user change); the preserved RRULE only fills the gap when the
|
||||
// edit omitted recurrence — matching the update-branch semantics and the WR-01 fix.
|
||||
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
|
||||
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
|
||||
const rruleFromPayload =
|
||||
fields.recurrence && fields.recurrence !== 'none'
|
||||
? RRULE_PRESETS[fields.recurrence as string]
|
||||
: undefined
|
||||
: undefined;
|
||||
const preservedRrule =
|
||||
typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0
|
||||
? fields._preservedRrule
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
// D-06: assemble the final RRULE string with optional UNTIL/COUNT bound.
|
||||
// CR-01: an explicit recurrence preset wins over _preservedRrule (deliberate user choice).
|
||||
// recurrence:'none' explicitly clears any RRULE — including when _preservedRrule is present.
|
||||
// If no explicit recurrence, fall back to _preservedRrule (edit-as-move RRULE carry-through).
|
||||
let finalRruleString: string | undefined
|
||||
let finalRruleString: string | undefined;
|
||||
if (hasExplicitRecurrence) {
|
||||
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
|
||||
finalRruleString = rruleFromPayload
|
||||
@@ -465,22 +486,22 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
fields.recurrenceCount,
|
||||
fields.allDay,
|
||||
)
|
||||
: undefined
|
||||
: undefined;
|
||||
} else if (preservedRrule) {
|
||||
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
|
||||
// Bound change on preserved RRULE: strip existing UNTIL/COUNT first (Pitfall 3)
|
||||
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '')
|
||||
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
|
||||
finalRruleString = assembleRruleString(
|
||||
strippedPreset,
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
finalRruleString = preservedRrule
|
||||
finalRruleString = preservedRrule;
|
||||
}
|
||||
} else {
|
||||
finalRruleString = rruleFromPayload
|
||||
finalRruleString = rruleFromPayload;
|
||||
}
|
||||
|
||||
const { icsString } = buildVeventString({
|
||||
@@ -492,13 +513,13 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
location: fields.location,
|
||||
description: fields.description,
|
||||
rruleString: finalRruleString,
|
||||
})
|
||||
});
|
||||
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
|
||||
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1]
|
||||
response = await createCalendarEvent(client, davCalendar, row.uid, icsString)
|
||||
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1];
|
||||
response = await createCalendarEvent(client, davCalendar, row.uid, icsString);
|
||||
}
|
||||
|
||||
const status = response.status
|
||||
const status = response.status;
|
||||
|
||||
if (status === CONFLICT_STATUS) {
|
||||
return {
|
||||
@@ -507,7 +528,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: false,
|
||||
transient: false,
|
||||
error: `412 conflict: etag mismatch for uid=${row.uid}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (HARD_FAIL_STATUSES.has(status)) {
|
||||
@@ -517,7 +538,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: `Hard fail: HTTP ${status} for uid=${row.uid}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (TRANSIENT_STATUSES.has(status)) {
|
||||
@@ -527,11 +548,11 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: false,
|
||||
transient: true,
|
||||
error: `Transient error: HTTP ${status} for uid=${row.uid}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, conflict: false, hardFail: false, transient: false }
|
||||
return { success: true, conflict: false, hardFail: false, transient: false };
|
||||
}
|
||||
|
||||
// IN-02: an unmapped 4xx (e.g. 405, 409, 422) is a permanent client error — retrying it
|
||||
@@ -547,7 +568,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: true,
|
||||
transient: false,
|
||||
error: `Hard fail: HTTP ${status} for uid=${row.uid}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown / 5xx status — treat as transient to avoid silent data loss
|
||||
@@ -557,7 +578,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
hardFail: false,
|
||||
transient: true,
|
||||
error: `Unknown HTTP ${status} for uid=${row.uid}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main drain loop ──────────────────────────────────────────────────────────
|
||||
@@ -580,8 +601,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
*/
|
||||
export async function runOutboxDrain(): Promise<void> {
|
||||
// CR-05: single-process concurrency guard (see isDraining declaration for limitations)
|
||||
if (isDraining) return
|
||||
isDraining = true
|
||||
if (isDraining) return;
|
||||
isDraining = true;
|
||||
|
||||
try {
|
||||
// Fetch pending rows: WHERE status='pending' AND next_attempt_at <= NOW()
|
||||
@@ -589,40 +610,37 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
.select()
|
||||
.from(calendarOutbox)
|
||||
.where(
|
||||
and(
|
||||
eq(calendarOutbox.status, 'pending'),
|
||||
lte(calendarOutbox.nextAttemptAt, new Date()),
|
||||
),
|
||||
)) as OutboxRow[]
|
||||
and(eq(calendarOutbox.status, 'pending'), lte(calendarOutbox.nextAttemptAt, new Date())),
|
||||
)) as OutboxRow[];
|
||||
|
||||
if (pending.length === 0) return
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// D-04 edit-as-move ordering: sort so create rows come before delete rows within the same groupId.
|
||||
// Rows without a groupId are unaffected (stable relative order preserved).
|
||||
// This is a fast path; the authoritative gate is the durable DB sibling-status check below.
|
||||
const sorted = [...pending].sort((a, b) => {
|
||||
if (a.groupId && b.groupId && a.groupId === b.groupId) {
|
||||
if (a.operation === 'create' && b.operation === 'delete') return -1
|
||||
if (a.operation === 'delete' && b.operation === 'create') return 1
|
||||
if (a.operation === 'create' && b.operation === 'delete') return -1;
|
||||
if (a.operation === 'delete' && b.operation === 'create') return 1;
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Track groupIds where the create failed within this batch (fast path for same-batch pairs).
|
||||
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
|
||||
const failedCreateGroups = new Set<string>()
|
||||
const failedCreateGroups = new Set<string>();
|
||||
|
||||
// IN-01: per-drain-cycle client cache so triggerTargetedResync decrypts each member's
|
||||
// credential at most once per cycle. Discarded when the drain returns — never persisted.
|
||||
const clientCache = new Map<number, FastmailClient>()
|
||||
const clientCache = new Map<number, FastmailClient>();
|
||||
|
||||
for (const row of sorted) {
|
||||
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
|
||||
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
|
||||
console.warn(
|
||||
`[outboxWorker] Skipping delete row.id=${row.id} — create for groupId=${row.groupId} failed this batch (D-04)`,
|
||||
)
|
||||
continue
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// CR-04: Durable create-before-delete gate — query DB for sibling create status.
|
||||
@@ -632,40 +650,37 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
.select({ status: calendarOutbox.status })
|
||||
.from(calendarOutbox)
|
||||
.where(
|
||||
and(
|
||||
eq(calendarOutbox.groupId, row.groupId),
|
||||
eq(calendarOutbox.operation, 'create'),
|
||||
),
|
||||
)) as Array<{ status: string }>
|
||||
and(eq(calendarOutbox.groupId, row.groupId), eq(calendarOutbox.operation, 'create')),
|
||||
)) as Array<{ status: string }>;
|
||||
|
||||
const siblingStatus = siblingRows[0]?.status
|
||||
const siblingStatus = siblingRows[0]?.status;
|
||||
|
||||
if (siblingStatus !== 'done') {
|
||||
if (siblingStatus === 'failed' || siblingStatus === 'dead') {
|
||||
// Sibling create failed permanently — skip this delete forever (D-04: original preserved)
|
||||
console.warn(
|
||||
`[outboxWorker] Paired create for groupId=${row.groupId} is ${siblingStatus} — marking delete row.id=${row.id} failed (original event preserved, D-04)`,
|
||||
)
|
||||
);
|
||||
await db
|
||||
.update(calendarOutbox)
|
||||
.set({
|
||||
status: 'failed',
|
||||
lastError: 'paired create did not succeed — original preserved',
|
||||
})
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
.where(eq(calendarOutbox.id, row.id));
|
||||
} else {
|
||||
// Sibling create is still pending/processing — defer this delete to a later cycle
|
||||
console.warn(
|
||||
`[outboxWorker] Deferring delete row.id=${row.id} — sibling create (groupId=${row.groupId}) is not yet done (status=${siblingStatus ?? 'not found'})`,
|
||||
)
|
||||
);
|
||||
// Leave the delete row pending; do NOT update its status
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dispatchRow(row)
|
||||
const result = await dispatchRow(row);
|
||||
|
||||
if (result.conflict) {
|
||||
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.
|
||||
@@ -677,19 +692,19 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
// and has no cue to retry. Emit a move-specific lastError that does NOT contain
|
||||
// '412' so the toast routes it to the dedicated move-failed copy instead of the
|
||||
// generic etag-conflict copy.
|
||||
const isMoveCreate = !!row.groupId && row.operation === 'create'
|
||||
const isMoveCreate = !!row.groupId && row.operation === 'create';
|
||||
const conflictError = isMoveCreate
|
||||
? 'move-failed: the event could not be moved — re-open it and save again'
|
||||
: (result.error ?? '412 conflict')
|
||||
: (result.error ?? '412 conflict');
|
||||
// 412 — mark failed (no retry), re-sync calendar so UI sees authoritative state (D-08)
|
||||
await db
|
||||
.update(calendarOutbox)
|
||||
.set({ status: 'failed', lastError: conflictError })
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
|
||||
.where(eq(calendarOutbox.id, row.id));
|
||||
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache);
|
||||
|
||||
if (row.groupId && row.operation === 'create') {
|
||||
failedCreateGroups.add(row.groupId)
|
||||
failedCreateGroups.add(row.groupId);
|
||||
}
|
||||
} else if (result.success) {
|
||||
// Success — refresh the local cache BEFORE marking done. The PWA's
|
||||
@@ -711,24 +726,24 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
await Promise.race([
|
||||
triggerTargetedResync(row.calendarUrl, row.userId, clientCache),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)),
|
||||
])
|
||||
]);
|
||||
await db
|
||||
.update(calendarOutbox)
|
||||
.set({ status: 'done' })
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
.where(eq(calendarOutbox.id, row.id));
|
||||
} else if (result.hardFail) {
|
||||
// Hard fail — mark failed immediately, no retry (D-07)
|
||||
await db
|
||||
.update(calendarOutbox)
|
||||
.set({ status: 'failed', lastError: result.error ?? 'Hard fail' })
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
.where(eq(calendarOutbox.id, row.id));
|
||||
|
||||
if (row.groupId && row.operation === 'create') {
|
||||
failedCreateGroups.add(row.groupId)
|
||||
failedCreateGroups.add(row.groupId);
|
||||
}
|
||||
} else {
|
||||
// Transient — exponential backoff or dead-letter (D-07 / T-03-12)
|
||||
const nextAttemptCount = row.attemptCount + 1
|
||||
const nextAttemptCount = row.attemptCount + 1;
|
||||
if (nextAttemptCount >= MAX_ATTEMPTS) {
|
||||
// Dead-letter: max attempts reached (T-03-12)
|
||||
await db
|
||||
@@ -738,15 +753,15 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
attemptCount: nextAttemptCount,
|
||||
lastError: result.error ?? 'Max attempts exceeded',
|
||||
})
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
.where(eq(calendarOutbox.id, row.id));
|
||||
|
||||
if (row.groupId && row.operation === 'create') {
|
||||
failedCreateGroups.add(row.groupId)
|
||||
failedCreateGroups.add(row.groupId);
|
||||
}
|
||||
} else {
|
||||
// WR-01: use row.attemptCount (the attempt that just failed, 0-based) as the backoff index.
|
||||
// This makes the first retry wait BACKOFF_SECONDS[0]=15s, not BACKOFF_SECONDS[1]=60s.
|
||||
const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000
|
||||
const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000;
|
||||
await db
|
||||
.update(calendarOutbox)
|
||||
.set({
|
||||
@@ -754,7 +769,7 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
nextAttemptAt: new Date(Date.now() + backoffMs),
|
||||
lastError: result.error,
|
||||
})
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
.where(eq(calendarOutbox.id, row.id));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -762,11 +777,11 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
console.error(
|
||||
`[outboxWorker] Error dispatching row.id=${row.id} uid=${row.uid}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraining = false
|
||||
isDraining = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,7 +796,7 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
export function startOutboxWorker(): void {
|
||||
setInterval(() => {
|
||||
runOutboxDrain().catch((err: unknown) => {
|
||||
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err)
|
||||
})
|
||||
}, 15 * 1000)
|
||||
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
|
||||
});
|
||||
}, 15 * 1000);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
* (node-cron 4.2.1 silently skipped scheduled executions in the long-running server process;
|
||||
* setInterval fires reliably in the same process — replaced to fix the silent skip.)
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { memberCredentials, calendars } from '../db/schema.js'
|
||||
import { decryptPassword } from './crypto.js'
|
||||
import { createFastmailClient } from './client.js'
|
||||
import { syncCalendar } from './sync.js'
|
||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { memberCredentials, calendars } from '../db/schema.js';
|
||||
import { decryptPassword } from './crypto.js';
|
||||
import { createFastmailClient } from './client.js';
|
||||
import { syncCalendar } from './sync.js';
|
||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
||||
|
||||
/**
|
||||
* Runs one full poll cycle:
|
||||
@@ -32,15 +32,15 @@ import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
|
||||
* so one bad credential does not stop processing for others.
|
||||
*/
|
||||
export async function runPoll(): Promise<void> {
|
||||
const creds = await db.select().from(memberCredentials)
|
||||
const creds = await db.select().from(memberCredentials);
|
||||
|
||||
for (const cred of creds) {
|
||||
try {
|
||||
// Decrypt before client creation (T-03-04: never expose decrypted value in logs)
|
||||
const appPassword = decryptPassword(cred.encryptedPassword)
|
||||
const appPassword = decryptPassword(cred.encryptedPassword);
|
||||
|
||||
const client = await createFastmailClient(cred.fastmailEmail, appPassword)
|
||||
const davCalendars = await client.fetchCalendars()
|
||||
const client = await createFastmailClient(cred.fastmailEmail, appPassword);
|
||||
const davCalendars = await client.fetchCalendars();
|
||||
|
||||
for (const davCal of davCalendars) {
|
||||
// Look up the stored calendar row to get the known ctag (D-13).
|
||||
@@ -52,15 +52,15 @@ export async function runPoll(): Promise<void> {
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.userId, cred.userId), eq(calendars.url, davCal.url)))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
// ctag/syncToken: defensive null handling (Pitfall #6)
|
||||
const knownCtag = stored?.ctag ?? null
|
||||
const currentCtag = davCal.ctag ?? davCal.syncToken ?? null
|
||||
const knownCtag = stored?.ctag ?? null;
|
||||
const currentCtag = davCal.ctag ?? davCal.syncToken ?? null;
|
||||
|
||||
// Skip if ctag is present on both sides and unchanged
|
||||
if (currentCtag !== null && currentCtag === knownCtag) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// NOTIF-03: pass onChanges so external event changes push to non-actor members.
|
||||
@@ -71,17 +71,17 @@ export async function runPoll(): Promise<void> {
|
||||
console.error(
|
||||
'[broker/poller] dispatchEventChange error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Log the error but do NOT log the app password or key (T-03-04)
|
||||
console.error(
|
||||
`[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,9 +93,12 @@ export async function runPoll(): Promise<void> {
|
||||
* in the long-running server process; setInterval fires reliably.
|
||||
*/
|
||||
export function startBrokerPoller(): void {
|
||||
setInterval(() => {
|
||||
runPoll().catch((err: unknown) => {
|
||||
console.error('[broker/poller] Unhandled runPoll error:', err)
|
||||
})
|
||||
}, 5 * 60 * 1000)
|
||||
setInterval(
|
||||
() => {
|
||||
runPoll().catch((err: unknown) => {
|
||||
console.error('[broker/poller] Unhandled runPoll error:', err);
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
* T-05-19 — per-subscription try/catch; dispatchPush already swallows 410/404.
|
||||
*/
|
||||
|
||||
import { and, eq, gt, lte, sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'
|
||||
import { dispatchPush } from '../lib/pushDispatcher.js'
|
||||
import type { NotificationPayload } from '../lib/pushDispatcher.js'
|
||||
import { and, eq, gt, lte, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js';
|
||||
import { dispatchPush } from '../lib/pushDispatcher.js';
|
||||
import type { NotificationPayload } from '../lib/pushDispatcher.js';
|
||||
|
||||
// ── In-memory dedup (D-12: single-process, no Redis) ─────────────────────────
|
||||
// Key: event uid (bare string — no minuteBucket suffix).
|
||||
@@ -45,7 +45,7 @@ import type { NotificationPayload } from '../lib/pushDispatcher.js'
|
||||
// Prevents double-fire when the same event sits in the catch-up window across
|
||||
// multiple consecutive ticks (cross-tick exactly-once guarantee).
|
||||
// Acceptable data loss on process restart for a two-person household.
|
||||
const sentReminders = new Map<string, number>()
|
||||
const sentReminders = new Map<string, number>();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,10 +53,10 @@ const sentReminders = new Map<string, number>()
|
||||
* Format a UTC Date as "YYYY-MM-DD" for calendar deep-link URLs.
|
||||
*/
|
||||
function yyyyMmDd(d: Date): string {
|
||||
const y = d.getUTCFullYear()
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// ── Core scan ─────────────────────────────────────────────────────────────────
|
||||
@@ -69,7 +69,7 @@ function yyyyMmDd(d: Date): string {
|
||||
* default = new Date() (current wall-clock time).
|
||||
*/
|
||||
export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
const windowEnd = new Date(now.getTime() + 16 * 60 * 1000)
|
||||
const windowEnd = new Date(now.getTime() + 16 * 60 * 1000);
|
||||
|
||||
// Single query: events (isShared, timed, in catch-up window) cross-joined with ALL
|
||||
// push_subscriptions. The cross-join (sql`1=1`) fans every matching event out
|
||||
@@ -99,21 +99,21 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
.innerJoin(pushSubscriptions, sql`1=1`)
|
||||
.where(
|
||||
and(
|
||||
eq(calendars.isShared, true), // D-05: shared calendar only (in QUERY, not copy)
|
||||
eq(calendarEvents.allDay, false), // D-07: timed events only
|
||||
gt(calendarEvents.dtstartUtc, now), // strictly future (excludes already-started)
|
||||
eq(calendars.isShared, true), // D-05: shared calendar only (in QUERY, not copy)
|
||||
eq(calendarEvents.allDay, false), // D-07: timed events only
|
||||
gt(calendarEvents.dtstartUtc, now), // strictly future (excludes already-started)
|
||||
lte(calendarEvents.dtstartUtc, windowEnd),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Group flat (event, subscription) rows by event uid so we can:
|
||||
// a) dedup per event (not per (event, sub) pair), and
|
||||
// b) fan out to ALL subscriptions for a deduped event in one pass.
|
||||
type SubRow = { id: number; userId: number; endpoint: string; p256dh: string; auth: string }
|
||||
type SubRow = { id: number; userId: number; endpoint: string; p256dh: string; auth: string };
|
||||
const byUid = new Map<
|
||||
string,
|
||||
{ uid: string; title: string | null; dtstartUtc: Date; subs: SubRow[] }
|
||||
>()
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!byUid.has(row.uid)) {
|
||||
@@ -123,7 +123,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
// dtstartUtc is guaranteed non-null by the allDay=false + gt/lte WHERE
|
||||
dtstartUtc: row.dtstartUtc as Date,
|
||||
subs: [],
|
||||
})
|
||||
});
|
||||
}
|
||||
// subId is undefined when cross-join produces no subscriptions row (empty table)
|
||||
if (row.subId != null) {
|
||||
@@ -133,7 +133,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
endpoint: row.subEndpoint,
|
||||
p256dh: row.subP256dh,
|
||||
auth: row.subAuth,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +143,12 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
// Per-uid exactly-once dedup (D-12): keyed on bare uid, no minuteBucket.
|
||||
// Same event fires at most once regardless of how many ticks it sits in window.
|
||||
// CAVEAT: rescheduling dtstart earlier after a reminder fired will not re-fire (v1).
|
||||
if (sentReminders.has(uid)) continue
|
||||
if (sentReminders.has(uid)) continue;
|
||||
|
||||
const dateStr = yyyyMmDd(event.dtstartUtc)
|
||||
const dateStr = yyyyMmDd(event.dtstartUtc);
|
||||
|
||||
// Lead-accurate body: compute actual minutes to start, guarded to minimum 1.
|
||||
const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000))
|
||||
const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000));
|
||||
|
||||
const notification: NotificationPayload = {
|
||||
// Null-safe title fallback (D-02 / NOTIF-01): title column may be NULL on rows
|
||||
@@ -157,12 +157,12 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
body: `Starts in ${minutes} min`,
|
||||
tag: `reminder-${uid}`,
|
||||
navigate: `/calendar?date=${dateStr}&event=${uid}`,
|
||||
}
|
||||
};
|
||||
|
||||
// Fan out to every subscriber (member-count-agnostic)
|
||||
for (const sub of event.subs) {
|
||||
try {
|
||||
await dispatchPush(sub, notification)
|
||||
await dispatchPush(sub, notification);
|
||||
} catch (err) {
|
||||
// Per-subscription error isolation (T-05-19): one bad sub never aborts the cycle.
|
||||
// dispatchPush itself never throws (it resolves after logging), so this outer
|
||||
@@ -170,20 +170,20 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
console.error(
|
||||
`[broker/reminderScheduler] Error dispatching reminder to sub id=${sub.id} for event uid=${uid}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// WR-01: mark sent AFTER all dispatches have been attempted. Pre-marking before
|
||||
// dispatch prevents retry when dispatchPush throws — at-least-once delivery
|
||||
// requires not pre-marking the uid.
|
||||
sentReminders.set(uid, event.dtstartUtc.getTime())
|
||||
sentReminders.set(uid, event.dtstartUtc.getTime());
|
||||
} catch (err) {
|
||||
// Per-event error isolation (T-05-18): one bad event never aborts remaining events.
|
||||
console.error(
|
||||
`[broker/reminderScheduler] Error processing event uid=${uid}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
// it can be removed safely (it will never re-enter the (now, now+16min] window).
|
||||
for (const [uid, dtstartMs] of sentReminders) {
|
||||
if (dtstartMs <= now.getTime()) {
|
||||
sentReminders.delete(uid)
|
||||
sentReminders.delete(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
export function startReminderScheduler(): void {
|
||||
setInterval(() => {
|
||||
runReminderCheck().catch((err: unknown) => {
|
||||
console.error('[broker/reminderScheduler] Unhandled runReminderCheck error:', err)
|
||||
})
|
||||
}, 60 * 1000)
|
||||
console.error('[broker/reminderScheduler] Unhandled runReminderCheck error:', err);
|
||||
});
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -24,49 +24,52 @@
|
||||
* .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
|
||||
*/
|
||||
|
||||
import { createFastmailClient } from './client.js'
|
||||
import { createFastmailClient } from './client.js';
|
||||
|
||||
async function main() {
|
||||
const email = process.env.FASTMAIL_EMAIL
|
||||
const appPassword = process.env.FASTMAIL_APP_PASSWORD
|
||||
const email = process.env.FASTMAIL_EMAIL;
|
||||
const appPassword = process.env.FASTMAIL_APP_PASSWORD;
|
||||
|
||||
if (!email || !appPassword) {
|
||||
console.error(
|
||||
'Usage: FASTMAIL_EMAIL=<email> FASTMAIL_APP_PASSWORD=<pw> pnpm exec tsx src/broker/spike.ts',
|
||||
)
|
||||
process.exit(1)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[CAL-08 spike] Connecting to Fastmail CalDAV as: ${email}`)
|
||||
console.log('[CAL-08 spike] Creating tsdav client...')
|
||||
console.log(`[CAL-08 spike] Connecting to Fastmail CalDAV as: ${email}`);
|
||||
console.log('[CAL-08 spike] Creating tsdav client...');
|
||||
|
||||
const client = await createFastmailClient(email, appPassword)
|
||||
const client = await createFastmailClient(email, appPassword);
|
||||
|
||||
console.log('[CAL-08 spike] Fetching calendars via PROPFIND...')
|
||||
const calendars = await client.fetchCalendars()
|
||||
console.log('[CAL-08 spike] Fetching calendars via PROPFIND...');
|
||||
const calendars = await client.fetchCalendars();
|
||||
|
||||
console.log(`\n[CAL-08 spike] Found ${calendars.length} calendar collection(s):\n`)
|
||||
console.log(`\n[CAL-08 spike] Found ${calendars.length} calendar collection(s):\n`);
|
||||
|
||||
for (const cal of calendars) {
|
||||
console.log('---')
|
||||
console.log(` url: ${cal.url}`)
|
||||
console.log('---');
|
||||
console.log(` url: ${cal.url}`);
|
||||
// displayName may be a string or a Record (language-tagged value) per CalDAV spec
|
||||
const displayName = typeof cal.displayName === 'string' ? cal.displayName : JSON.stringify(cal.displayName ?? '(none)')
|
||||
console.log(` displayName: ${displayName}`)
|
||||
const displayName =
|
||||
typeof cal.displayName === 'string'
|
||||
? cal.displayName
|
||||
: JSON.stringify(cal.displayName ?? '(none)');
|
||||
console.log(` displayName: ${displayName}`);
|
||||
// ctag/syncToken: Fastmail may return either field (Pitfall #6)
|
||||
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`)
|
||||
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`)
|
||||
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`);
|
||||
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`);
|
||||
}
|
||||
|
||||
console.log('\n[CAL-08 spike] Done.')
|
||||
console.log('\nNext steps:')
|
||||
console.log(' 1. Confirm the shared family calendar URL appears above.')
|
||||
console.log(' 2. Confirm Lucas\'s personal calendar URL appears above.')
|
||||
console.log(' 3. Record both URLs and ctag/syncToken findings in:')
|
||||
console.log(' .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md')
|
||||
console.log('\n[CAL-08 spike] Done.');
|
||||
console.log('\nNext steps:');
|
||||
console.log(' 1. Confirm the shared family calendar URL appears above.');
|
||||
console.log(" 2. Confirm Lucas's personal calendar URL appears above.");
|
||||
console.log(' 3. Record both URLs and ctag/syncToken findings in:');
|
||||
console.log(' .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md');
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error('[CAL-08 spike] Fatal error:', err instanceof Error ? err.message : String(err))
|
||||
process.exit(1)
|
||||
})
|
||||
console.error('[CAL-08 spike] Fatal error:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
+60
-66
@@ -15,13 +15,13 @@
|
||||
* - https://github.com/kewisch/ical.js (ICAL.parse, ICAL.Component, ICAL.Time.isDate)
|
||||
*/
|
||||
|
||||
import type { DAVCalendar } from 'tsdav'
|
||||
import type { FastmailClient } from './client.js'
|
||||
import ICAL from 'ical.js'
|
||||
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 type { DAVCalendar } from 'tsdav';
|
||||
import type { FastmailClient } from './client.js';
|
||||
import ICAL from 'ical.js';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
|
||||
@@ -55,7 +55,7 @@ export async function syncCalendar(
|
||||
syncToken: davCal.syncToken ?? null,
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 2. Select the calendar row to get its DB id (insertId is unreliable on ON DUPLICATE KEY UPDATE).
|
||||
// BUG B: scope by (userId, url) — the same collection URL exists for both members
|
||||
@@ -65,78 +65,80 @@ export async function syncCalendar(
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.userId, userId), eq(calendars.url, davCal.url)))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
if (!cal) {
|
||||
// Should never happen — we just upserted it
|
||||
throw new Error(`syncCalendar: could not find calendar row for userId=${userId} url=${davCal.url}`)
|
||||
throw new Error(
|
||||
`syncCalendar: could not find calendar row for userId=${userId} url=${davCal.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Fetch all calendar objects (REPORT calendar-query).
|
||||
const objects = await client.fetchCalendarObjects({ calendar: davCal })
|
||||
const objects = await client.fetchCalendarObjects({ calendar: davCal });
|
||||
|
||||
// 4. Parse each VCALENDAR/VEVENT and upsert into calendar_events.
|
||||
// Track every uid we see on the server so step 5 can prune cache rows that
|
||||
// no longer exist on Fastmail (deletes — local or external).
|
||||
// Also collect EventChange records for the onChanges callback (NOTIF-03).
|
||||
const seenUids: string[] = []
|
||||
const changes: EventChange[] = []
|
||||
const seenUids: string[] = [];
|
||||
const changes: EventChange[] = [];
|
||||
|
||||
for (const obj of objects) {
|
||||
if (!obj.data) continue
|
||||
if (!obj.data) continue;
|
||||
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
let parsed: ReturnType<typeof ICAL.parse>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||
parsed = ICAL.parse(obj.data as string)
|
||||
parsed = ICAL.parse(obj.data as string);
|
||||
} catch {
|
||||
// Malformed VCALENDAR — skip but do not crash the sync
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) continue
|
||||
const comp = new ICAL.Component(parsed);
|
||||
const vevent = comp.getFirstSubcomponent('vevent');
|
||||
if (!vevent) continue;
|
||||
|
||||
// ical.js getFirstPropertyValue returns a union type; cast to ICAL.Time for date handling
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
|
||||
const uid = vevent.getFirstPropertyValue('uid') as string | null
|
||||
if (!uid) continue
|
||||
seenUids.push(uid)
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null;
|
||||
const uid = vevent.getFirstPropertyValue('uid') as string | null;
|
||||
if (!uid) continue;
|
||||
seenUids.push(uid);
|
||||
|
||||
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
|
||||
const allDay: boolean = dtstart?.isDate ?? false
|
||||
const allDay: boolean = dtstart?.isDate ?? false;
|
||||
|
||||
// Determine if this event is a recurring master (has RRULE or RDATE).
|
||||
// Use ICAL.Event.isRecurring() for parity with expand.ts — it checks both properties.
|
||||
const isRecurring: boolean = new ICAL.Event(vevent).isRecurring()
|
||||
const isRecurring: boolean = new ICAL.Event(vevent).isRecurring();
|
||||
|
||||
// dtstartDate: Drizzle's `date` column accepts a Date object or null.
|
||||
// We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so
|
||||
// Drizzle serialises it correctly as a DATE without a time component.
|
||||
const dtstartDateValue: Date | null =
|
||||
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null
|
||||
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null
|
||||
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null;
|
||||
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null;
|
||||
|
||||
// Extract SUMMARY → title (NOTIF-01 dependency; closes title column stub).
|
||||
const titleValue: string | null =
|
||||
(vevent.getFirstPropertyValue('summary') as string | null) ?? null
|
||||
(vevent.getFirstPropertyValue('summary') as string | null) ?? null;
|
||||
|
||||
// Extract LOCATION for meaningful-change detection (D-04).
|
||||
const locationValue: string | null =
|
||||
(vevent.getFirstPropertyValue('location') as string | null) ?? null
|
||||
(vevent.getFirstPropertyValue('location') as string | null) ?? 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.
|
||||
let oldRow: (typeof calendarEvents.$inferSelect) | null = null
|
||||
let oldRow: typeof calendarEvents.$inferSelect | null = null;
|
||||
if (onChanges) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(calendarEvents)
|
||||
.where(and(eq(calendarEvents.calendarId, cal.id), eq(calendarEvents.uid, uid)))
|
||||
.limit(1)
|
||||
oldRow = existing[0] ?? null
|
||||
.limit(1);
|
||||
oldRow = existing[0] ?? null;
|
||||
}
|
||||
|
||||
await db
|
||||
@@ -165,7 +167,7 @@ export async function syncCalendar(
|
||||
hasRrule: isRecurring,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Collect change record for onChanges callback (NOTIF-03).
|
||||
if (onChanges) {
|
||||
@@ -177,48 +179,45 @@ export async function syncCalendar(
|
||||
operation: 'create',
|
||||
dtstartUtc: dtstartUtcValue,
|
||||
allDay,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Existing event — compute which meaningful fields changed (D-04)
|
||||
const changedFields: string[] = []
|
||||
const changedFields: string[] = [];
|
||||
|
||||
// Compare dtstartUtc (timed events)
|
||||
const oldUtcMs = oldRow.dtstartUtc ? new Date(oldRow.dtstartUtc).getTime() : null
|
||||
const newUtcMs = dtstartUtcValue ? dtstartUtcValue.getTime() : null
|
||||
if (oldUtcMs !== newUtcMs) changedFields.push('dtstartUtc')
|
||||
const oldUtcMs = oldRow.dtstartUtc ? new Date(oldRow.dtstartUtc).getTime() : null;
|
||||
const newUtcMs = dtstartUtcValue ? dtstartUtcValue.getTime() : null;
|
||||
if (oldUtcMs !== newUtcMs) changedFields.push('dtstartUtc');
|
||||
|
||||
// Compare dtstartDate (all-day events) — compare ISO date string
|
||||
const oldDateStr = oldRow.dtstartDate
|
||||
? new Date(oldRow.dtstartDate).toISOString().slice(0, 10)
|
||||
: null
|
||||
const newDateStr = dtstartDateValue
|
||||
? dtstartDateValue.toISOString().slice(0, 10)
|
||||
: null
|
||||
if (oldDateStr !== newDateStr) changedFields.push('dtstartDate')
|
||||
: null;
|
||||
const newDateStr = dtstartDateValue ? dtstartDateValue.toISOString().slice(0, 10) : null;
|
||||
if (oldDateStr !== newDateStr) changedFields.push('dtstartDate');
|
||||
|
||||
// Compare allDay flag
|
||||
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay')
|
||||
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay');
|
||||
|
||||
// Compare title (SUMMARY)
|
||||
const oldTitle = oldRow.title ?? null
|
||||
if (oldTitle !== titleValue) changedFields.push('title')
|
||||
const oldTitle = oldRow.title ?? null;
|
||||
if (oldTitle !== titleValue) changedFields.push('title');
|
||||
|
||||
// Compare location — extract from old rawVevent for comparison
|
||||
let oldLocation: string | null = null
|
||||
let oldLocation: string | null = null;
|
||||
if (oldRow.rawVevent) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() returns 'any'; ICAL.Component is the correct consumer of this value
|
||||
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent))
|
||||
const oldVevent = oldComp.getFirstSubcomponent('vevent')
|
||||
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent));
|
||||
const oldVevent = oldComp.getFirstSubcomponent('vevent');
|
||||
if (oldVevent) {
|
||||
oldLocation =
|
||||
(oldVevent.getFirstPropertyValue('location') as string | null) ?? null
|
||||
oldLocation = (oldVevent.getFirstPropertyValue('location') as string | null) ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Malformed old VEVENT — skip location comparison
|
||||
}
|
||||
}
|
||||
if (oldLocation !== locationValue) changedFields.push('location')
|
||||
if (oldLocation !== locationValue) changedFields.push('location');
|
||||
|
||||
if (changedFields.length > 0) {
|
||||
changes.push({
|
||||
@@ -228,7 +227,7 @@ export async function syncCalendar(
|
||||
changedFields,
|
||||
dtstartUtc: dtstartUtcValue,
|
||||
allDay,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,18 +246,13 @@ export async function syncCalendar(
|
||||
// was removed, then push changes AFTER the delete completes. This ensures the
|
||||
// onChanges payload only describes events that are truly gone from the cache —
|
||||
// not events that may have been re-fetched in a concurrent poll.
|
||||
let pendingDeleteRows: Array<{ uid: string; title: string | null }> = []
|
||||
let pendingDeleteRows: Array<{ uid: string; title: string | null }> = [];
|
||||
if (onChanges && seenUids.length > 0) {
|
||||
// Find cached uids that are about to be pruned so we can emit delete changes
|
||||
pendingDeleteRows = await db
|
||||
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
|
||||
.from(calendarEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(calendarEvents.calendarId, cal.id),
|
||||
notInArray(calendarEvents.uid, seenUids),
|
||||
),
|
||||
)
|
||||
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
|
||||
} else if (onChanges) {
|
||||
// NEW-WR-01: server returned zero events → entire calendar cache will be cleared.
|
||||
// Capture ALL currently-cached rows before the delete so delete-change events
|
||||
@@ -266,15 +260,15 @@ export async function syncCalendar(
|
||||
pendingDeleteRows = await db
|
||||
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
|
||||
.from(calendarEvents)
|
||||
.where(eq(calendarEvents.calendarId, cal.id))
|
||||
.where(eq(calendarEvents.calendarId, cal.id));
|
||||
}
|
||||
|
||||
if (seenUids.length > 0) {
|
||||
await db
|
||||
.delete(calendarEvents)
|
||||
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)))
|
||||
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
|
||||
} else {
|
||||
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id))
|
||||
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id));
|
||||
}
|
||||
|
||||
// Collect delete changes AFTER the DB delete (WR-04: avoids race where the same
|
||||
@@ -284,12 +278,12 @@ export async function syncCalendar(
|
||||
uid: row.uid,
|
||||
title: row.title ?? null,
|
||||
operation: 'delete',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Fire onChanges callback if provided and there are changes (NOTIF-03).
|
||||
// Fire-and-forget: sync correctness must not depend on push success.
|
||||
if (onChanges && changes.length > 0) {
|
||||
onChanges(changes)
|
||||
onChanges(changes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
* - https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js
|
||||
*/
|
||||
|
||||
import ICAL from 'ical.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import ICAL from 'ical.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface NewEventParams {
|
||||
uid?: string // omit = generate new UUID (appended with @familysync)
|
||||
summary: string
|
||||
allDay: boolean
|
||||
uid?: string; // omit = generate new UUID (appended with @familysync)
|
||||
summary: string;
|
||||
allDay: boolean;
|
||||
// All-day: YYYY-MM-DD string (or Date — only the date portion is used)
|
||||
// Timed: JS Date representing a UTC instant
|
||||
dtstart: string | Date
|
||||
dtend: string | Date
|
||||
location?: string
|
||||
description?: string
|
||||
rruleString?: string // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07)
|
||||
dtstamp?: Date // omit = now()
|
||||
dtstart: string | Date;
|
||||
dtend: string | Date;
|
||||
location?: string;
|
||||
description?: string;
|
||||
rruleString?: string; // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07)
|
||||
dtstamp?: Date; // omit = now()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ export const RRULE_PRESETS: Record<string, string> = {
|
||||
weekly: 'FREQ=WEEKLY',
|
||||
monthly: 'FREQ=MONTHLY',
|
||||
yearly: 'FREQ=YEARLY',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* WR-01: Extract the existing RRULE string from a stored VCALENDAR/VEVENT, so the
|
||||
@@ -61,21 +61,21 @@ export const RRULE_PRESETS: Record<string, string> = {
|
||||
* the input cannot be parsed.
|
||||
*/
|
||||
export function extractRruleString(rawVevent: string): string | undefined {
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
let parsed: ReturnType<typeof ICAL.parse>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||
parsed = ICAL.parse(rawVevent)
|
||||
parsed = ICAL.parse(rawVevent);
|
||||
} catch {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) return undefined
|
||||
const rrule = vevent.getFirstPropertyValue('rrule')
|
||||
if (!rrule) return undefined
|
||||
const comp = new ICAL.Component(parsed);
|
||||
const vevent = comp.getFirstSubcomponent('vevent');
|
||||
if (!vevent) return undefined;
|
||||
const rrule = vevent.getFirstPropertyValue('rrule');
|
||||
if (!rrule) return undefined;
|
||||
// ICAL.Recur#toString() yields the RECUR value, e.g. 'FREQ=WEEKLY'.
|
||||
return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString()
|
||||
return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,57 +85,61 @@ export function extractRruleString(rawVevent: string): string | undefined {
|
||||
* icsString is the full VCALENDAR string ready for PUT to Fastmail.
|
||||
*/
|
||||
export function buildVeventString(params: NewEventParams): { uid: string; icsString: string } {
|
||||
const uid = params.uid ?? `${randomUUID()}@familysync`
|
||||
const uid = params.uid ?? `${randomUUID()}@familysync`;
|
||||
|
||||
// --- VCALENDAR wrapper ---
|
||||
const cal = new ICAL.Component(['vcalendar', [], []])
|
||||
cal.updatePropertyWithValue('version', '2.0')
|
||||
cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN')
|
||||
const cal = new ICAL.Component(['vcalendar', [], []]);
|
||||
cal.updatePropertyWithValue('version', '2.0');
|
||||
cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN');
|
||||
|
||||
// --- VEVENT ---
|
||||
const vevent = new ICAL.Component('vevent')
|
||||
vevent.addPropertyWithValue('uid', uid)
|
||||
vevent.addPropertyWithValue('summary', params.summary)
|
||||
const vevent = new ICAL.Component('vevent');
|
||||
vevent.addPropertyWithValue('uid', uid);
|
||||
vevent.addPropertyWithValue('summary', params.summary);
|
||||
|
||||
// DTSTAMP: always present (RFC 5545 §3.8.7.2 — required property)
|
||||
const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true)
|
||||
vevent.addPropertyWithValue('dtstamp', dtstamp)
|
||||
const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true);
|
||||
vevent.addPropertyWithValue('dtstamp', dtstamp);
|
||||
|
||||
if (params.allDay) {
|
||||
// DATE value (not DATETIME) — isDate:true produces VALUE=DATE, no time component (D-13 contract)
|
||||
const startStr =
|
||||
typeof params.dtstart === 'string'
|
||||
? params.dtstart
|
||||
: params.dtstart.toISOString().slice(0, 10)
|
||||
: params.dtstart.toISOString().slice(0, 10);
|
||||
const endStr =
|
||||
typeof params.dtend === 'string'
|
||||
? params.dtend
|
||||
: params.dtend.toISOString().slice(0, 10)
|
||||
typeof params.dtend === 'string' ? params.dtend : params.dtend.toISOString().slice(0, 10);
|
||||
|
||||
const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number]
|
||||
const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number]
|
||||
const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number];
|
||||
const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number];
|
||||
|
||||
// WR-04 (owning boundary): RFC-5545 §3.6.1 — DTEND for an all-day event is the
|
||||
// EXCLUSIVE end date. Advance the user-entered inclusive end by one calendar day.
|
||||
// Building a Date from UTC components ensures no DST ambiguity during the roll-over.
|
||||
const endDate = new Date(Date.UTC(ey, em - 1, ed))
|
||||
endDate.setUTCDate(endDate.getUTCDate() + 1)
|
||||
const ey2 = endDate.getUTCFullYear()
|
||||
const em2 = endDate.getUTCMonth() + 1
|
||||
const ed2 = endDate.getUTCDate()
|
||||
const endDate = new Date(Date.UTC(ey, em - 1, ed));
|
||||
endDate.setUTCDate(endDate.getUTCDate() + 1);
|
||||
const ey2 = endDate.getUTCFullYear();
|
||||
const em2 = endDate.getUTCMonth() + 1;
|
||||
const ed2 = endDate.getUTCDate();
|
||||
|
||||
// ICAL.Timezone.localTimezone is passed as the zone arg required by TS types.
|
||||
// isDate:true suppresses any time/TZID output regardless of zone. (D-13)
|
||||
const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }, ICAL.Timezone.localTimezone)
|
||||
const endTime = new ICAL.Time({ year: ey2, month: em2, day: ed2, isDate: true }, ICAL.Timezone.localTimezone)
|
||||
vevent.addPropertyWithValue('dtstart', startTime)
|
||||
vevent.addPropertyWithValue('dtend', endTime)
|
||||
const startTime = new ICAL.Time(
|
||||
{ year: sy, month: sm, day: sd, isDate: true },
|
||||
ICAL.Timezone.localTimezone,
|
||||
);
|
||||
const endTime = new ICAL.Time(
|
||||
{ year: ey2, month: em2, day: ed2, isDate: true },
|
||||
ICAL.Timezone.localTimezone,
|
||||
);
|
||||
vevent.addPropertyWithValue('dtstart', startTime);
|
||||
vevent.addPropertyWithValue('dtend', endTime);
|
||||
} else {
|
||||
// DATETIME in UTC (useUTC=true → Z suffix; TZID is NOT added by ical.js) (D-13 contract)
|
||||
const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true)
|
||||
const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true)
|
||||
vevent.addPropertyWithValue('dtstart', startTime)
|
||||
vevent.addPropertyWithValue('dtend', endTime)
|
||||
const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true);
|
||||
const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true);
|
||||
vevent.addPropertyWithValue('dtstart', startTime);
|
||||
vevent.addPropertyWithValue('dtend', endTime);
|
||||
}
|
||||
|
||||
// Optional: RRULE (CAL-07 — whole-series recurring events only in v1, D-11)
|
||||
@@ -143,21 +147,21 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr
|
||||
// addPropertyWithValue('rrule', string) serializes the string character-by-character
|
||||
// instead of as a RECUR value type.
|
||||
if (params.rruleString) {
|
||||
const recur = ICAL.Recur.fromString(params.rruleString)
|
||||
const rruleProp = new ICAL.Property('rrule')
|
||||
rruleProp.setValue(recur)
|
||||
vevent.addProperty(rruleProp)
|
||||
const recur = ICAL.Recur.fromString(params.rruleString);
|
||||
const rruleProp = new ICAL.Property('rrule');
|
||||
rruleProp.setValue(recur);
|
||||
vevent.addProperty(rruleProp);
|
||||
}
|
||||
|
||||
// Optional fields — included only when provided (no empty properties in ICS)
|
||||
if (params.location) {
|
||||
vevent.addPropertyWithValue('location', params.location)
|
||||
vevent.addPropertyWithValue('location', params.location);
|
||||
}
|
||||
if (params.description) {
|
||||
vevent.addPropertyWithValue('description', params.description)
|
||||
vevent.addPropertyWithValue('description', params.description);
|
||||
}
|
||||
|
||||
cal.addSubcomponent(vevent)
|
||||
cal.addSubcomponent(vevent);
|
||||
|
||||
return { uid, icsString: cal.toString() }
|
||||
return { uid, icsString: cal.toString() };
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
* - https://github.com/natelindev/tsdav/blob/main/src/request.ts (If-Match confirmed)
|
||||
*/
|
||||
|
||||
import type { FastmailClient } from './client.js'
|
||||
import type { DAVCalendar } from 'tsdav'
|
||||
import type { FastmailClient } from './client.js';
|
||||
import type { DAVCalendar } from 'tsdav';
|
||||
|
||||
/**
|
||||
* Creates a new CalDAV object via PUT with If-None-Match: * (create semantics).
|
||||
@@ -46,7 +46,7 @@ export async function createCalendarEvent(
|
||||
calendar,
|
||||
filename: `${uid}.ics`,
|
||||
iCalString: icsString,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +74,7 @@ export async function updateCalendarEvent(
|
||||
if (etag == null || etag === '') {
|
||||
console.warn(
|
||||
`[write] updateCalendarObject dispatching with NO If-Match (unconditional PUT) — conflict detection disabled for url=${calendarObjectUrl}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
return client.updateCalendarObject({
|
||||
calendarObject: {
|
||||
@@ -82,7 +82,7 @@ export async function updateCalendarEvent(
|
||||
data: icsString,
|
||||
etag: etag ?? '', // tsdav maps etag → If-Match header; '' skips the header (safe default)
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +103,7 @@ export async function deleteCalendarEvent(
|
||||
if (etag == null || etag === '') {
|
||||
console.warn(
|
||||
`[write] deleteCalendarObject dispatching with NO If-Match (unconditional DELETE) — conflict detection disabled for url=${calendarObjectUrl}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
return client.deleteCalendarObject({
|
||||
calendarObject: {
|
||||
@@ -111,5 +111,5 @@ export async function deleteCalendarEvent(
|
||||
data: '', // tsdav deleteCalendarObject requires the DAVCalendarObject shape; data unused
|
||||
etag: etag ?? '',
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user