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:
@@ -24,8 +24,8 @@
|
||||
* - This file must never be removed — the pattern is referenced by Plan 02 routes.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { COLOR_PALETTE } from './user.js'
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { COLOR_PALETTE } from './user.js';
|
||||
|
||||
export const DEV_USER = {
|
||||
id: 1,
|
||||
@@ -33,7 +33,7 @@ export const DEV_USER = {
|
||||
oidcSub: 'dev-user',
|
||||
displayName: 'Dev User',
|
||||
color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...)
|
||||
@@ -43,7 +43,7 @@ export const DEV_USER = {
|
||||
*/
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: typeof DEV_USER
|
||||
user: typeof DEV_USER;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,18 +59,18 @@ export function devAuthBypass(): MiddlewareHandler {
|
||||
// Hard production guard — FIRST check, before reading any other env var.
|
||||
// Ensures this middleware can never grant access in production regardless of config.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return async (_c, next) => next()
|
||||
return async (_c, next) => next();
|
||||
}
|
||||
|
||||
// Bypass flag not set — passthrough; OIDC auth proceeds normally.
|
||||
if (process.env.DEV_AUTH_BYPASS !== 'true') {
|
||||
return async (_c, next) => next()
|
||||
return async (_c, next) => next();
|
||||
}
|
||||
|
||||
// Bypass active: inject fixed dev user into Hono context.
|
||||
// Routes that read c.get('user') will receive DEV_USER.
|
||||
return async (c, next) => {
|
||||
c.set('user', DEV_USER)
|
||||
await next()
|
||||
}
|
||||
c.set('user', DEV_USER);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
* Source: https://github.com/honojs/middleware/tree/main/packages/oidc-auth
|
||||
*/
|
||||
|
||||
export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth'
|
||||
export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth';
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
* cookie only when the library produced a valid session this request.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { setCookie } from 'hono/cookie'
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { setCookie } from 'hono/cookie';
|
||||
|
||||
/**
|
||||
* Returns a Hono MiddlewareHandler that upgrades the session-scoped oidc-auth cookie
|
||||
@@ -37,7 +37,7 @@ export function persistSessionCookie(): MiddlewareHandler {
|
||||
// The 'as never' cast is required because 'oidcAuthJwt' is a library-internal key
|
||||
// that is not declared in Hono's ContextVariableMap — mirrors the loose-context
|
||||
// convention used elsewhere in this codebase (e.g. resolveUserId).
|
||||
const jwt = c.get('oidcAuthJwt' as never) as string | undefined
|
||||
const jwt = c.get('oidcAuthJwt' as never) as string | undefined;
|
||||
|
||||
// CRITICAL CORRECTNESS GUARD: if no valid session JWT is on context (logged-out,
|
||||
// deleted, or never-set request), do NOT touch cookies and fall straight through.
|
||||
@@ -45,16 +45,16 @@ export function persistSessionCookie(): MiddlewareHandler {
|
||||
// logged-out user would receive a new oidc-auth Set-Cookie with no value, which
|
||||
// could re-authenticate them or produce confusing browser state.
|
||||
if (!jwt) {
|
||||
await next()
|
||||
return
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// A valid session JWT is present — re-issue the cookie BEFORE next() so that if
|
||||
// any future downstream handler deletes the cookie, that delete Set-Cookie header
|
||||
// comes last and wins (safe ordering even without a current logout/revoke route).
|
||||
const name = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'
|
||||
const path = process.env.OIDC_COOKIE_PATH ?? '/'
|
||||
const maxAge = Number(process.env.OIDC_AUTH_EXPIRES ?? 86400)
|
||||
const name = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth';
|
||||
const path = process.env.OIDC_COOKIE_PATH ?? '/';
|
||||
const maxAge = Number(process.env.OIDC_AUTH_EXPIRES ?? 86400);
|
||||
|
||||
// Build the options object. domain is included ONLY when OIDC_COOKIE_DOMAIN is set —
|
||||
// mirroring the library's own conditional-domain logic so the two Set-Cookie headers
|
||||
@@ -66,15 +66,15 @@ export function persistSessionCookie(): MiddlewareHandler {
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
maxAge, // seconds — Hono's setCookie maxAge unit matches OIDC_AUTH_EXPIRES unit
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.OIDC_COOKIE_DOMAIN) {
|
||||
options.domain = process.env.OIDC_COOKIE_DOMAIN
|
||||
options.domain = process.env.OIDC_COOKIE_DOMAIN;
|
||||
}
|
||||
|
||||
// Re-issue the same JWT the library signed. Do NOT re-sign or modify the payload.
|
||||
setCookie(c, name, jwt, options)
|
||||
setCookie(c, name, jwt, options);
|
||||
|
||||
await next()
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
+21
-35
@@ -8,9 +8,9 @@
|
||||
* Source: RESEARCH.md § "User upsert with color assignment"
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { users } from '../db/schema.js'
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
|
||||
/**
|
||||
* Accessible, visually-distinct palette for per-member member-color assignment.
|
||||
@@ -29,11 +29,11 @@ export const COLOR_PALETTE: string[] = [
|
||||
'#9B6DC5', // soft purple
|
||||
'#E8A840', // warm amber (near shared rose — assigned only after cool colors)
|
||||
'#E8734A', // warm coral (closest to shared rose — assigned last)
|
||||
]
|
||||
];
|
||||
|
||||
/** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */
|
||||
const claimStr = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
|
||||
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
||||
|
||||
/**
|
||||
* Derive the best available display name from OIDC claims, in preference order:
|
||||
@@ -54,16 +54,13 @@ const claimStr = (v: unknown): string | undefined =>
|
||||
* so the write-path upsert agrees with /api/me.
|
||||
*/
|
||||
export function deriveDisplayName(claims: {
|
||||
name?: unknown
|
||||
preferred_username?: unknown
|
||||
email?: unknown
|
||||
name?: unknown;
|
||||
preferred_username?: unknown;
|
||||
email?: unknown;
|
||||
}): string | null {
|
||||
return (
|
||||
claimStr(claims.name) ??
|
||||
claimStr(claims.preferred_username) ??
|
||||
claimStr(claims.email) ??
|
||||
null
|
||||
)
|
||||
claimStr(claims.name) ?? claimStr(claims.preferred_username) ?? claimStr(claims.email) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,17 +73,13 @@ export function deriveDisplayName(claims: {
|
||||
* Never keys on email or displayName for identity. displayName is stored as a
|
||||
* display hint only and may change without affecting identity.
|
||||
*/
|
||||
export async function upsertUser(
|
||||
oidcIss: string,
|
||||
oidcSub: string,
|
||||
displayName?: string | null,
|
||||
) {
|
||||
export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string | null) {
|
||||
// 1. Look up by composite identity key (iss + sub) — never email
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub)))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
// Track the IdP display name authoritatively: when the caller supplies a
|
||||
@@ -97,13 +90,10 @@ export async function upsertUser(
|
||||
// A null displayName (no usable claim this request) never overwrites a good
|
||||
// stored value.
|
||||
if (displayName != null && displayName !== existing[0].displayName) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ displayName })
|
||||
.where(eq(users.id, existing[0].id))
|
||||
return { ...existing[0], displayName }
|
||||
await db.update(users).set({ displayName }).where(eq(users.id, existing[0].id));
|
||||
return { ...existing[0], displayName };
|
||||
}
|
||||
return existing[0]
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// 2. Assign the first palette color NOT already in use by another member.
|
||||
@@ -113,11 +103,11 @@ export async function upsertUser(
|
||||
// guarantees distinct, stable colors for up to COLOR_PALETTE.length members
|
||||
// (AUTH-03). Falls back to round-robin by count only once the palette is
|
||||
// exhausted (more members than colors).
|
||||
const usedRows = await db.select({ color: users.color }).from(users)
|
||||
const usedColors = new Set(usedRows.map((r) => r.color))
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
|
||||
// 3. Insert new user row
|
||||
// mysql2 has no RETURNING clause — use $returningId() then re-select
|
||||
@@ -129,14 +119,10 @@ export async function upsertUser(
|
||||
displayName: displayName ?? null,
|
||||
color,
|
||||
})
|
||||
.$returningId()
|
||||
.$returningId();
|
||||
|
||||
// 4. Re-select to return the full typed row
|
||||
const [newUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, inserted.id))
|
||||
.limit(1)
|
||||
const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1);
|
||||
|
||||
return newUser
|
||||
return newUser;
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Source: https://orm.drizzle.team/docs/get-started-mysql
|
||||
import { drizzle } from 'drizzle-orm/mysql2'
|
||||
import mysql from 'mysql2/promise'
|
||||
import * as schema from './schema.js'
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
@@ -11,6 +11,6 @@ const pool = mysql.createPool({
|
||||
database: process.env.DB_NAME ?? 'familysync',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
})
|
||||
});
|
||||
|
||||
export const db = drizzle({ client: pool, schema, mode: 'default' })
|
||||
export const db = drizzle({ client: pool, schema, mode: 'default' });
|
||||
|
||||
+11
-11
@@ -11,7 +11,7 @@ import {
|
||||
index,
|
||||
unique,
|
||||
customType,
|
||||
} from 'drizzle-orm/mysql-core'
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
// Custom varchar type with explicit binary collation.
|
||||
// Drizzle 0.45.x does not expose a first-class collation option on varchar,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
const varcharBin = (name: string) =>
|
||||
customType<{ data: string; driverData: string }>({
|
||||
dataType: () => 'varchar(255) COLLATE utf8mb4_bin',
|
||||
})(name)
|
||||
})(name);
|
||||
|
||||
// ── Phase 4: List tables ───────────────────────────────────────────────────
|
||||
// Imported by test/setup.ts for afterEach cleanup — keep exports consistent.
|
||||
@@ -45,7 +45,7 @@ export const users = mysqlTable(
|
||||
// Composite unique key — identity is iss+sub, never email (D-10)
|
||||
unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Encrypted Fastmail app-password credentials per member (D-04).
|
||||
@@ -66,7 +66,7 @@ export const memberCredentials = mysqlTable(
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
},
|
||||
(t) => [index('idx_member_credentials_user_id').on(t.userId)],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Calendar collections discovered via CalDAV PROPFIND.
|
||||
@@ -98,7 +98,7 @@ export const calendars = mysqlTable(
|
||||
// calendarId. Keying on (userId, url) makes the upsert idempotent per member.
|
||||
unique('uniq_calendar_user_url').on(t.userId, t.url),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Calendar event cache — raw VEVENT blob + indexed dtstart fields.
|
||||
@@ -138,7 +138,7 @@ export const calendarEvents = mysqlTable(
|
||||
// uid is unique per calendar (idempotency key for broker upsert)
|
||||
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Transactional outbox for CalDAV write-back (D-05).
|
||||
@@ -178,7 +178,7 @@ export const calendarOutbox = mysqlTable(
|
||||
index('idx_outbox_next_attempt').on(t.nextAttemptAt, t.status),
|
||||
index('idx_outbox_uid').on(t.uid),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* App-owned named lists — stored in MariaDB, NOT CalDAV (Phase 4).
|
||||
@@ -200,7 +200,7 @@ export const lists = mysqlTable(
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
},
|
||||
(t) => [index('idx_lists_owner_id').on(t.ownerId)],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* List sharing join table (D-02, member-count-agnostic).
|
||||
@@ -224,7 +224,7 @@ export const listShares = mysqlTable(
|
||||
unique('uniq_list_share').on(t.listId, t.userId),
|
||||
index('idx_list_shares_user_id').on(t.userId),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Push notification subscriptions (Phase 5 — Web Push).
|
||||
@@ -254,7 +254,7 @@ export const pushSubscriptions = mysqlTable(
|
||||
unique('uniq_push_endpoint').on(t.endpoint),
|
||||
index('idx_push_subscriptions_user_id').on(t.userId),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Items within a list.
|
||||
@@ -283,4 +283,4 @@ export const listItems = mysqlTable(
|
||||
// Secondary index for checked/unchecked split queries
|
||||
index('idx_list_items_list_id_checked').on(t.listId, t.checked),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
+51
-51
@@ -1,46 +1,44 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { healthRouter } from './routes/health.js'
|
||||
import { meRouter } from './routes/me.js'
|
||||
import { eventsRouter } from './routes/events.js'
|
||||
import { sseRouter } from './routes/sse.js'
|
||||
import { listsRouter, listItemsRouter } from './routes/lists.js'
|
||||
import { pushRouter } from './routes/push.js'
|
||||
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'
|
||||
import { devAuthBypass } from './auth/devBypass.js'
|
||||
import { persistSessionCookie } from './auth/persistSessionCookie.js'
|
||||
import { startBrokerPoller } from './broker/poller.js'
|
||||
import { startOutboxWorker } from './broker/outboxWorker.js'
|
||||
import { startReminderScheduler } from './broker/reminderScheduler.js'
|
||||
import webpush from 'web-push'
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { meRouter } from './routes/me.js';
|
||||
import { eventsRouter } from './routes/events.js';
|
||||
import { sseRouter } from './routes/sse.js';
|
||||
import { listsRouter, listItemsRouter } from './routes/lists.js';
|
||||
import { pushRouter } from './routes/push.js';
|
||||
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js';
|
||||
import { devAuthBypass } from './auth/devBypass.js';
|
||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||
import { startBrokerPoller } from './broker/poller.js';
|
||||
import { startOutboxWorker } from './broker/outboxWorker.js';
|
||||
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
||||
import webpush from 'web-push';
|
||||
|
||||
export const app = new Hono()
|
||||
export const app = new Hono();
|
||||
|
||||
// Compute once at startup: bypass is active only in non-production with explicit opt-in.
|
||||
// In production NODE_ENV='production' → devBypassActive=false → OIDC is always mounted.
|
||||
const devBypassActive =
|
||||
process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true'
|
||||
process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true';
|
||||
|
||||
if (devBypassActive) {
|
||||
console.warn(
|
||||
'⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.',
|
||||
)
|
||||
console.warn('⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.');
|
||||
}
|
||||
|
||||
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
||||
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
|
||||
app.get('/callback', (c) => processOAuthCallback(c))
|
||||
app.get('/callback', (c) => processOAuthCallback(c));
|
||||
|
||||
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
|
||||
app.route('/health', healthRouter)
|
||||
app.route('/health', healthRouter);
|
||||
|
||||
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
|
||||
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
|
||||
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
|
||||
app.use('/api/*', devAuthBypass())
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
||||
// Skipped entirely when devBypassActive so that local dev works without Authelia.
|
||||
@@ -49,9 +47,9 @@ app.use('/api/*', devAuthBypass())
|
||||
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
||||
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
||||
if (!devBypassActive) {
|
||||
app.use('/api/*', oidcAuthMiddleware())
|
||||
app.use('/api/*', oidcAuthMiddleware());
|
||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
}
|
||||
|
||||
// Protected API routes (behind oidcAuthMiddleware)
|
||||
@@ -63,14 +61,14 @@ if (!devBypassActive) {
|
||||
// browser follows it here — now authenticated. The handler then redirects to /
|
||||
// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not
|
||||
// mounted, so /api/login reaches this handler directly and still redirects to /.
|
||||
app.get('/api/login', (c) => c.redirect('/'))
|
||||
app.get('/api/login', (c) => c.redirect('/'));
|
||||
|
||||
app.route('/api/me', meRouter)
|
||||
app.route('/api/events', eventsRouter)
|
||||
app.route('/api/sse', sseRouter)
|
||||
app.route('/api/lists', listsRouter)
|
||||
app.route('/api/list-items', listItemsRouter)
|
||||
app.route('/api/push', pushRouter)
|
||||
app.route('/api/me', meRouter);
|
||||
app.route('/api/events', eventsRouter);
|
||||
app.route('/api/sse', sseRouter);
|
||||
app.route('/api/lists', listsRouter);
|
||||
app.route('/api/list-items', listItemsRouter);
|
||||
app.route('/api/push', pushRouter);
|
||||
|
||||
// WR-04: background worker startup (cron schedules) moved into the isMainModule()
|
||||
// guard below. Calling them at top level registered real node-cron schedules whenever
|
||||
@@ -85,8 +83,8 @@ app.route('/api/push', pushRouter)
|
||||
// apple-touch-icon.png) live at the root. serveStatic calls next() when a file
|
||||
// is not found, so SPA routes fall through to the index.html catch-all below.
|
||||
// (Registered AFTER /health, /api/*, and /callback, so those win.)
|
||||
app.use('/*', serveStatic({ root: './public' }))
|
||||
app.get('*', serveStatic({ path: './public/index.html' }))
|
||||
app.use('/*', serveStatic({ root: './public' }));
|
||||
app.get('*', serveStatic({ path: './public/index.html' }));
|
||||
|
||||
/**
|
||||
* True only when this module is the process entrypoint (run directly), not when it
|
||||
@@ -100,11 +98,11 @@ app.get('*', serveStatic({ path: './public/index.html' }))
|
||||
* resolves symlinks on argv[1]; fileURLToPath turns the module URL into a real path.
|
||||
*/
|
||||
function isMainModule(): boolean {
|
||||
if (!process.argv[1]) return false
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1])
|
||||
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,32 +113,34 @@ if (isMainModule()) {
|
||||
// Configure VAPID credentials for web-push before starting background workers.
|
||||
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
|
||||
// The private key is NEVER served to clients; it signs push requests server-side only.
|
||||
const vapidSubject = process.env.VAPID_SUBJECT ?? ''
|
||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY ?? ''
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY ?? ''
|
||||
const vapidSubject = process.env.VAPID_SUBJECT ?? '';
|
||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY ?? '';
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY ?? '';
|
||||
if (vapidSubject && vapidPublicKey && vapidPrivateKey) {
|
||||
try {
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[startup] setVapidDetails failed — push notifications will not work:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('[startup] VAPID env vars not set — push notifications will fail. Set VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY.')
|
||||
console.warn(
|
||||
'[startup] VAPID env vars not set — push notifications will fail. Set VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY.',
|
||||
);
|
||||
}
|
||||
|
||||
// Start the CalDAV broker poller (5-min cron, D-13 ctag change-detection).
|
||||
// Runs in the background — errors are caught and logged per-credential (T-03-04).
|
||||
startBrokerPoller()
|
||||
startBrokerPoller();
|
||||
// Drain the D-05 outbox every 15s: dispatches pending CalDAV writes to Fastmail.
|
||||
startOutboxWorker()
|
||||
startOutboxWorker();
|
||||
// Start the 1-min reminder scan for shared timed events starting in ~15 min (NOTIF-01).
|
||||
// VAPID must be configured (above) before this starts or push sends will fail.
|
||||
startReminderScheduler()
|
||||
startReminderScheduler();
|
||||
|
||||
serve({ fetch: app.fetch, port: 3000 }, (info) => {
|
||||
console.log(`FamilySync API running on http://localhost:${info.port}`)
|
||||
})
|
||||
console.log(`FamilySync API running on http://localhost:${info.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
* Fire-and-forget: sync correctness does not depend on push success.
|
||||
*/
|
||||
|
||||
import { eq, ne } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { users, pushSubscriptions } from '../db/schema.js'
|
||||
import { dispatchPush } from './pushDispatcher.js'
|
||||
import { eq, ne } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users, pushSubscriptions } from '../db/schema.js';
|
||||
import { dispatchPush } from './pushDispatcher.js';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EventChangeOperation = 'create' | 'update' | 'delete'
|
||||
export type EventChangeOperation = 'create' | 'update' | 'delete';
|
||||
|
||||
/**
|
||||
* Meaningful fields whose change triggers a push notification (D-04).
|
||||
@@ -34,21 +34,21 @@ export const MEANINGFUL_FIELDS = new Set([
|
||||
'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
|
||||
uid: string;
|
||||
title: string | null;
|
||||
operation: EventChangeOperation;
|
||||
/** For 'update': which fields changed. Omit for 'create' and 'delete'. */
|
||||
changedFields?: string[]
|
||||
changedFields?: string[];
|
||||
/** UTC timestamp of the event start (for notification copy). */
|
||||
dtstartUtc?: Date | null
|
||||
allDay?: boolean
|
||||
dtstartUtc?: Date | null;
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
// ── Core logic ───────────────────────────────────────────────────────────────
|
||||
@@ -63,11 +63,11 @@ export interface EventChange {
|
||||
*/
|
||||
export function isMeaningfulChange(change: EventChange): boolean {
|
||||
if (change.operation === 'create' || change.operation === 'delete') {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
// update: require at least one meaningful field
|
||||
const fields = change.changedFields ?? []
|
||||
return fields.some((f) => MEANINGFUL_FIELDS.has(f))
|
||||
const fields = change.changedFields ?? [];
|
||||
return fields.some((f) => MEANINGFUL_FIELDS.has(f));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,32 +81,32 @@ function buildCopy(
|
||||
change: EventChange,
|
||||
actorName: string,
|
||||
): { notifTitle: string; notifBody: string; navigate: string } {
|
||||
const eventTitle = change.title ?? change.uid
|
||||
const eventTitle = change.title ?? change.uid;
|
||||
|
||||
let notifTitle: string
|
||||
let notifBody: string
|
||||
let notifTitle: string;
|
||||
let notifBody: string;
|
||||
|
||||
// D-02: event notifications show specifics — actor + title.
|
||||
// D-03: name the actor in every change notification.
|
||||
if (change.operation === 'create') {
|
||||
notifTitle = `${actorName} added an event`
|
||||
notifBody = eventTitle
|
||||
notifTitle = `${actorName} added an event`;
|
||||
notifBody = eventTitle;
|
||||
} else if (change.operation === 'delete') {
|
||||
notifTitle = `${actorName} removed an event`
|
||||
notifBody = eventTitle
|
||||
notifTitle = `${actorName} removed an event`;
|
||||
notifBody = eventTitle;
|
||||
} else {
|
||||
// update
|
||||
notifTitle = `${actorName} updated an event`
|
||||
notifBody = eventTitle
|
||||
notifTitle = `${actorName} updated an event`;
|
||||
notifBody = eventTitle;
|
||||
}
|
||||
|
||||
// Navigate: /calendar?event=uid for create/update; /calendar for delete
|
||||
const navigate =
|
||||
change.operation === 'delete'
|
||||
? '/calendar'
|
||||
: `/calendar?event=${encodeURIComponent(change.uid)}`
|
||||
: `/calendar?event=${encodeURIComponent(change.uid)}`;
|
||||
|
||||
return { notifTitle, notifBody, navigate }
|
||||
return { notifTitle, notifBody, navigate };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,13 +119,10 @@ function buildCopy(
|
||||
*
|
||||
* Fire-and-forget: resolves after dispatching without awaiting push ACKs.
|
||||
*/
|
||||
export async function dispatchEventChange(
|
||||
change: EventChange,
|
||||
actorUserId: number,
|
||||
): Promise<void> {
|
||||
export async function dispatchEventChange(change: EventChange, actorUserId: number): Promise<void> {
|
||||
// D-04: skip description-only edits
|
||||
if (!isMeaningfulChange(change)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// IN-01: resolve actor display name for D-02/D-03 notification copy.
|
||||
@@ -139,22 +136,19 @@ export async function dispatchEventChange(
|
||||
// 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).
|
||||
db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(ne(pushSubscriptions.userId, actorUserId)),
|
||||
])
|
||||
db.select().from(pushSubscriptions).where(ne(pushSubscriptions.userId, actorUserId)),
|
||||
]);
|
||||
|
||||
const actorName: string = actorRows[0]?.displayName ?? 'A family member'
|
||||
const actorName: string = actorRows[0]?.displayName ?? 'A family member';
|
||||
|
||||
// D-03: additional application-level actor exclusion (defence-in-depth)
|
||||
const subs = allSubs.filter((s) => s.userId !== actorUserId)
|
||||
const subs = allSubs.filter((s) => s.userId !== actorUserId);
|
||||
|
||||
if (subs.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName)
|
||||
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName);
|
||||
|
||||
// Fan out to all non-actor subscriptions (D-03 already enforced by ne() filter)
|
||||
for (const sub of subs) {
|
||||
@@ -164,12 +158,12 @@ export async function dispatchEventChange(
|
||||
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),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,27 +17,24 @@
|
||||
* Source: RESEARCH.md Finding 3 verbatim pattern.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { lists, listShares } from '../db/schema.js'
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { lists, listShares } from '../db/schema.js';
|
||||
|
||||
/**
|
||||
* Returns all list IDs accessible to userId:
|
||||
* owned lists UNION lists shared to this user, deduplicated.
|
||||
*/
|
||||
export async function getAccessibleListIds(userId: number): Promise<number[]> {
|
||||
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId))
|
||||
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId));
|
||||
|
||||
const shared = await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(eq(listShares.userId, userId))
|
||||
.where(eq(listShares.userId, userId));
|
||||
|
||||
const all = [
|
||||
...owned.map((r) => r.id),
|
||||
...shared.map((r) => r.listId),
|
||||
]
|
||||
const all = [...owned.map((r) => r.id), ...shared.map((r) => r.listId)];
|
||||
|
||||
// Deduplicate (handles the degenerate case where a list is both owned and shared)
|
||||
return [...new Set(all)]
|
||||
return [...new Set(all)];
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* - coalesceListPush handles burst collapsing; this module owns audience + copy.
|
||||
*/
|
||||
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js'
|
||||
import { coalesceListPush } from './pushCoalescer.js'
|
||||
import { dispatchPush } from './pushDispatcher.js'
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js';
|
||||
import { coalesceListPush } from './pushCoalescer.js';
|
||||
import { dispatchPush } from './pushDispatcher.js';
|
||||
|
||||
/**
|
||||
* Notify all accessible, non-actor subscribers that a list changed.
|
||||
@@ -36,83 +36,82 @@ import { dispatchPush } from './pushDispatcher.js'
|
||||
* for fast fake-timer or real-timer test execution.
|
||||
*/
|
||||
export function notifyListChange(listId: number, actorId: number, windowMs?: number): void {
|
||||
coalesceListPush(listId, actorId, async (coalListId, coalActorId, count) => {
|
||||
try {
|
||||
await sendListChangePush(coalListId, coalActorId, count)
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
`[listChangeDispatcher] unhandled error for list ${coalListId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
}, windowMs)
|
||||
coalesceListPush(
|
||||
listId,
|
||||
actorId,
|
||||
async (coalListId, coalActorId, count) => {
|
||||
try {
|
||||
await sendListChangePush(coalListId, coalActorId, count);
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
`[listChangeDispatcher] unhandled error for list ${coalListId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
},
|
||||
windowMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner dispatch: resolves actor name + list name, builds the audience,
|
||||
* and sends a push to every accessible non-actor subscriber.
|
||||
*/
|
||||
async function sendListChangePush(
|
||||
listId: number,
|
||||
actorId: number,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
async function sendListChangePush(listId: number, actorId: number, count: number): Promise<void> {
|
||||
// Resolve actor display name and list name in parallel
|
||||
const [actorRow, listRow] = await Promise.all([
|
||||
db.select({ displayName: users.displayName }).from(users).where(eq(users.id, actorId)).limit(1),
|
||||
db.select({ name: lists.name }).from(lists).where(eq(lists.id, listId)).limit(1),
|
||||
])
|
||||
]);
|
||||
|
||||
if (!listRow[0]) {
|
||||
// List deleted between mutation and coalesce fire — no-op
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const actorName: string = actorRow[0]?.displayName ?? 'Someone'
|
||||
const listName: string = listRow[0].name
|
||||
const actorName: string = actorRow[0]?.displayName ?? 'Someone';
|
||||
const listName: string = listRow[0].name;
|
||||
|
||||
// Build audience: list owner ∪ list_shares members, MINUS the actor (D-03)
|
||||
const [ownerRows, shareRows] = await Promise.all([
|
||||
db.select({ ownerId: lists.ownerId }).from(lists).where(eq(lists.id, listId)).limit(1),
|
||||
db.select({ userId: listShares.userId }).from(listShares).where(eq(listShares.listId, listId)),
|
||||
])
|
||||
]);
|
||||
|
||||
if (!ownerRows[0]) {
|
||||
// List gone — no-op
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = ownerRows[0].ownerId
|
||||
const shareUserIds = shareRows.map((r) => r.userId)
|
||||
const ownerId = ownerRows[0].ownerId;
|
||||
const shareUserIds = shareRows.map((r) => r.userId);
|
||||
|
||||
// Union of owner + sharees; deduplicate; exclude actor (D-03)
|
||||
const audienceIds = [
|
||||
...new Set([ownerId, ...shareUserIds]),
|
||||
].filter((uid) => uid !== actorId)
|
||||
const audienceIds = [...new Set([ownerId, ...shareUserIds])].filter((uid) => uid !== actorId);
|
||||
|
||||
if (audienceIds.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Load push subscriptions for all audience members
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(inArray(pushSubscriptions.userId, audienceIds))
|
||||
.where(inArray(pushSubscriptions.userId, audienceIds));
|
||||
|
||||
if (subs.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// D-02 generic copy: "{Actor} made {N} changes to {ListName}"
|
||||
// No item text — keeps the lock screen clean.
|
||||
const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`
|
||||
const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`;
|
||||
const notification = {
|
||||
title: listName,
|
||||
body,
|
||||
tag: `list-change:${listId}`,
|
||||
navigate: `/lists/${listId}`,
|
||||
}
|
||||
};
|
||||
|
||||
// Fan out to each subscription; one failure must not abort the rest (T-05-04)
|
||||
for (const sub of subs) {
|
||||
@@ -120,7 +119,7 @@ async function sendListChangePush(
|
||||
console.error(
|
||||
`[listChangeDispatcher] dispatchPush failed for sub ${sub.id}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,29 +15,29 @@
|
||||
* Source: RESEARCH.md Finding 1 verbatim pattern.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Module-level singleton — one emitter shared across all route handlers
|
||||
// in this Node.js process.
|
||||
const emitter = new EventEmitter()
|
||||
emitter.setMaxListeners(200) // 100 members × 2 devices, generous headroom (T-04-04)
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(200); // 100 members × 2 devices, generous headroom (T-04-04)
|
||||
|
||||
/**
|
||||
* Event type union for list change notifications.
|
||||
* All events carry the originating listId and an opaque payload.
|
||||
*/
|
||||
export type ListEvent = {
|
||||
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
|
||||
listId: number
|
||||
payload: unknown
|
||||
}
|
||||
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted';
|
||||
listId: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Broadcast an event to all SSE subscribers watching this list.
|
||||
* Channel is keyed by listId — events for list A never reach subscribers of list B.
|
||||
*/
|
||||
export function publishListEvent(listId: number, event: ListEvent): void {
|
||||
emitter.emit(`list:${listId}`, event)
|
||||
emitter.emit(`list:${listId}`, event);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,7 @@ export function subscribeListEvents(
|
||||
listId: number,
|
||||
handler: (event: ListEvent) => void,
|
||||
): () => void {
|
||||
const channel = `list:${listId}`
|
||||
emitter.on(channel, handler)
|
||||
return () => emitter.off(channel, handler)
|
||||
const channel = `list:${listId}`;
|
||||
emitter.on(channel, handler);
|
||||
return () => emitter.off(channel, handler);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
* Exports: coalesceListPush
|
||||
*/
|
||||
|
||||
type DispatchFn = (listId: number, actorId: number, count: number) => Promise<void>
|
||||
type DispatchFn = (listId: number, actorId: number, count: number) => Promise<void>;
|
||||
|
||||
type PendingEntry = {
|
||||
count: number
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
count: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
// Module-level singleton — keyed by `${listId}:${actorId}`.
|
||||
// Entries are self-deleting: deleted when the timer fires.
|
||||
const pending = new Map<string, PendingEntry>()
|
||||
const pending = new Map<string, PendingEntry>();
|
||||
|
||||
/**
|
||||
* Coalesce list-change push notifications for a single (list, actor) pair.
|
||||
@@ -36,35 +36,30 @@ export function coalesceListPush(
|
||||
dispatch: DispatchFn,
|
||||
windowMs = 45_000,
|
||||
): void {
|
||||
const key = `${listId}:${actorId}`
|
||||
const existing = pending.get(key)
|
||||
const key = `${listId}:${actorId}`;
|
||||
const existing = pending.get(key);
|
||||
|
||||
if (existing) {
|
||||
// Extend the window on every new call within the burst (sliding debounce).
|
||||
clearTimeout(existing.timer)
|
||||
existing.count++
|
||||
existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs)
|
||||
clearTimeout(existing.timer);
|
||||
existing.count++;
|
||||
existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs);
|
||||
} else {
|
||||
// First call in a new burst — start a fresh entry.
|
||||
const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs)
|
||||
pending.set(key, { count: 1, timer })
|
||||
const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs);
|
||||
pending.set(key, { count: 1, timer });
|
||||
}
|
||||
}
|
||||
|
||||
function fire(
|
||||
key: string,
|
||||
listId: number,
|
||||
actorId: number,
|
||||
dispatch: DispatchFn,
|
||||
): void {
|
||||
const entry = pending.get(key)
|
||||
if (!entry) return
|
||||
const count = entry.count
|
||||
pending.delete(key)
|
||||
function fire(key: string, listId: number, actorId: number, dispatch: DispatchFn): void {
|
||||
const entry = pending.get(key);
|
||||
if (!entry) return;
|
||||
const count = entry.count;
|
||||
pending.delete(key);
|
||||
dispatch(listId, actorId, count).catch((err: unknown) => {
|
||||
console.error(
|
||||
`[pushCoalescer] dispatch failed for list ${listId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,31 +16,31 @@
|
||||
* - dispatchPush resolves (never throws) so fan-out loops continue after failures.
|
||||
*/
|
||||
|
||||
import webpush from 'web-push'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { pushSubscriptions } from '../db/schema.js'
|
||||
import webpush from 'web-push';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { pushSubscriptions } from '../db/schema.js';
|
||||
|
||||
/**
|
||||
* Push subscription row shape (subset of schema.pushSubscriptions used by dispatcher).
|
||||
*/
|
||||
export type PushSubscription = {
|
||||
id: number
|
||||
userId: number
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
id: number;
|
||||
userId: number;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Notification content passed to dispatchPush and used to build the push body.
|
||||
*/
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body?: string
|
||||
tag?: string
|
||||
navigate?: string
|
||||
}
|
||||
title: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
navigate?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the dual-format push payload body string.
|
||||
@@ -52,7 +52,7 @@ export type NotificationPayload = {
|
||||
* The service worker reads whichever format the browser understands.
|
||||
*/
|
||||
export function buildPushBody(notification: NotificationPayload): string {
|
||||
const { title, body = '', tag, navigate } = notification
|
||||
const { title, body = '', tag, navigate } = notification;
|
||||
|
||||
return JSON.stringify({
|
||||
// iOS 18.4+ declarative web push format (WebKit blog 2025-04-14)
|
||||
@@ -69,7 +69,7 @@ export function buildPushBody(notification: NotificationPayload): string {
|
||||
data: {
|
||||
url: navigate,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,26 +96,26 @@ export async function dispatchPush(
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const body = buildPushBody(notification)
|
||||
const body = buildPushBody(notification);
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(webPushSub, body, {
|
||||
TTL: 300,
|
||||
urgency: 'normal',
|
||||
})
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode
|
||||
const statusCode = (err as { statusCode?: number }).statusCode;
|
||||
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
// Dead subscription — prune from DB (D-11)
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id))
|
||||
return
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Transient error — log and continue; do NOT delete subscription
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error('[pushDispatcher] sendNotification failed:', statusCode, message)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[pushDispatcher] sendNotification failed:', statusCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Both are pure functions — no DB access, no side effects.
|
||||
*/
|
||||
|
||||
import { generateKeyBetween } from 'fractional-indexing'
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
|
||||
/**
|
||||
* Generate a rank suitable for appending an item AFTER the last active item.
|
||||
@@ -23,7 +23,7 @@ import { generateKeyBetween } from 'fractional-indexing'
|
||||
* An empty list gets "a0" (generateKeyBetween(null, null)).
|
||||
*/
|
||||
export function rankForAppend(lastRank: string | null): string {
|
||||
return generateKeyBetween(lastRank, null)
|
||||
return generateKeyBetween(lastRank, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,5 +38,5 @@ export function rankForAppend(lastRank: string | null): string {
|
||||
* @returns a rank string that sorts between `prev` and `next` when ordered ASC.
|
||||
*/
|
||||
export function rankBetween(prev: string | null, next: string | null): string {
|
||||
return generateKeyBetween(prev, next)
|
||||
return generateKeyBetween(prev, next);
|
||||
}
|
||||
|
||||
+116
-117
@@ -21,29 +21,29 @@
|
||||
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { and, or, eq, desc } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js'
|
||||
import { expandOccurrences } from '../broker/expand.js'
|
||||
import { extractRruleString } from '../broker/vevent.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, or, eq, desc } from 'drizzle-orm';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js';
|
||||
import { expandOccurrences } from '../broker/expand.js';
|
||||
import { extractRruleString } from '../broker/vevent.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const eventsRouter = new Hono()
|
||||
export const eventsRouter = new Hono();
|
||||
|
||||
/** Shared-family calendar rose color (D-06). */
|
||||
const SHARED_FAMILY_COLOR = '#F25C7A'
|
||||
const SHARED_FAMILY_COLOR = '#F25C7A';
|
||||
|
||||
/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
|
||||
const MAX_WINDOW_DAYS = 90
|
||||
const MAX_WINDOW_DAYS = 90;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — shared by all write endpoints
|
||||
@@ -61,22 +61,22 @@ const MAX_WINDOW_DAYS = 90
|
||||
// ContextVariableMap augmentation in auth/devBypass.ts (typed as the DEV_USER shape),
|
||||
// and getAuth(c) accepts a Context — so no `any` / eslint-disable is needed here.
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
|
||||
// Derive displayName via the shared helper (name → preferred_username → email
|
||||
// → sub fallback) so the write-path upsert agrees with me.ts and never
|
||||
// overwrites a correctly-derived name with a worse one.
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const displayName = deriveDisplayName(auth);
|
||||
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,7 +87,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const eventsQuerySchema = z.object({
|
||||
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared event field validation (V5 — bounded lengths, T-03-08).
|
||||
@@ -111,14 +111,17 @@ const eventFieldsSchema = z.object({
|
||||
// ≤10-char non-date string cannot survive .replace(/-/g,'') and inject extra ';'-delimited
|
||||
// RRULE parts when spliced into the UNTIL template (outboxWorker.assembleRruleString).
|
||||
// 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
|
||||
})
|
||||
});
|
||||
|
||||
/** sync-status query params. */
|
||||
const syncStatusQuerySchema = z.object({
|
||||
uid: z.string().min(1).max(512),
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
|
||||
@@ -132,17 +135,17 @@ const syncStatusQuerySchema = z.object({
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
// Resolve the current user first — only return events for owned + shared calendars (T-03-06).
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const { start, end } = c.req.valid('query')
|
||||
const { start, end } = c.req.valid('query');
|
||||
|
||||
// --- Window span guard (T-02b-02) ---
|
||||
const windowStartDate = new Date(start + 'T00:00:00Z')
|
||||
const windowEndDate = new Date(end + 'T00:00:00Z')
|
||||
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
const windowStartDate = new Date(start + 'T00:00:00Z');
|
||||
const windowEndDate = new Date(end + 'T00:00:00Z');
|
||||
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
|
||||
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400)
|
||||
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -211,11 +214,11 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// --- Expand each row into concrete occurrences ---
|
||||
const allOccurrences = rows.flatMap((row) => {
|
||||
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor
|
||||
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor;
|
||||
return expandOccurrences(
|
||||
row.rawVevent,
|
||||
windowStartDate,
|
||||
@@ -226,15 +229,15 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
row.ownerName ?? null,
|
||||
color,
|
||||
row.isShared,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return c.json({ occurrences: allOccurrences })
|
||||
return c.json({ occurrences: allOccurrences });
|
||||
} catch (err) {
|
||||
console.error('[events] DB query or expansion failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events] DB query or expansion failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/events/create
|
||||
@@ -244,16 +247,16 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
// Does NOT build a VEVENT and does NOT call Fastmail — that is the worker's job (D-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const payload = c.req.valid('json')
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// --- Resolve target calendar (D-03 / T-03-06) ---
|
||||
// If calendarUrl given: assert the calendar is owned by the current user OR is shared.
|
||||
// If not given: use the first personal calendar (D-01 last-used is a frontend concern).
|
||||
let targetCalendarUrl: string
|
||||
let targetCalendarUrl: string;
|
||||
|
||||
if (payload.calendarUrl) {
|
||||
// Look up the calendar — it must be owned by the current user or be shared.
|
||||
@@ -265,12 +268,12 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
eq(calendars.url, payload.calendarUrl),
|
||||
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'Calendar not found or access denied' }, 403)
|
||||
return c.json({ error: 'Calendar not found or access denied' }, 403);
|
||||
}
|
||||
targetCalendarUrl = calRow.url
|
||||
targetCalendarUrl = calRow.url;
|
||||
} else {
|
||||
// Default to the member's first personal calendar (D-01).
|
||||
// WR-02: add a deterministic ORDER BY + LIMIT. Without them, a member with
|
||||
@@ -282,16 +285,16 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
.from(calendars)
|
||||
.where(eq(calendars.userId, currentUserId))
|
||||
.orderBy(calendars.id)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'No writable calendar found for user' }, 422)
|
||||
return c.json({ error: 'No writable calendar found for user' }, 422);
|
||||
}
|
||||
targetCalendarUrl = calRow.url
|
||||
targetCalendarUrl = calRow.url;
|
||||
}
|
||||
|
||||
// Generate a UID for the new event (Node.js 22 built-in)
|
||||
const uid = `${randomUUID()}@familysync`
|
||||
const uid = `${randomUUID()}@familysync`;
|
||||
|
||||
// Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
|
||||
await db.insert(calendarOutbox).values({
|
||||
@@ -301,14 +304,14 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
uid,
|
||||
calendarUrl: targetCalendarUrl,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ uid }, 202)
|
||||
return c.json({ uid }, 202);
|
||||
} catch (err) {
|
||||
console.error('[events/create] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/create] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/events/:uid/edit
|
||||
@@ -319,11 +322,11 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const uid = c.req.param('uid')
|
||||
const payload = c.req.valid('json')
|
||||
const uid = c.req.param('uid');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// --- Look up the event and verify ownership ---
|
||||
@@ -358,10 +361,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
),
|
||||
)
|
||||
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!eventRow) {
|
||||
return c.json({ error: 'Event not found' }, 404)
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Ownership check: must be the calendar owner or shared (T-03-06).
|
||||
@@ -372,20 +375,20 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
const [calRow] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl
|
||||
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl
|
||||
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl;
|
||||
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl;
|
||||
|
||||
if (isCalendarMove) {
|
||||
// D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5)
|
||||
const newUid = `${randomUUID()}@familysync`
|
||||
const groupId = randomUUID()
|
||||
const newUid = `${randomUUID()}@familysync`;
|
||||
const groupId = randomUUID();
|
||||
|
||||
// CR-01: carry the existing RRULE through the move. The edit payload omits
|
||||
// `recurrence` (the occurrence contract does not expose it, D-03), and the
|
||||
@@ -398,13 +401,9 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
// Only stash when the edit did NOT carry an explicit recurrence: an explicit
|
||||
// value (including 'none') is a deliberate user change and must win.
|
||||
const preservedRrule =
|
||||
payload.recurrence === undefined
|
||||
? extractRruleString(eventRow.rawVevent ?? '')
|
||||
: undefined
|
||||
payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined;
|
||||
const createPayload =
|
||||
preservedRrule !== undefined
|
||||
? { ...payload, _preservedRrule: preservedRrule }
|
||||
: payload
|
||||
preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete from old calendar
|
||||
@@ -417,7 +416,7 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
||||
etag: eventRow.etag ?? undefined,
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
// Create on new calendar
|
||||
await tx.insert(calendarOutbox).values({
|
||||
userId: currentUserId,
|
||||
@@ -427,10 +426,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
calendarUrl: newCalendarUrl,
|
||||
payload: JSON.stringify(createPayload),
|
||||
groupId,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return c.json({ uid: newUid }, 202)
|
||||
return c.json({ uid: newUid }, 202);
|
||||
}
|
||||
|
||||
// Same calendar — simple update row
|
||||
@@ -443,14 +442,14 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
||||
etag: eventRow.etag ?? undefined,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ uid }, 202)
|
||||
return c.json({ uid }, 202);
|
||||
} catch (err) {
|
||||
console.error('[events/edit] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/edit] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/events/:uid
|
||||
@@ -459,10 +458,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.delete('/:uid', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const uid = c.req.param('uid')
|
||||
const uid = c.req.param('uid');
|
||||
|
||||
try {
|
||||
// Look up the event — join calendars so calendars.url / calendars.userId are accessible.
|
||||
@@ -490,10 +489,10 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
),
|
||||
)
|
||||
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!eventRow) {
|
||||
return c.json({ error: 'Event not found' }, 404)
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Ownership check (T-03-06)
|
||||
@@ -501,10 +500,10 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
const [calRow] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,14 +516,14 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
calendarUrl: eventRow.calendarUrl ?? '',
|
||||
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
||||
etag: eventRow.etag ?? undefined,
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ uid }, 202)
|
||||
return c.json({ uid }, 202);
|
||||
} catch (err) {
|
||||
console.error('[events/delete] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/delete] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/events/sync-status?uid=<uid>
|
||||
@@ -534,10 +533,10 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
// Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const { uid } = c.req.valid('query')
|
||||
const { uid } = c.req.valid('query');
|
||||
|
||||
try {
|
||||
// Scope strictly to current member's rows (T-03-07 — never leak another member's outbox).
|
||||
@@ -562,24 +561,24 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
|
||||
sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`,
|
||||
desc(calendarOutbox.createdAt),
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) {
|
||||
// No outbox row → nothing pending = settled as done
|
||||
return c.json({ uid, status: 'done' })
|
||||
return c.json({ uid, status: 'done' });
|
||||
}
|
||||
|
||||
const row = rows[0]
|
||||
const row = rows[0];
|
||||
return c.json({
|
||||
uid: row.uid,
|
||||
status: row.status,
|
||||
...(row.lastError != null ? { error: row.lastError } : {}),
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[events/sync-status] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/sync-status] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/events/writable-calendars
|
||||
@@ -595,8 +594,8 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
|
||||
// Response: { calendars: [{ url, displayName, color, isShared }] }
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.get('/writable-calendars', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
// D-03 writable set: own personal calendars + shared Family calendar.
|
||||
@@ -609,7 +608,7 @@ eventsRouter.get('/writable-calendars', async (c) => {
|
||||
isShared: calendars.isShared,
|
||||
})
|
||||
.from(calendars)
|
||||
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
|
||||
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)));
|
||||
|
||||
return c.json({
|
||||
calendars: rows.map((row) => ({
|
||||
@@ -618,9 +617,9 @@ eventsRouter.get('/writable-calendars', async (c) => {
|
||||
color: row.color ?? '',
|
||||
isShared: row.isShared,
|
||||
})),
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[events/writable-calendars] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/writable-calendars] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { db } from '../db/client.js'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono';
|
||||
import { db } from '../db/client.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const healthRouter = new Hono()
|
||||
export const healthRouter = new Hono();
|
||||
|
||||
/**
|
||||
* GET /health — unauthenticated endpoint that proves a real DB read+write round-trip.
|
||||
@@ -17,11 +17,11 @@ healthRouter.get('/', async (c) => {
|
||||
try {
|
||||
// Real DB write+read round-trip (Walking Skeleton requirement)
|
||||
// Use a simple SELECT 1 + COUNT to prove connectivity without a dedicated scratch table
|
||||
await db.execute(sql`SELECT 1`)
|
||||
await db.execute(sql`SELECT 1`);
|
||||
|
||||
return c.json({ ok: true, db: 'up' })
|
||||
return c.json({ ok: true, db: 'up' });
|
||||
} catch (err) {
|
||||
console.error('[health] DB round-trip failed:', err)
|
||||
return c.json({ ok: false, db: 'down' }, 503)
|
||||
console.error('[health] DB round-trip failed:', err);
|
||||
return c.json({ ok: false, db: 'down' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+207
-215
@@ -17,23 +17,23 @@
|
||||
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware (or dev-bypass).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { lists, listShares, listItems, users } from '../db/schema.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { rankForAppend } from '../lib/rank.js'
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { lists, listShares, listItems, users } from '../db/schema.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
import { rankForAppend } from '../lib/rank.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
import { publishListEvent } from '../lib/listEmitter.js'
|
||||
import { notifyListChange } from '../lib/listChangeDispatcher.js'
|
||||
import { publishListEvent } from '../lib/listEmitter.js';
|
||||
import { notifyListChange } from '../lib/listChangeDispatcher.js';
|
||||
|
||||
export const listsRouter = new Hono()
|
||||
export const listsRouter = new Hono();
|
||||
|
||||
/**
|
||||
* listItemsRouter — single-item mutation routes.
|
||||
@@ -45,7 +45,7 @@ export const listsRouter = new Hono()
|
||||
* Separate from listsRouter (mounted at /api/lists) per the RESEARCH.md
|
||||
* architecture diagram.
|
||||
*/
|
||||
export const listItemsRouter = new Hono()
|
||||
export const listItemsRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — copied verbatim from events.ts per project convention.
|
||||
@@ -56,17 +56,17 @@ export const listItemsRouter = new Hono()
|
||||
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,7 +79,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const createListSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
isShared: z.boolean().default(true),
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch list — name and/or isShared; at least one field required (T-04-07).
|
||||
@@ -92,14 +92,14 @@ const patchListSchema = z
|
||||
.partial()
|
||||
.refine((obj) => Object.keys(obj).length >= 1, {
|
||||
message: 'PATCH must update at least one field (name or isShared)',
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Create item — text 1..500 (T-04-06 XSS: plain-text only, no HTML).
|
||||
*/
|
||||
const createItemSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-field PATCH for list items (D-08, T-04-07).
|
||||
@@ -115,7 +115,7 @@ const patchItemSchema = z
|
||||
.partial()
|
||||
.refine((obj) => Object.keys(obj).length === 1, {
|
||||
message: 'PATCH must update exactly one field (checked, text, or position)',
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: check list access (owner OR sharee)
|
||||
@@ -124,19 +124,18 @@ const patchItemSchema = z
|
||||
async function checkListAccess(
|
||||
listId: number,
|
||||
userId: number,
|
||||
): Promise<{ allowed: true; isOwner: boolean; listRow: typeof lists.$inferSelect } | { allowed: false; notFound: boolean }> {
|
||||
const [listRow] = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
): Promise<
|
||||
| { allowed: true; isOwner: boolean; listRow: typeof lists.$inferSelect }
|
||||
| { allowed: false; notFound: boolean }
|
||||
> {
|
||||
const [listRow] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);
|
||||
|
||||
if (!listRow) {
|
||||
return { allowed: false, notFound: true }
|
||||
return { allowed: false, notFound: true };
|
||||
}
|
||||
|
||||
if (listRow.ownerId === userId) {
|
||||
return { allowed: true, isOwner: true, listRow }
|
||||
return { allowed: true, isOwner: true, listRow };
|
||||
}
|
||||
|
||||
// Check list_shares
|
||||
@@ -144,13 +143,13 @@ async function checkListAccess(
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(and(eq(listShares.listId, listId), eq(listShares.userId, userId)))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (shareRow) {
|
||||
return { allowed: true, isOwner: false, listRow }
|
||||
return { allowed: true, isOwner: false, listRow };
|
||||
}
|
||||
|
||||
return { allowed: false, notFound: false }
|
||||
return { allowed: false, notFound: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,35 +161,29 @@ async function checkListAccess(
|
||||
// Security: T-04-02 — WHERE owner_id = caller OR id IN list_shares.userId = caller.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.get('/', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
// Collect all list IDs accessible to this user: owned + shared
|
||||
const owned = await db
|
||||
.select({ id: lists.id })
|
||||
.from(lists)
|
||||
.where(eq(lists.ownerId, currentUserId))
|
||||
.where(eq(lists.ownerId, currentUserId));
|
||||
|
||||
const shared = await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(eq(listShares.userId, currentUserId))
|
||||
.where(eq(listShares.userId, currentUserId));
|
||||
|
||||
const accessibleIds = [...new Set([
|
||||
...owned.map((r) => r.id),
|
||||
...shared.map((r) => r.listId),
|
||||
])]
|
||||
const accessibleIds = [...new Set([...owned.map((r) => r.id), ...shared.map((r) => r.listId)])];
|
||||
|
||||
if (accessibleIds.length === 0) {
|
||||
return c.json({ lists: [] })
|
||||
return c.json({ lists: [] });
|
||||
}
|
||||
|
||||
// Fetch list rows for accessible IDs
|
||||
const listRows = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(inArray(lists.id, accessibleIds))
|
||||
const listRows = await db.select().from(lists).where(inArray(lists.id, accessibleIds));
|
||||
|
||||
// Compute item counts per list
|
||||
const countRows = await db
|
||||
@@ -201,24 +194,24 @@ listsRouter.get('/', async (c) => {
|
||||
})
|
||||
.from(listItems)
|
||||
.where(inArray(listItems.listId, accessibleIds))
|
||||
.groupBy(listItems.listId, listItems.checked)
|
||||
.groupBy(listItems.listId, listItems.checked);
|
||||
|
||||
// Build counts map: { listId -> { active, done } }
|
||||
const countsMap = new Map<number, { active: number; done: number }>()
|
||||
const countsMap = new Map<number, { active: number; done: number }>();
|
||||
for (const row of countRows) {
|
||||
if (!countsMap.has(row.listId)) {
|
||||
countsMap.set(row.listId, { active: 0, done: 0 })
|
||||
countsMap.set(row.listId, { active: 0, done: 0 });
|
||||
}
|
||||
const entry = countsMap.get(row.listId)!
|
||||
const entry = countsMap.get(row.listId)!;
|
||||
if (row.checked) {
|
||||
entry.done += Number(row.count)
|
||||
entry.done += Number(row.count);
|
||||
} else {
|
||||
entry.active += Number(row.count)
|
||||
entry.active += Number(row.count);
|
||||
}
|
||||
}
|
||||
|
||||
const result = listRows.map((list) => {
|
||||
const counts = countsMap.get(list.id) ?? { active: 0, done: 0 }
|
||||
const counts = countsMap.get(list.id) ?? { active: 0, done: 0 };
|
||||
return {
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
@@ -228,15 +221,15 @@ listsRouter.get('/', async (c) => {
|
||||
doneCount: counts.done,
|
||||
createdAt: list.createdAt,
|
||||
updatedAt: list.updatedAt,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ lists: result })
|
||||
return c.json({ lists: result });
|
||||
} catch (err) {
|
||||
console.error('[lists/GET /] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/GET /] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/lists
|
||||
@@ -248,19 +241,19 @@ listsRouter.get('/', async (c) => {
|
||||
// Security: T-04-08 — shares are server-managed only; no client endpoint for shares.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const { name, isShared } = c.req.valid('json')
|
||||
const { name, isShared } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Insert the list
|
||||
const [inserted] = await db
|
||||
.insert(lists)
|
||||
.values({ ownerId: currentUserId, name, isShared })
|
||||
.$returningId()
|
||||
.$returningId();
|
||||
|
||||
const listId = inserted.id
|
||||
const listId = inserted.id;
|
||||
|
||||
// Auto-populate list_shares for all other members when isShared=true (D-01, D-02, OQ-3)
|
||||
if (isShared) {
|
||||
@@ -268,24 +261,22 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
const otherUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(sql`${users.id} != ${currentUserId}`)
|
||||
.where(sql`${users.id} != ${currentUserId}`);
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
await db.insert(listShares).values(
|
||||
otherUsers.map((u) => ({ listId, userId: u.id })),
|
||||
)
|
||||
await db.insert(listShares).values(otherUsers.map((u) => ({ listId, userId: u.id })));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch the newly created list to return canonical shape
|
||||
const [newList] = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
const [newList] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that this list was created/updated (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: newList.name } })
|
||||
publishListEvent(listId, {
|
||||
type: 'list:updated',
|
||||
listId,
|
||||
payload: { id: listId, name: newList.name },
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
@@ -299,12 +290,12 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
updatedAt: newList.updatedAt,
|
||||
},
|
||||
201,
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[lists/POST /] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/POST /] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/lists/:id
|
||||
@@ -318,35 +309,35 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
// T-04-07 — zod whitelists name/isShared only.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const patch = c.req.valid('json')
|
||||
const listId = Number(c.req.param('id'));
|
||||
const patch = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// T-04-08 / T-04-05: owner-only guard for isShared mutations.
|
||||
// A sharee may rename a list (patch.name) but must never mutate list_shares.
|
||||
if (patch.isShared !== undefined && !access.isOwner) {
|
||||
return c.json({ error: 'Only the list owner can change sharing settings' }, 403)
|
||||
return c.json({ error: 'Only the list owner can change sharing settings' }, 403);
|
||||
}
|
||||
|
||||
const prevIsShared = access.listRow.isShared
|
||||
const newIsShared = patch.isShared ?? prevIsShared
|
||||
const prevIsShared = access.listRow.isShared;
|
||||
const newIsShared = patch.isShared ?? prevIsShared;
|
||||
|
||||
// Apply field updates
|
||||
const updateValues: Partial<typeof lists.$inferInsert> = {}
|
||||
if (patch.name !== undefined) updateValues.name = patch.name
|
||||
if (patch.isShared !== undefined) updateValues.isShared = patch.isShared
|
||||
const updateValues: Partial<typeof lists.$inferInsert> = {};
|
||||
if (patch.name !== undefined) updateValues.name = patch.name;
|
||||
if (patch.isShared !== undefined) updateValues.isShared = patch.isShared;
|
||||
|
||||
await db.update(lists).set(updateValues).where(eq(lists.id, listId))
|
||||
await db.update(lists).set(updateValues).where(eq(lists.id, listId));
|
||||
|
||||
// Owner-only: reconcile list_shares on visibility change
|
||||
if (patch.isShared !== undefined && patch.isShared !== prevIsShared) {
|
||||
@@ -355,13 +346,13 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
const otherUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(sql`${users.id} != ${access.listRow.ownerId}`)
|
||||
.where(sql`${users.id} != ${access.listRow.ownerId}`);
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
// Use INSERT IGNORE semantics by catching duplicate key errors gracefully
|
||||
for (const u of otherUsers) {
|
||||
try {
|
||||
await db.insert(listShares).values({ listId, userId: u.id })
|
||||
await db.insert(listShares).values({ listId, userId: u.id });
|
||||
} catch {
|
||||
// Duplicate key — share already exists, skip
|
||||
}
|
||||
@@ -369,23 +360,21 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
}
|
||||
} else {
|
||||
// true → false: remove all non-owner shares
|
||||
await db
|
||||
.delete(listShares)
|
||||
.where(eq(listShares.listId, listId))
|
||||
await db.delete(listShares).where(eq(listShares.listId, listId));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch and return the updated list
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
const [updated] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that this list metadata changed (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: updated.name } })
|
||||
publishListEvent(listId, {
|
||||
type: 'list:updated',
|
||||
listId,
|
||||
payload: { id: listId, name: updated.name },
|
||||
});
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
notifyListChange(listId, currentUserId)
|
||||
notifyListChange(listId, currentUserId);
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
@@ -394,12 +383,12 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
ownerId: updated.ownerId,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[lists/PATCH /:id] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/PATCH /:id] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/lists/:id
|
||||
@@ -410,38 +399,38 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
// Security: T-04-05 — only the owner can delete; non-owner/non-sharee → 403.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.delete('/:id', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const listId = Number(c.req.param('id'));
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Owner-only delete (plan spec: "owner-only delete is the safe default")
|
||||
if (!access.isOwner) {
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(lists).where(eq(lists.id, listId))
|
||||
await db.delete(lists).where(eq(lists.id, listId));
|
||||
|
||||
// Fan-out: notify accessible subscribers that this list was deleted (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } })
|
||||
publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } });
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
// Note: list is already deleted from DB; notifyListChange handles missing list gracefully.
|
||||
notifyListChange(listId, currentUserId)
|
||||
notifyListChange(listId, currentUserId);
|
||||
|
||||
return c.json({ id: listId })
|
||||
return c.json({ id: listId });
|
||||
} catch (err) {
|
||||
console.error('[lists/DELETE /:id] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/DELETE /:id] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Item routes (LIST-02)
|
||||
@@ -465,17 +454,17 @@ listsRouter.delete('/:id', async (c) => {
|
||||
// Security: list access check before insert.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const { text } = c.req.valid('json')
|
||||
const listId = Number(c.req.param('id'));
|
||||
const { text } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Find the last active item's rank (unchecked, sorted DESC by rank, limit 1)
|
||||
@@ -484,25 +473,29 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
.from(listItems)
|
||||
.where(and(eq(listItems.listId, listId), eq(listItems.checked, false)))
|
||||
.orderBy(sql`${listItems.rank} DESC`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
const newRank = rankForAppend(lastActive?.rank ?? null)
|
||||
const newRank = rankForAppend(lastActive?.rank ?? null);
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(listItems)
|
||||
.values({ listId, text, rank: newRank })
|
||||
.$returningId()
|
||||
.$returningId();
|
||||
|
||||
const [newItem] = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, inserted.id))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that an item was added (LIST-04)
|
||||
publishListEvent(listId, { type: 'item:added', listId, payload: { id: newItem.id, listId, text: newItem.text } })
|
||||
publishListEvent(listId, {
|
||||
type: 'item:added',
|
||||
listId,
|
||||
payload: { id: newItem.id, listId, text: newItem.text },
|
||||
});
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
notifyListChange(listId, currentUserId)
|
||||
notifyListChange(listId, currentUserId);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
@@ -515,12 +508,12 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
updatedAt: newItem.updatedAt,
|
||||
},
|
||||
201,
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[lists/POST /:id/items] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/POST /:id/items] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/lists/:id/items
|
||||
@@ -529,23 +522,23 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
// Access-gated: owner OR sharee only (T-04-05).
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.get('/:id/items', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const listId = Number(c.req.param('id'));
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.listId, listId))
|
||||
.orderBy(asc(listItems.rank))
|
||||
.orderBy(asc(listItems.rank));
|
||||
|
||||
const result = items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -555,14 +548,14 @@ listsRouter.get('/:id/items', async (c) => {
|
||||
rank: item.rank,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}))
|
||||
}));
|
||||
|
||||
return c.json({ items: result })
|
||||
return c.json({ items: result });
|
||||
} catch (err) {
|
||||
console.error('[lists/GET /:id/items] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/GET /:id/items] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/list-items/:itemId
|
||||
@@ -577,79 +570,74 @@ listsRouter.get('/:id/items', async (c) => {
|
||||
// T-04-09 — if item row is missing (deleted), 404 (no upsert).
|
||||
// ---------------------------------------------------------------------------
|
||||
listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const itemId = Number(c.req.param('itemId'))
|
||||
const patch = c.req.valid('json')
|
||||
const itemId = Number(c.req.param('itemId'));
|
||||
const patch = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Fetch the item to get its listId for access verification
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
const [item] = await db.select().from(listItems).where(eq(listItems.id, itemId)).limit(1);
|
||||
|
||||
// T-04-09: if deleted, 404 (no resurrection)
|
||||
if (!item) return c.json({ error: 'Not found' }, 404)
|
||||
if (!item) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const access = await checkListAccess(item.listId, currentUserId)
|
||||
const access = await checkListAccess(item.listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Build the update payload — single-field write with updatedAt=NOW()
|
||||
const updateValues: {
|
||||
checked?: boolean
|
||||
text?: string
|
||||
rank?: string
|
||||
updatedAt?: Date
|
||||
} = {}
|
||||
checked?: boolean;
|
||||
text?: string;
|
||||
rank?: string;
|
||||
updatedAt?: Date;
|
||||
} = {};
|
||||
|
||||
if (patch.checked !== undefined) {
|
||||
updateValues.checked = patch.checked
|
||||
updateValues.checked = patch.checked;
|
||||
|
||||
// Open Question 2: uncheck → recompute rank to active-bottom
|
||||
if (patch.checked === false) {
|
||||
const [lastActive] = await db
|
||||
.select({ rank: listItems.rank })
|
||||
.from(listItems)
|
||||
.where(and(
|
||||
eq(listItems.listId, item.listId),
|
||||
eq(listItems.checked, false),
|
||||
sql`${listItems.id} != ${itemId}`,
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
eq(listItems.listId, item.listId),
|
||||
eq(listItems.checked, false),
|
||||
sql`${listItems.id} != ${itemId}`,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${listItems.rank} DESC`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
updateValues.rank = rankForAppend(lastActive?.rank ?? null)
|
||||
updateValues.rank = rankForAppend(lastActive?.rank ?? null);
|
||||
}
|
||||
} else if (patch.text !== undefined) {
|
||||
updateValues.text = patch.text
|
||||
updateValues.text = patch.text;
|
||||
} else if (patch.position !== undefined) {
|
||||
updateValues.rank = patch.position
|
||||
updateValues.rank = patch.position;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(listItems)
|
||||
.set(updateValues)
|
||||
.where(eq(listItems.id, itemId))
|
||||
await db.update(listItems).set(updateValues).where(eq(listItems.id, itemId));
|
||||
|
||||
// Re-fetch to return the updated row
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
const [updated] = await db.select().from(listItems).where(eq(listItems.id, itemId)).limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that an item was updated (LIST-04)
|
||||
publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: { id: updated.id, listId: updated.listId } })
|
||||
publishListEvent(item.listId, {
|
||||
type: 'item:updated',
|
||||
listId: item.listId,
|
||||
payload: { id: updated.id, listId: updated.listId },
|
||||
});
|
||||
// Push: coalesced list-change notification — only for meaningful changes (D-01).
|
||||
// Reorder (position) patches do NOT trigger a push; only checked/text changes do.
|
||||
if (patch.position === undefined) {
|
||||
notifyListChange(item.listId, currentUserId)
|
||||
notifyListChange(item.listId, currentUserId);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -660,12 +648,12 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
||||
rank: updated.rank,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/list-items/:itemId
|
||||
@@ -676,10 +664,10 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
||||
// Security: T-04-05 — list access check (owner OR sharee) before delete.
|
||||
// ---------------------------------------------------------------------------
|
||||
listItemsRouter.delete('/:itemId', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const itemId = Number(c.req.param('itemId'))
|
||||
const itemId = Number(c.req.param('itemId'));
|
||||
|
||||
try {
|
||||
// Fetch item to get listId for access check
|
||||
@@ -687,27 +675,31 @@ listItemsRouter.delete('/:itemId', async (c) => {
|
||||
.select({ id: listItems.id, listId: listItems.listId })
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!item) return c.json({ error: 'Not found' }, 404)
|
||||
if (!item) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const access = await checkListAccess(item.listId, currentUserId)
|
||||
const access = await checkListAccess(item.listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Delete-wins (D-09): delete is final; no rollback path.
|
||||
await db.delete(listItems).where(eq(listItems.id, itemId))
|
||||
await db.delete(listItems).where(eq(listItems.id, itemId));
|
||||
|
||||
// Fan-out: notify accessible subscribers that an item was deleted (LIST-04)
|
||||
publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } })
|
||||
publishListEvent(item.listId, {
|
||||
type: 'item:deleted',
|
||||
listId: item.listId,
|
||||
payload: { id: itemId },
|
||||
});
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
notifyListChange(item.listId, currentUserId)
|
||||
notifyListChange(item.listId, currentUserId);
|
||||
|
||||
return c.json({ id: itemId })
|
||||
return c.json({ id: itemId });
|
||||
} catch (err) {
|
||||
console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+16
-16
@@ -19,18 +19,18 @@
|
||||
* No credential or refresh-token data is included in the response (T-02-04).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { Hono } from 'hono';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const meRouter = new Hono()
|
||||
export const meRouter = new Hono();
|
||||
|
||||
meRouter.get('/', async (c) => {
|
||||
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
||||
// Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
|
||||
const devUser = c.get('user')
|
||||
const devUser = c.get('user');
|
||||
if (devUser) {
|
||||
return c.json({
|
||||
user: {
|
||||
@@ -38,30 +38,30 @@ meRouter.get('/', async (c) => {
|
||||
displayName: devUser.displayName,
|
||||
color: devUser.color,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Normal OIDC path: getAuth returns null only if the session is invalid.
|
||||
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
|
||||
// is reached, so null here indicates a genuine session error.
|
||||
const auth = await getAuth(c)
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10).
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
|
||||
// Derive the best available display name from OIDC claims (name →
|
||||
// preferred_username → email → sub fallback). Shared helper keeps every
|
||||
// upsert call site in agreement (see deriveDisplayName).
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const displayName = deriveDisplayName(auth);
|
||||
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Could not resolve user' }, 500)
|
||||
return c.json({ error: 'Could not resolve user' }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -70,5 +70,5 @@ meRouter.get('/', async (c) => {
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
+38
-38
@@ -12,19 +12,19 @@
|
||||
* Mounted under /api/push in index.ts.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { pushSubscriptions } from '../db/schema.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { pushSubscriptions } from '../db/schema.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const pushRouter = new Hono()
|
||||
export const pushRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — Duplicated per router (not extracted to shared module).
|
||||
@@ -35,17 +35,17 @@ export const pushRouter = new Hono()
|
||||
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,7 +63,7 @@ const subscribeSchema = z.object({
|
||||
p256dh: z.string().min(1).max(512),
|
||||
auth: z.string().min(1).max(256),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/push/vapid-public-key
|
||||
@@ -76,8 +76,8 @@ const subscribeSchema = z.object({
|
||||
// subscribe to push anyway).
|
||||
// ---------------------------------------------------------------------------
|
||||
pushRouter.get('/vapid-public-key', (c) => {
|
||||
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' })
|
||||
})
|
||||
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/push/subscription
|
||||
@@ -90,10 +90,10 @@ pushRouter.get('/vapid-public-key', (c) => {
|
||||
// T-05-10 — zod subscribeSchema validates all fields before DB write.
|
||||
// ---------------------------------------------------------------------------
|
||||
pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const body = c.req.valid('json')
|
||||
const body = c.req.valid('json');
|
||||
|
||||
try {
|
||||
await db
|
||||
@@ -110,17 +110,17 @@ pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c)
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ ok: true }, 201)
|
||||
return c.json({ ok: true }, 201);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[push/POST /subscription] DB operation failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/push/subscription
|
||||
@@ -129,17 +129,17 @@ pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c)
|
||||
// Scoped to caller only — cannot delete another member's subscriptions (T-05-13).
|
||||
// ---------------------------------------------------------------------------
|
||||
pushRouter.delete('/subscription', async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId))
|
||||
return c.json({ ok: true })
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId));
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[push/DELETE /subscription] DB operation failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+39
-39
@@ -11,34 +11,34 @@
|
||||
* RESEARCH Pattern 5 (heartbeat) + Finding 1 (lists scoped fan-out)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { streamSSE } from 'hono/streaming'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { subscribeListEvents } from '../lib/listEmitter.js'
|
||||
import { getAccessibleListIds } from '../lib/listAccess.js'
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
import { subscribeListEvents } from '../lib/listEmitter.js';
|
||||
import { getAccessibleListIds } from '../lib/listAccess.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const sseRouter = new Hono()
|
||||
export const sseRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — same pattern as lists.ts (per-router duplication convention).
|
||||
// Resolution order: dev-bypass user first, then OIDC.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,17 +48,17 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
*/
|
||||
sseRouter.get('/heartbeat', (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
let id = 0
|
||||
let id = 0;
|
||||
while (!stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ ts: new Date().toISOString(), id }),
|
||||
event: 'heartbeat',
|
||||
id: String(id++),
|
||||
})
|
||||
await stream.sleep(10_000)
|
||||
});
|
||||
await stream.sleep(10_000);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /lists
|
||||
@@ -83,13 +83,13 @@ sseRouter.get('/heartbeat', (c) => {
|
||||
* D-12: PWA refetchInterval: 30000 polling fallback always active.
|
||||
*/
|
||||
sseRouter.get('/lists', async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (userId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (userId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const accessibleListIds = await getAccessibleListIds(userId)
|
||||
const accessibleListIds = await getAccessibleListIds(userId);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribers: Array<() => void> = []
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
// Subscribe to each accessible list's channel (D-04 — scoped, not global)
|
||||
for (const listId of accessibleListIds) {
|
||||
@@ -97,31 +97,31 @@ sseRouter.get('/lists', async (c) => {
|
||||
// The handler signature is void-returning; wrap the async write in void+catch.
|
||||
// writeSSE errors are non-fatal — the SSE loop detects stream.aborted and cleans up.
|
||||
void (async () => {
|
||||
if (stream.aborted) return
|
||||
if (stream.aborted) return;
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: event.type,
|
||||
id: `${listId}-${Date.now()}`,
|
||||
})
|
||||
});
|
||||
})().catch((err: unknown) => {
|
||||
console.error('[sse] writeSSE error:', err)
|
||||
})
|
||||
})
|
||||
unsubscribers.push(unsub)
|
||||
console.error('[sse] writeSSE error:', err);
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
||||
// 30s heartbeat — keeps Pangolin connection alive (smoke-tested in Phase 1)
|
||||
let tick = 0
|
||||
let tick = 0;
|
||||
while (!stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ ts: new Date().toISOString() }),
|
||||
event: 'heartbeat',
|
||||
id: String(tick++),
|
||||
})
|
||||
await stream.sleep(30_000)
|
||||
});
|
||||
await stream.sleep(30_000);
|
||||
}
|
||||
|
||||
// Cleanup all subscriptions on client disconnect
|
||||
unsubscribers.forEach((unsub) => unsub())
|
||||
})
|
||||
})
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user