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>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
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
|
||
}
|