balloon-shop/src/lib/caldav.ts
chris cdaf79ac71 Security hardening, checkout reliability, onboarding tour, and UX fixes
Security:
- Replace raw password cookie with HMAC-derived session token + constant-time compare
- Add rate limiting (5 attempts / 15 min) to admin login
- Atomic JSON writes via file-utils to prevent corruption on crash
- Tighten CSP headers; add Square CDN to style-src and font-src
- WebP conversion + 20 MB limit on admin image uploads

Checkout reliability:
- Delayed capture flow: pre-auth → calendar write → capture (never charge without booking)
- Derive payment idempotency key from SHA-256(nonce) to prevent nonce/key mismatch on retry
- Idempotency key persisted in localStorage; auto-retry on network failure
- Idempotent CalDAV writes using orderId-based UIDs; treat 412 as success
- User-friendly Square error messages instead of raw API detail strings

UX:
- Welcome modal + 5-step guided tour with spotlight and scroll-into-view
- Balloon release agreement checkbox required before payment
- 24-hour lead time enforced server-side in both delivery and pickup slot generators
- Fix Square card form race condition with double-rAF before attach()
- Tour hides Bulma modal-background for bright, unobscured modal steps

Notifications:
- Improved SMTP error logging; re-throw on failure so callers see it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:27:33 -04:00

319 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createDAVClient } from 'tsdav'
import ICAL from 'ical.js'
import { addDays } from 'date-fns'
import type { DeliveryTier } from './delivery'
import { JOB_MINUTES } from './delivery'
export interface BusyBlock {
start: string // ISO 8601
end: string // ISO 8601
summary?: string
location?: string // address of the event, if set — used for travel-time chaining
}
// Cache both the client and the resolved calendar URL for the lifetime of the
// server process. createDAVClient() does CalDAV service discovery (multiple
// PROPFIND round-trips) on every call — caching it eliminates that overhead
// and is the main reason requests used to time out.
type DAVClientType = Awaited<ReturnType<typeof createDAVClient>>
let _cachedClient: DAVClientType | null = null
let _cachedCalendarUrl: string | null = null
async function getCalendarClient() {
if (!_cachedClient) {
_cachedClient = await createDAVClient({
serverUrl: process.env.CALDAV_URL!,
credentials: {
username: process.env.CALDAV_USERNAME!,
password: process.env.CALDAV_PASSWORD!,
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
})
}
if (!_cachedCalendarUrl) {
const calendars = await _cachedClient.fetchCalendars()
const targetCal =
calendars.find((c) => c.displayName === process.env.CALDAV_CALENDAR_NAME) ??
calendars[0]
if (!targetCal) throw new Error('No calendar found')
_cachedCalendarUrl = targetCal.url
}
return { client: _cachedClient, targetCal: { url: _cachedCalendarUrl } }
}
/** RFC 5545 §3.1 line folding: insert CRLF + space after every 73 chars */
function foldLine(s: string): string {
const out: string[] = []
while (s.length > 73) {
out.push(s.slice(0, 73))
s = ' ' + s.slice(73)
}
out.push(s)
return out.join('\r\n')
}
/** UTC timestamp for DTSTAMP — always ends with Z */
function toIcalDate(d: Date): string {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
}
/** Local time in America/New_York for DTSTART/DTEND — used with TZID property */
function toIcalDateET(d: Date): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d)
const p: Record<string, string> = {}
for (const { type, value } of parts) p[type] = value
const h = p.hour === '24' ? '00' : p.hour // Intl can emit '24' for midnight
return `${p.year}${p.month}${p.day}T${h}${p.minute}${p.second}`
}
export interface CalendarLineItem {
name: string
quantity: number
colors?: string[]
modifiers?: Array<{ name: string }>
note?: string
}
function formatPhone(raw: string): string {
const digits = raw.replace(/\D/g, '')
if (digits.length === 10) return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6)}`
if (digits.length === 11 && digits[0] === '1') return `${digits.slice(1, 4)}.${digits.slice(4, 7)}.${digits.slice(7)}`
return raw
}
function buildItemLines(lineItems: CalendarLineItem[]): string {
return lineItems.map((li) => {
const parts: string[] = [`${li.quantity} × ${li.name}`]
if (li.modifiers?.length) parts.push(` ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) parts.push(` Colors: ${li.colors.join(', ')}`)
if (li.note) parts.push(` Note: ${li.note}`)
return parts.join('\\n')
}).join('\\n')
}
export async function createDeliveryEvent(params: {
startTime: Date
tier: DeliveryTier
driveMinutes: number
address: string
lineItems: CalendarLineItem[]
colors: string[]
customerName: string
customerPhone: string
notes?: string
orderId: string
}): Promise<void> {
const { startTime, tier, driveMinutes, address, lineItems, colors, customerName, customerPhone, notes, orderId } = params
// startTime is the customer arrival time; the calendar block covers only the on-site window.
// Drive time is used for scheduling (slot availability) but not shown in the calendar event.
const endTime = new Date(startTime.getTime() + JOB_MINUTES[tier] * 60_000)
// UID is derived from the Square order ID so retries are idempotent — writing
// the same event twice is a no-op rather than creating a duplicate.
const uid = `delivery-${orderId}@beachpartyballoons.com`
const descParts = [
customerName,
formatPhone(customerPhone),
'',
buildItemLines(lineItems),
colors.length ? `Colors: ${colors.join(', ')}` : null,
notes ? `Notes: ${notes}` : null,
'',
`Order: ${orderId}`,
`Drive: ~${driveMinutes} min each way`,
].filter((p) => p !== null).join('\\n')
const ical = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//BeachPartyBalloons//Shop//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${toIcalDate(new Date())}`,
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
foldLine(`SUMMARY:${customerName}`),
foldLine(`LOCATION:${address}`),
foldLine(`DESCRIPTION:${descParts}`),
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
const { client, targetCal } = await getCalendarClient()
if (!targetCal) throw new Error('No calendar found')
try {
await client.createCalendarObject({
calendar: targetCal,
filename: `${uid}.ics`,
iCalString: ical,
})
} catch (err) {
// 412 Precondition Failed means the event already exists (same UID/filename).
// This is expected on retries — treat it as success.
const msg = String(err)
if (msg.includes('412') || msg.toLowerCase().includes('precondition')) return
throw err
}
}
export async function createPickupEvent(params: {
startTime: Date
lineItems: CalendarLineItem[]
colors: string[]
customerName: string
customerPhone: string
notes?: string
orderId: string
}): Promise<void> {
const { startTime, lineItems, colors, customerName, customerPhone, notes, orderId } = params
const endTime = new Date(startTime.getTime() + 15 * 60_000) // 15-min marker
const uid = `pickup-${orderId}@beachpartyballoons.com`
const descParts = [
customerName,
formatPhone(customerPhone),
'',
buildItemLines(lineItems),
colors.length ? `Colors: ${colors.join(', ')}` : null,
notes ? `Notes: ${notes}` : null,
'',
`Order: ${orderId}`,
].filter((p) => p !== null).join('\\n')
const ical = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//BeachPartyBalloons//Shop//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${toIcalDate(new Date())}`,
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
foldLine(`SUMMARY:${customerName}`),
'LOCATION:Beach Party Balloons\\, 554 Boston Post Rd\\, Milford CT',
foldLine(`DESCRIPTION:${descParts}`),
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
const { client, targetCal } = await getCalendarClient()
if (!targetCal) throw new Error('No calendar found')
try {
await client.createCalendarObject({
calendar: targetCal,
filename: `${uid}.ics`,
iCalString: ical,
})
} catch (err) {
const msg = String(err)
if (msg.includes('412') || msg.toLowerCase().includes('precondition')) return
throw err
}
}
/**
* Fetches busy/opaque calendar events from your Nextcloud CalDAV calendar
* for a given date range. Retries up to 3 times with exponential backoff.
*/
const CALDAV_ATTEMPT_TIMEOUT_MS = 3_000
export async function getBusyDates(
rangeStart: Date,
rangeEnd: Date
): Promise<BusyBlock[]> {
let lastErr: unknown
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await Promise.race([
_fetchBusyDates(rangeStart, rangeEnd),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('CalDAV timeout')), CALDAV_ATTEMPT_TIMEOUT_MS)
),
])
} catch (err) {
lastErr = err
if (attempt < 2) {
console.warn(`[caldav] getBusyDates attempt ${attempt + 1} failed, retrying...`, err)
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)))
}
}
}
throw lastErr
}
async function _fetchBusyDates(rangeStart: Date, rangeEnd: Date): Promise<BusyBlock[]> {
const { client, targetCal } = await getCalendarClient()
if (!targetCal) return []
const objects = await client.fetchCalendarObjects({
calendar: targetCal,
timeRange: {
start: rangeStart.toISOString(),
end: rangeEnd.toISOString(),
},
})
const busy: BusyBlock[] = []
for (const obj of objects) {
if (!obj.data) continue
const jcal = ICAL.parse(obj.data as string)
const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents('vevent')
for (const vevent of vevents) {
// Skip transparent (free) events
const transp = vevent.getFirstPropertyValue('transp')
if (transp === 'TRANSPARENT') continue
const ev = new ICAL.Event(vevent)
const location = (vevent.getFirstPropertyValue('location') as string ?? '').trim() || undefined
busy.push({
start: ev.startDate.toJSDate().toISOString(),
end: ev.endDate.toJSDate().toISOString(),
summary: ev.summary ?? undefined,
location,
})
}
}
return busy
}
/**
* Returns the set of date strings (YYYY-MM-DD) that are fully blocked.
*/
export async function getBlockedDateStrings(days = 60): Promise<Set<string>> {
const rangeStart = new Date()
const rangeEnd = addDays(rangeStart, days)
const busy = await getBusyDates(rangeStart, rangeEnd)
const blocked = new Set<string>()
for (const b of busy) {
const d = new Date(b.start)
blocked.add(d.toISOString().slice(0, 10))
}
return blocked
}