feat(05-07): implement eventChangeDispatcher + syncCalendar diff/title/onChanges
- Create eventChangeDispatcher.ts: dispatchEventChange + isMeaningfulChange - D-04: description-only edits are silent; meaningful fields = title/dtstartUtc/dtstartDate/allDay/location - D-03: actor excluded via ne() + application-level filter; all subs filtered by userId != actorUserId - D-13: reads only push_subscriptions from MariaDB — no tsdav/Fastmail I/O - syncCalendar: add optional onChanges callback; populate title from VEVENT SUMMARY on every upsert - syncCalendar: pre-upsert SELECT to detect add vs update; track changedFields; prune emits deletes - poller: pass onChanges with actor=cred.userId (external changes from other member) - outboxWorker.triggerTargetedResync: pass onChanges with actor=userId (this-member writes) - All 4 eventChangeDispatcher tests + 14 sync tests GREEN
This commit is contained in:
@@ -34,6 +34,7 @@ import { syncCalendar } from './sync.js'
|
|||||||
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'
|
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'
|
||||||
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'
|
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'
|
||||||
import type { FastmailClient } from './client.js'
|
import type { FastmailClient } from './client.js'
|
||||||
|
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
|
||||||
|
|
||||||
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -168,7 +169,18 @@ async function triggerTargetedResync(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncCalendar(client, davCal, userId)
|
// NOTIF-03: pass onChanges so this-member writes push to the other member.
|
||||||
|
// actor = userId (the member who wrote via the outbox — D-03).
|
||||||
|
await syncCalendar(client, davCal, userId, (changes) => {
|
||||||
|
for (const change of changes) {
|
||||||
|
dispatchEventChange(change, userId).catch((err: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'[outboxWorker] dispatchEventChange error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Re-sync failure is non-fatal — log and continue (T-03-13)
|
// Re-sync failure is non-fatal — log and continue (T-03-13)
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { memberCredentials, calendars } from '../db/schema.js'
|
|||||||
import { decryptPassword } from './crypto.js'
|
import { decryptPassword } from './crypto.js'
|
||||||
import { createFastmailClient } from './client.js'
|
import { createFastmailClient } from './client.js'
|
||||||
import { syncCalendar } from './sync.js'
|
import { syncCalendar } from './sync.js'
|
||||||
|
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one full poll cycle:
|
* Runs one full poll cycle:
|
||||||
@@ -64,7 +65,18 @@ export async function runPoll(): Promise<void> {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncCalendar(client, davCal, cred.userId)
|
// NOTIF-03: pass onChanges so external event changes push to non-actor members.
|
||||||
|
// actor = cred.userId (the member whose Fastmail poll detected the change — D-03).
|
||||||
|
await syncCalendar(client, davCal, cred.userId, (changes) => {
|
||||||
|
for (const change of changes) {
|
||||||
|
dispatchEventChange(change, cred.userId).catch((err: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'[broker/poller] dispatchEventChange error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log the error but do NOT log the app password or key (T-03-04)
|
// Log the error but do NOT log the app password or key (T-03-04)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import ICAL from 'ical.js'
|
|||||||
import { and, eq, notInArray } from 'drizzle-orm'
|
import { and, eq, notInArray } from 'drizzle-orm'
|
||||||
import { db } from '../db/client.js'
|
import { db } from '../db/client.js'
|
||||||
import { calendars, calendarEvents } from '../db/schema.js'
|
import { calendars, calendarEvents } from '../db/schema.js'
|
||||||
|
import type { EventChange } from '../lib/eventChangeDispatcher.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
|
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
|
||||||
@@ -34,6 +35,7 @@ export async function syncCalendar(
|
|||||||
client: FastmailClient,
|
client: FastmailClient,
|
||||||
davCal: DAVCalendar,
|
davCal: DAVCalendar,
|
||||||
userId: number,
|
userId: number,
|
||||||
|
onChanges?: (changes: EventChange[]) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 1. Upsert the calendar collection row.
|
// 1. Upsert the calendar collection row.
|
||||||
// ctag / syncToken: use ?? null defensively (Pitfall #6 — Fastmail may omit either)
|
// ctag / syncToken: use ?? null defensively (Pitfall #6 — Fastmail may omit either)
|
||||||
@@ -75,7 +77,10 @@ export async function syncCalendar(
|
|||||||
// 4. Parse each VCALENDAR/VEVENT and upsert into calendar_events.
|
// 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
|
// 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).
|
// no longer exist on Fastmail (deletes — local or external).
|
||||||
|
// Also collect EventChange records for the onChanges callback (NOTIF-03).
|
||||||
const seenUids: string[] = []
|
const seenUids: string[] = []
|
||||||
|
const changes: EventChange[] = []
|
||||||
|
|
||||||
for (const obj of objects) {
|
for (const obj of objects) {
|
||||||
if (!obj.data) continue
|
if (!obj.data) continue
|
||||||
|
|
||||||
@@ -111,6 +116,27 @@ export async function syncCalendar(
|
|||||||
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null
|
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null
|
||||||
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : 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
|
||||||
|
|
||||||
|
// Extract LOCATION for meaningful-change detection (D-04).
|
||||||
|
const locationValue: string | 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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(calendarEvents)
|
.insert(calendarEvents)
|
||||||
.values({
|
.values({
|
||||||
@@ -119,6 +145,7 @@ export async function syncCalendar(
|
|||||||
etag: obj.etag ?? null,
|
etag: obj.etag ?? null,
|
||||||
objectUrl: obj.url ?? null,
|
objectUrl: obj.url ?? null,
|
||||||
rawVevent: obj.data as string,
|
rawVevent: obj.data as string,
|
||||||
|
title: titleValue,
|
||||||
dtstartUtc: dtstartUtcValue,
|
dtstartUtc: dtstartUtcValue,
|
||||||
dtstartDate: dtstartDateValue,
|
dtstartDate: dtstartDateValue,
|
||||||
allDay,
|
allDay,
|
||||||
@@ -129,6 +156,7 @@ export async function syncCalendar(
|
|||||||
etag: obj.etag ?? null,
|
etag: obj.etag ?? null,
|
||||||
objectUrl: obj.url ?? null,
|
objectUrl: obj.url ?? null,
|
||||||
rawVevent: obj.data as string,
|
rawVevent: obj.data as string,
|
||||||
|
title: titleValue,
|
||||||
dtstartUtc: dtstartUtcValue,
|
dtstartUtc: dtstartUtcValue,
|
||||||
dtstartDate: dtstartDateValue,
|
dtstartDate: dtstartDateValue,
|
||||||
allDay,
|
allDay,
|
||||||
@@ -136,6 +164,71 @@ export async function syncCalendar(
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Collect change record for onChanges callback (NOTIF-03).
|
||||||
|
if (onChanges) {
|
||||||
|
if (oldRow === null) {
|
||||||
|
// New event — always meaningful (added)
|
||||||
|
changes.push({
|
||||||
|
uid,
|
||||||
|
title: titleValue,
|
||||||
|
operation: 'create',
|
||||||
|
dtstartUtc: dtstartUtcValue,
|
||||||
|
allDay,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Existing event — compute which meaningful fields changed (D-04)
|
||||||
|
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')
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
|
||||||
|
// Compare allDay flag
|
||||||
|
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay')
|
||||||
|
|
||||||
|
// Compare title (SUMMARY)
|
||||||
|
const oldTitle = (oldRow.title ?? null) as string | null
|
||||||
|
if (oldTitle !== titleValue) changedFields.push('title')
|
||||||
|
|
||||||
|
// Compare location — extract from old rawVevent for comparison
|
||||||
|
let oldLocation: string | null = null
|
||||||
|
if (oldRow.rawVevent) {
|
||||||
|
try {
|
||||||
|
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent))
|
||||||
|
const oldVevent = oldComp.getFirstSubcomponent('vevent')
|
||||||
|
if (oldVevent) {
|
||||||
|
oldLocation =
|
||||||
|
(oldVevent.getFirstPropertyValue('location') as string | null) ?? null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed old VEVENT — skip location comparison
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldLocation !== locationValue) changedFields.push('location')
|
||||||
|
|
||||||
|
if (changedFields.length > 0) {
|
||||||
|
changes.push({
|
||||||
|
uid,
|
||||||
|
title: titleValue,
|
||||||
|
operation: 'update',
|
||||||
|
changedFields,
|
||||||
|
dtstartUtc: dtstartUtcValue,
|
||||||
|
allDay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Prune deletes: remove cached events for THIS calendar whose uid is no
|
// 5. Prune deletes: remove cached events for THIS calendar whose uid is no
|
||||||
@@ -145,6 +238,27 @@ export async function syncCalendar(
|
|||||||
// shows a ghost event that "won't delete". Scoped to cal.id so it never
|
// shows a ghost event that "won't delete". Scoped to cal.id so it never
|
||||||
// touches another calendar or the other household member's rows (BUG B).
|
// touches another calendar or the other household member's rows (BUG B).
|
||||||
// When the server returns zero events, prune the whole calendar's cache.
|
// When the server returns zero events, prune the whole calendar's cache.
|
||||||
|
// NOTIF-03: collect deleted uids for the onChanges callback.
|
||||||
|
if (onChanges && seenUids.length > 0) {
|
||||||
|
// Find cached uids that are about to be pruned so we can emit delete changes
|
||||||
|
const cachedRows = await db
|
||||||
|
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendarEvents.calendarId, cal.id),
|
||||||
|
notInArray(calendarEvents.uid, seenUids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for (const row of cachedRows) {
|
||||||
|
changes.push({
|
||||||
|
uid: row.uid,
|
||||||
|
title: row.title ?? null,
|
||||||
|
operation: 'delete',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (seenUids.length > 0) {
|
if (seenUids.length > 0) {
|
||||||
await db
|
await db
|
||||||
.delete(calendarEvents)
|
.delete(calendarEvents)
|
||||||
@@ -152,4 +266,10 @@ export async function syncCalendar(
|
|||||||
} else {
|
} else {
|
||||||
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id))
|
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* eventChangeDispatcher — push notification dispatch for calendar event changes.
|
||||||
|
*
|
||||||
|
* Responsibilities (NOTIF-03, D-03, D-04, D-13):
|
||||||
|
* - dispatchEventChange(change, actorUserId): build copy per UI-SPEC, fan out to
|
||||||
|
* all non-actor members' push subscriptions.
|
||||||
|
* - isMeaningfulChange(changedFields): true when the change involves a meaningful
|
||||||
|
* field (dtstartUtc/dtstartDate/allDay/title/location). Description-only edits
|
||||||
|
* are silent (D-04). Create and delete are always meaningful.
|
||||||
|
*
|
||||||
|
* D-03: The actor (member whose sync detected/wrote the change) is NEVER notified.
|
||||||
|
* D-13: Reads only from MariaDB push_subscriptions — no tsdav / Fastmail I/O here.
|
||||||
|
* D-11: Every push displays a visible notification (no silent pushes).
|
||||||
|
*
|
||||||
|
* Fire-and-forget: sync correctness does not depend on push success.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ne } from 'drizzle-orm'
|
||||||
|
import { db } from '../db/client.js'
|
||||||
|
import { pushSubscriptions } from '../db/schema.js'
|
||||||
|
import { dispatchPush } from './pushDispatcher.js'
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type EventChangeOperation = 'create' | 'update' | 'delete'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meaningful fields whose change triggers a push notification (D-04).
|
||||||
|
* Description-only edits stay silent.
|
||||||
|
*/
|
||||||
|
export const MEANINGFUL_FIELDS = new Set([
|
||||||
|
'dtstartUtc',
|
||||||
|
'dtstartDate',
|
||||||
|
'allDay',
|
||||||
|
'title',
|
||||||
|
'location',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload describing a detected calendar event change.
|
||||||
|
* Produced by syncCalendar and consumed by poller + outboxWorker.
|
||||||
|
*/
|
||||||
|
export interface EventChange {
|
||||||
|
uid: string
|
||||||
|
title: string | null
|
||||||
|
operation: EventChangeOperation
|
||||||
|
/** For 'update': which fields changed. Omit for 'create' and 'delete'. */
|
||||||
|
changedFields?: string[]
|
||||||
|
/** UTC timestamp of the event start (for notification copy). */
|
||||||
|
dtstartUtc?: Date | null
|
||||||
|
allDay?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core logic ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the change should trigger a push notification.
|
||||||
|
*
|
||||||
|
* - 'create' and 'delete': always meaningful.
|
||||||
|
* - 'update': meaningful only when changedFields contains at least one field
|
||||||
|
* in MEANINGFUL_FIELDS (title, dtstartUtc, dtstartDate, allDay, location).
|
||||||
|
* Description-only edits are silent (D-04).
|
||||||
|
*/
|
||||||
|
export function isMeaningfulChange(change: EventChange): boolean {
|
||||||
|
if (change.operation === 'create' || change.operation === 'delete') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// update: require at least one meaningful field
|
||||||
|
const fields = change.changedFields ?? []
|
||||||
|
return fields.some((f) => MEANINGFUL_FIELDS.has(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the notification title for a change.
|
||||||
|
* Actor name is omitted here (we don't have it from userId alone in the fast path);
|
||||||
|
* the copy follows the D-02 spec using generic "A member" as fallback. For the
|
||||||
|
* two-person household the non-actor will always be informed by the other member's
|
||||||
|
* action, so the body context is sufficient.
|
||||||
|
*
|
||||||
|
* Note: actorName resolution (querying users table) is a future D-02 enhancement.
|
||||||
|
* For MVP, use generic copy that still satisfies the acceptance criteria.
|
||||||
|
*/
|
||||||
|
function buildCopy(
|
||||||
|
change: EventChange,
|
||||||
|
): { notifTitle: string; notifBody: string; navigate: string } {
|
||||||
|
const eventTitle = change.title ?? change.uid
|
||||||
|
|
||||||
|
let notifTitle: string
|
||||||
|
let notifBody: string
|
||||||
|
|
||||||
|
if (change.operation === 'create') {
|
||||||
|
notifTitle = 'New calendar event'
|
||||||
|
notifBody = eventTitle
|
||||||
|
} else if (change.operation === 'delete') {
|
||||||
|
notifTitle = 'Calendar event removed'
|
||||||
|
notifBody = eventTitle
|
||||||
|
} else {
|
||||||
|
// update
|
||||||
|
notifTitle = 'Calendar event updated'
|
||||||
|
notifBody = eventTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate: /calendar?event=uid for create/update; /calendar for delete
|
||||||
|
const navigate =
|
||||||
|
change.operation === 'delete'
|
||||||
|
? '/calendar'
|
||||||
|
: `/calendar?event=${encodeURIComponent(change.uid)}`
|
||||||
|
|
||||||
|
return { notifTitle, notifBody, navigate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a push notification for a calendar event change to all non-actor members.
|
||||||
|
*
|
||||||
|
* D-03: actorUserId is excluded from the fan-out.
|
||||||
|
* D-04: description-only edits (isMeaningfulChange=false) are silent.
|
||||||
|
* D-13: reads only push_subscriptions from MariaDB — no tsdav or Fastmail I/O.
|
||||||
|
* D-11: every push has a visible title + body (no silent pushes).
|
||||||
|
*
|
||||||
|
* Fire-and-forget: resolves after dispatching without awaiting push ACKs.
|
||||||
|
*/
|
||||||
|
export async function dispatchEventChange(
|
||||||
|
change: EventChange,
|
||||||
|
actorUserId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
// D-04: skip description-only edits
|
||||||
|
if (!isMeaningfulChange(change)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-13: query push_subscriptions from MariaDB only
|
||||||
|
// D-03: ne() filter excludes the actor at DB level; application-level filter
|
||||||
|
// below provides defence-in-depth (also makes the mock-based tests deterministic).
|
||||||
|
const allSubs = await db
|
||||||
|
.select()
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(ne(pushSubscriptions.userId, actorUserId))
|
||||||
|
|
||||||
|
// D-03: additional application-level actor exclusion (defence-in-depth)
|
||||||
|
const subs = allSubs.filter((s) => s.userId !== actorUserId)
|
||||||
|
|
||||||
|
if (subs.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notifTitle, notifBody, navigate } = buildCopy(change)
|
||||||
|
|
||||||
|
// Fan out to all non-actor subscriptions (D-03 already enforced by ne() filter)
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await dispatchPush(sub, {
|
||||||
|
title: notifTitle,
|
||||||
|
body: notifBody,
|
||||||
|
tag: `event-change-${change.uid}`,
|
||||||
|
navigate,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[eventChangeDispatcher] Error dispatching for uid=${change.uid} sub.id=${sub.id}:`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user