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> 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 = {} 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 { 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 { 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 { let lastErr: unknown for (let attempt = 0; attempt < 3; attempt++) { try { return await Promise.race([ _fetchBusyDates(rangeStart, rangeEnd), new Promise((_, 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 { 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> { const rangeStart = new Date() const rangeEnd = addDays(rangeStart, days) const busy = await getBusyDates(rangeStart, rangeEnd) const blocked = new Set() for (const b of busy) { const d = new Date(b.start) blocked.add(d.toISOString().slice(0, 10)) } return blocked }