From cf040a6801bce03d538fdd3c95f09ca3ef287068 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 30 Sep 2026 17:19:41 -0400 Subject: [PATCH 1/2] feat: email the owner on bookings, leads, tickets Only visitors were ever emailed, so a booked consult was missed. Bookings add calendar events, zone-labeled times, and a working cancel link; public form text is stripped of control characters. --- __tests__/ics.test.ts | 81 ++++++++ __tests__/owner-notification.test.ts | 67 ++++++ __tests__/text-validation.test.ts | 21 ++ __tests__/tz.test.ts | 28 ++- app/api/cron/reminders/route.ts | 4 +- lib/ics.ts | 94 +++++++++ lib/tz.ts | 45 ++++ lib/validations/text.ts | 15 ++ server/email/index.ts | 7 +- server/email/notify-owner.ts | 44 ++++ .../email/templates/appointment-reminder.ts | 14 +- server/email/templates/appointment.ts | 18 +- server/email/templates/owner-notification.ts | 44 ++++ server/scheduling/booking-emails.ts | 196 ++++++++++++++++++ server/trpc/routers/leads.ts | 31 ++- server/trpc/routers/scheduling.ts | 43 ++-- server/trpc/routers/tickets.ts | 24 ++- 17 files changed, 712 insertions(+), 64 deletions(-) create mode 100644 __tests__/ics.test.ts create mode 100644 __tests__/owner-notification.test.ts create mode 100644 __tests__/text-validation.test.ts create mode 100644 lib/ics.ts create mode 100644 lib/validations/text.ts create mode 100644 server/email/notify-owner.ts create mode 100644 server/email/templates/owner-notification.ts create mode 100644 server/scheduling/booking-emails.ts diff --git a/__tests__/ics.test.ts b/__tests__/ics.test.ts new file mode 100644 index 0000000..6c33672 --- /dev/null +++ b/__tests__/ics.test.ts @@ -0,0 +1,81 @@ +// __tests__/ics.test.ts +import { describe, it, expect } from "vitest"; +import { buildIcs, type IcsEvent } from "@/lib/ics"; + +const base: IcsEvent = { + uid: "appt123@akritos.com", + method: "PUBLISH", + start: new Date("2026-10-05T15:00:00Z"), + end: new Date("2026-10-05T15:30:00Z"), + stamp: new Date("2026-09-30T12:00:00Z"), + summary: "Free Consultation", + organizer: { name: "Akritos", email: "hello@akritos.com" }, +}; + +const unfold = (ics: string) => ics.replace(/\r\n /g, ""); +const lines = (ics: string) => unfold(ics).split("\r\n").filter(Boolean); + +describe("buildIcs", () => { + it("emits a CRLF-delimited VCALENDAR with UTC times", () => { + const ics = buildIcs(base); + expect(ics.endsWith("\r\n")).toBe(true); + expect(ics.replace(/\r\n/g, "")).not.toMatch(/\n/); + const l = lines(ics); + expect(l[0]).toBe("BEGIN:VCALENDAR"); + expect(l.at(-1)).toBe("END:VCALENDAR"); + expect(l).toContain("METHOD:PUBLISH"); + expect(l).toContain("SEQUENCE:0"); + expect(l).toContain("DTSTART:20261005T150000Z"); + expect(l).toContain("DTEND:20261005T153000Z"); + expect(l).toContain('ORGANIZER;CN="Akritos":mailto:hello@akritos.com'); + expect(l).toContain("STATUS:CONFIRMED"); + }); + + it("never lists attendees (RFC 5546 forbids them on PUBLISH)", () => { + expect(lines(buildIcs(base)).some((x) => x.startsWith("ATTENDEE"))).toBe(false); + }); + + it("marks a cancellation with a higher sequence", () => { + const l = lines(buildIcs({ ...base, method: "CANCEL" })); + expect(l).toContain("METHOD:CANCEL"); + expect(l).toContain("SEQUENCE:1"); + expect(l).toContain("STATUS:CANCELLED"); + }); + + it("escapes TEXT so visitor input cannot inject properties", () => { + const l = lines( + buildIcs({ + ...base, + summary: "Call; with, commas \\ and slashes", + description: "Notes line 1\r\nATTENDEE:mailto:evil@example.com\nEND:VEVENT", + }) + ); + expect(l).toContain("SUMMARY:Call\\; with\\, commas \\\\ and slashes"); + expect(l).toContain("DESCRIPTION:Notes line 1\\nATTENDEE:mailto:evil@example.com\\nEND:VEVENT"); + expect(l.some((x) => x.startsWith("ATTENDEE"))).toBe(false); + expect(l.filter((x) => x === "END:VEVENT").length).toBe(1); + }); + + it("drops control characters strict parsers reject, keeping tabs", () => { + const l = lines(buildIcs({ ...base, summary: "Pat\x00\x0b\x1b\x7f\tClient" })); + expect(l).toContain("SUMMARY:Pat\tClient"); + }); + + it("strips quotes and line breaks from the organizer", () => { + const l = lines( + buildIcs({ ...base, organizer: { name: 'Ak "x"\r\nX-EVIL:1', email: "hello@akritos.com\r\n;SENT-BY=x" } }) + ); + expect(l.filter((x) => x.startsWith("ORGANIZER"))).toEqual(['ORGANIZER;CN="Ak xX-EVIL:1":mailto:hello@akritos.comSENT-BY=x']); + expect(l.some((x) => x.startsWith("X-EVIL"))).toBe(false); + }); + + it("folds long lines at 75 octets without splitting multi-byte characters", () => { + const description = "Café ☕ résumé — ".repeat(20) + "終わり"; + const ics = buildIcs({ ...base, description }); + const encoder = new TextEncoder(); + for (const physical of ics.split("\r\n")) { + expect(encoder.encode(physical).length).toBeLessThanOrEqual(75); + } + expect(lines(ics)).toContain(`DESCRIPTION:${description}`); + }); +}); diff --git a/__tests__/owner-notification.test.ts b/__tests__/owner-notification.test.ts new file mode 100644 index 0000000..b0b5c4b --- /dev/null +++ b/__tests__/owner-notification.test.ts @@ -0,0 +1,67 @@ +// __tests__/owner-notification.test.ts +import { describe, it, expect } from "vitest"; +import { ownerNotificationHtml, ownerNotificationText } from "@/server/email/templates/owner-notification"; +import { appointmentConfirmationHtml, appointmentConfirmationText } from "@/server/email/templates/appointment"; +import { appointmentReminderText } from "@/server/email/templates/appointment-reminder"; + +const notice = { + heading: "New booking", + rows: [ + ["Name", ` "Pat"`], + ["Phone", null], + ["Notes", "line one\nline two"], + ] as Array<[string, string | null]>, + link: { href: "https://akritos.com/dashboard/scheduling", label: "Open scheduling" }, +}; + +describe("ownerNotificationHtml", () => { + it("escapes visitor-supplied values", () => { + const html = ownerNotificationHtml(notice); + expect(html).not.toContain(" { + const html = ownerNotificationHtml(notice); + expect(html).toContain("line one
line two"); + expect(html).not.toContain("Phone"); + }); + + it("renders a plain-text version with the link", () => { + const text = ownerNotificationText(notice); + expect(text).toContain("Notes: line one\nline two"); + expect(text).not.toContain("Phone"); + expect(text).toContain("Open scheduling: https://akritos.com/dashboard/scheduling"); + }); +}); + +describe("appointment confirmation", () => { + const data = { + serviceName: "Free Consultation", + bookerName: "Pat", + startsAt: new Date("2026-10-05T15:00:00Z"), + duration: 30, + timezone: "America/Los_Angeles", + cancelUrl: "https://akritos.com/portal/appointments/cancel?token=abc", + companyName: "Akritos", + }; + + it("shows date and time in the booker's zone, labeled", () => { + const html = appointmentConfirmationHtml(data); + expect(html).toContain("Mon, Oct 5, 2026"); + expect(html).toContain("8:00 AM PDT"); + expect(appointmentConfirmationText(data)).toContain("Time: 8:00 AM PDT"); + }); + + it("uses the date in the booker's zone, not the server's", () => { + // 11:30 PM Sunday in LA is already Monday in UTC. + const late = { ...data, startsAt: new Date("2026-10-05T06:30:00Z") }; + expect(appointmentConfirmationText(late)).toContain("Date: Sun, Oct 4, 2026"); + }); + + it("formats the reminder the same way", () => { + const text = appointmentReminderText({ ...data, startsAt: new Date("2026-10-05T06:30:00Z") }); + expect(text).toContain("Date: Sun, Oct 4, 2026"); + expect(text).toContain("Time: 11:30 PM PDT"); + }); +}); diff --git a/__tests__/text-validation.test.ts b/__tests__/text-validation.test.ts new file mode 100644 index 0000000..cc2dfef --- /dev/null +++ b/__tests__/text-validation.test.ts @@ -0,0 +1,21 @@ +// __tests__/text-validation.test.ts +import { describe, it, expect } from "vitest"; +import { lineText, multilineText } from "@/lib/validations/text"; + +describe("lineText", () => { + it("drops every control character, including line breaks", () => { + expect(lineText(255).parse("Pat\x00\x1b\r\nBcc: x\x7f")).toBe("PatBcc: x"); + }); + + it("applies limits after stripping", () => { + expect(lineText(255, 1).safeParse("\x00\x1b").success).toBe(false); + expect(lineText(3).parse("ab\x00c")).toBe("abc"); + expect(lineText(3).safeParse("abcd").success).toBe(false); + }); +}); + +describe("multilineText", () => { + it("keeps newlines and tabs, drops other controls", () => { + expect(multilineText(100).parse("line one\r\n\tline two\x00\x07")).toBe("line one\r\n\tline two"); + }); +}); diff --git a/__tests__/tz.test.ts b/__tests__/tz.test.ts index b78d9a5..4978ffc 100644 --- a/__tests__/tz.test.ts +++ b/__tests__/tz.test.ts @@ -1,6 +1,6 @@ // __tests__/tz.test.ts import { describe, it, expect } from "vitest"; -import { weekdayInTz } from "@/lib/tz"; +import { weekdayInTz, isValidTimeZone, formatInTz } from "@/lib/tz"; describe("weekdayInTz", () => { it("returns the weekday in the target timezone, not UTC", () => { @@ -18,3 +18,29 @@ describe("weekdayInTz", () => { expect(weekdayInTz(noonEt, "UTC")).toBe(1); }); }); + +describe("isValidTimeZone", () => { + it("accepts IANA zones", () => { + expect(isValidTimeZone("America/New_York")).toBe(true); + expect(isValidTimeZone("Pacific/Honolulu")).toBe(true); + expect(isValidTimeZone("UTC")).toBe(true); + }); + + it("rejects values Intl would throw on at email time", () => { + expect(isValidTimeZone("Not/AZone")).toBe(false); + expect(isValidTimeZone("")).toBe(false); + expect(isValidTimeZone("America/New_York\r\nBcc: x@y.z")).toBe(false); + }); +}); + +describe("formatInTz", () => { + it("formats in the target zone with a zone label", () => { + const threePmEdt = new Date("2026-10-05T19:00:00Z"); + expect(formatInTz(threePmEdt, "America/New_York")).toBe("Mon, Oct 5, 2026, 3:00 PM EDT"); + expect(formatInTz(threePmEdt, "America/Los_Angeles")).toBe("Mon, Oct 5, 2026, 12:00 PM PDT"); + }); + + it("switches label across DST", () => { + expect(formatInTz(new Date("2026-12-07T20:00:00Z"), "America/New_York")).toBe("Mon, Dec 7, 2026, 3:00 PM EST"); + }); +}); diff --git a/app/api/cron/reminders/route.ts b/app/api/cron/reminders/route.ts index ec2a4a1..9da9468 100644 --- a/app/api/cron/reminders/route.ts +++ b/app/api/cron/reminders/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/server/db"; import { sendEmail } from "@/server/email"; +import { appointmentCancelUrl } from "@/server/scheduling/booking-emails"; import { appointmentReminderHtml, appointmentReminderText, @@ -37,12 +38,11 @@ export async function GET(request: NextRequest) { const companyName = (await db.setting.findUnique({ where: { key: "company_name" } }))?.value ?? "Akritos"; - const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; let sent = 0; for (const appt of appointments) { - const cancelUrl = `${baseUrl}/book/cancel?token=${appt.cancelToken}`; + const cancelUrl = appointmentCancelUrl(appt.cancelToken); try { await sendEmail({ diff --git a/lib/ics.ts b/lib/ics.ts new file mode 100644 index 0000000..900b847 --- /dev/null +++ b/lib/ics.ts @@ -0,0 +1,94 @@ +// lib/ics.ts + +export interface IcsOrganizer { + name?: string; + email: string; +} + +// PUBLISH is a plain "add this event": no RSVP buttons, so nobody mistakes +// declining for cancelling, and no ATTENDEE (RFC 5546 forbids it here), so a +// CalDAV calendar can't re-invite the booker on the owner's behalf. CANCEL +// with the same UID removes it. +export interface IcsEvent { + uid: string; + method: "PUBLISH" | "CANCEL"; + start: Date; + end: Date; + stamp: Date; + summary: string; + description?: string; + organizer?: IcsOrganizer; +} + +// RFC 5545 TEXT escaping. Summary and description carry visitor input: an +// unescaped newline would inject properties, and other control characters +// make strict parsers (Outlook) drop the whole event. +function text(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r\n|\r|\n/g, "\\n") + .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); +} + +// Parameter values can't contain DQUOTE or control characters. +function param(value: string): string { + return value.replace(/["\x00-\x1f\x7f]/g, ""); +} + +function mailto(email: string): string { + return email.replace(/[\s"<>;:,\\]/g, ""); +} + +function utc(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); +} + +// Content lines are capped at 75 octets. Fold on code points so a multi-byte +// character is never split across lines. +function fold(line: string): string { + const encoder = new TextEncoder(); + const out: string[] = []; + let current = ""; + let bytes = 0; + for (const ch of line) { + const size = encoder.encode(ch).length; + const limit = out.length === 0 ? 75 : 74; // continuation lines start with a space + if (bytes + size > limit) { + out.push(current); + current = ""; + bytes = 0; + } + current += ch; + bytes += size; + } + out.push(current); + return out.join("\r\n "); +} + +export function buildIcs(e: IcsEvent): string { + const cancel = e.method === "CANCEL"; + const lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Akritos//Scheduling//EN", + "CALSCALE:GREGORIAN", + `METHOD:${e.method}`, + "BEGIN:VEVENT", + `UID:${text(e.uid)}`, + `SEQUENCE:${cancel ? 1 : 0}`, + `DTSTAMP:${utc(e.stamp)}`, + `DTSTART:${utc(e.start)}`, + `DTEND:${utc(e.end)}`, + `SUMMARY:${text(e.summary)}`, + ...(e.description ? [`DESCRIPTION:${text(e.description)}`] : []), + ...(e.organizer + ? [`ORGANIZER${e.organizer.name ? `;CN="${param(e.organizer.name)}"` : ""}:mailto:${mailto(e.organizer.email)}`] + : []), + `STATUS:${cancel ? "CANCELLED" : "CONFIRMED"}`, + "END:VEVENT", + "END:VCALENDAR", + ]; + return lines.map(fold).join("\r\n") + "\r\n"; +} diff --git a/lib/tz.ts b/lib/tz.ts index f7ad210..1509e6d 100644 --- a/lib/tz.ts +++ b/lib/tz.ts @@ -12,3 +12,48 @@ export function weekdayInTz(date: Date, tz: string): number { const short = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }).format(date); return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(short); } + +// Browser-supplied zone names reach Intl at email time; an invalid one throws +// there, after the booking is already saved. +export function isValidTimeZone(tz: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +export function formatDayInTz(date: Date, tz: string): string { + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + timeZone: tz, + }); +} + +// The zone label ("EDT") keeps a reader in another timezone from arriving an +// hour off. +export function formatTimeInTz(date: Date, tz: string): string { + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: tz, + timeZoneName: "short", + }); +} + +export function formatInTz(date: Date, tz: string): string { + return new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: tz, + timeZoneName: "short", + }).format(date); +} diff --git a/lib/validations/text.ts b/lib/validations/text.ts new file mode 100644 index 0000000..2dd1e11 --- /dev/null +++ b/lib/validations/text.ts @@ -0,0 +1,15 @@ +// lib/validations/text.ts +import { z } from "zod"; + +// Visitor-typed text from public forms. Control characters are dropped at the +// boundary: Postgres rejects NUL (a 500 instead of a validation error), and the +// rest end up in email subjects and calendar files. Limits apply after +// stripping, so a value of only control characters still fails min(). +const ANY_CONTROL = /[\x00-\x1f\x7f]/g; +const CONTROL_EXCEPT_NEWLINES_TABS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; + +export const lineText = (max: number, min = 0) => + z.string().transform((s) => s.replace(ANY_CONTROL, "")).pipe(z.string().min(min).max(max)); + +export const multilineText = (max: number, min = 0) => + z.string().transform((s) => s.replace(CONTROL_EXCEPT_NEWLINES_TABS, "")).pipe(z.string().min(min).max(max)); diff --git a/server/email/index.ts b/server/email/index.ts index b642ffe..f8a421e 100644 --- a/server/email/index.ts +++ b/server/email/index.ts @@ -103,12 +103,17 @@ export interface EmailPayload { html: string; text?: string; replyTo?: string; + // Server-built iCalendar body (lib/ics.ts); nodemailer sends it as a + // text/calendar part so mail clients offer or auto-add the event. + icalEvent?: { method: "PUBLISH" | "CANCEL"; content: string }; } export async function sendEmail(payload: EmailPayload): Promise { const { transport, from } = await getTransport(); try { - await transport.sendMail({ from, ...payload }); + // Subjects carry visitor input (names, ticket subjects); a line break there + // must never become a new header, and other controls render as garbage. + await transport.sendMail({ from, ...payload, subject: payload.subject.replace(/[\x00-\x1f\x7f]+/g, " ") }); } catch (err) { // Email failures upstream are usually swallowed by callers (.catch(()=>{}) // for fire-and-forget). Log here so the failure shows up in container logs diff --git a/server/email/notify-owner.ts b/server/email/notify-owner.ts new file mode 100644 index 0000000..b4463b9 --- /dev/null +++ b/server/email/notify-owner.ts @@ -0,0 +1,44 @@ +// server/email/notify-owner.ts +import "server-only"; +import { db } from "@/server/db"; +import { sendEmail, type EmailPayload } from "@/server/email"; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +let warnedFallback = false; + +// Settings → company_email, if it's a usable address. It's the business's +// public contact, so it's the only owner address shown to visitors. +export async function companyEmail(): Promise { + const value = (await db.setting.findUnique({ where: { key: "company_email" } }))?.value.trim(); + return value && EMAIL.test(value) ? value : undefined; +} + +// The business inbox, else every active admin (oldest first, so the order is +// stable). The fallback is logged: admin login addresses are often unwatched. +export async function ownerRecipients(): Promise { + const company = await companyEmail(); + if (company) return [company]; + + const admins = await db.user.findMany({ + where: { role: "ADMIN", isActive: true }, + select: { email: true }, + orderBy: { createdAt: "asc" }, + }); + const emails = admins.map((a) => a.email); + if (!warnedFallback) { + warnedFallback = true; + console.warn(`[email] company_email unset or invalid; owner notifications go to admin logins: ${emails.join(", ") || "none"}`); + } + return emails; +} + +// Bookings, leads, and tickets used to land silently in the dashboard; a +// missing recipient is logged loudly rather than dropped. +export async function notifyOwner(payload: Omit, recipients?: string[]): Promise { + const to = recipients ?? (await ownerRecipients()); + if (to.length === 0) { + console.error(`[email] no owner recipient (set company_email in Settings): subject="${payload.subject}"`); + return; + } + await sendEmail({ ...payload, to }); +} diff --git a/server/email/templates/appointment-reminder.ts b/server/email/templates/appointment-reminder.ts index a8ab6c5..1e6b2bc 100644 --- a/server/email/templates/appointment-reminder.ts +++ b/server/email/templates/appointment-reminder.ts @@ -1,6 +1,6 @@ // server/email/templates/appointment-reminder.ts -import { formatDate } from "@/lib/utils"; import { escapeHtml } from "@/server/email/escape"; +import { formatDayInTz as formatDay, formatTimeInTz as formatTime } from "@/lib/tz"; export interface AppointmentReminderData { serviceName: string; @@ -11,14 +11,6 @@ export interface AppointmentReminderData { companyName: string; } -function formatTime(date: Date, tz: string): string { - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - timeZone: tz, - }); -} - export function appointmentReminderHtml(data: AppointmentReminderData): string { const { startsAt, timezone, cancelUrl } = data; const serviceName = escapeHtml(data.serviceName); @@ -55,7 +47,7 @@ export function appointmentReminderHtml(data: AppointmentReminderData): string { Date - ${formatDate(startsAt)} + ${formatDay(startsAt, timezone)} @@ -97,7 +89,7 @@ export function appointmentReminderText(data: AppointmentReminderData): string { "", `Reminder: your appointment for ${serviceName} is coming up tomorrow.`, "", - `Date: ${formatDate(startsAt)}`, + `Date: ${formatDay(startsAt, timezone)}`, `Time: ${formatTime(startsAt, timezone)}`, "", `Need to cancel? ${cancelUrl}`, diff --git a/server/email/templates/appointment.ts b/server/email/templates/appointment.ts index ac814d7..559ce53 100644 --- a/server/email/templates/appointment.ts +++ b/server/email/templates/appointment.ts @@ -1,6 +1,6 @@ // server/email/templates/appointment.ts -import { formatDate } from "@/lib/utils"; import { escapeHtml } from "@/server/email/escape"; +import { formatDayInTz as formatDay, formatTimeInTz as formatTime } from "@/lib/tz"; export interface AppointmentConfirmationData { serviceName: string; @@ -13,14 +13,6 @@ export interface AppointmentConfirmationData { notes?: string | null; } -function formatTime(date: Date, tz: string): string { - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - timeZone: tz, - }); -} - export function appointmentConfirmationHtml(data: AppointmentConfirmationData): string { const { startsAt, duration, timezone, cancelUrl, notes } = data; const serviceName = escapeHtml(data.serviceName); @@ -65,7 +57,7 @@ export function appointmentConfirmationHtml(data: AppointmentConfirmationData): Date - ${formatDate(startsAt)} + ${formatDay(startsAt, timezone)} @@ -118,7 +110,7 @@ export function appointmentConfirmationText(data: AppointmentConfirmationData): `Your appointment has been confirmed.`, "", `Service: ${serviceName}`, - `Date: ${formatDate(startsAt)}`, + `Date: ${formatDay(startsAt, timezone)}`, `Time: ${formatTime(startsAt, timezone)}`, `Duration: ${duration} minutes`, "", @@ -162,7 +154,7 @@ export function appointmentCancellationHtml(data: AppointmentCancellationData):

Hi ${bookerName},

- Your appointment for ${serviceName} on ${formatDate(startsAt)} at ${formatTime(startsAt, timezone)} has been cancelled. + Your appointment for ${serviceName} on ${formatDay(startsAt, timezone)} at ${formatTime(startsAt, timezone)} has been cancelled.

@@ -199,7 +191,7 @@ export function appointmentCancellationText(data: AppointmentCancellationData): "", `Hi ${bookerName},`, "", - `Your appointment for ${serviceName} on ${formatDate(startsAt)} at ${formatTime(startsAt, timezone)} has been cancelled.`, + `Your appointment for ${serviceName} on ${formatDay(startsAt, timezone)} at ${formatTime(startsAt, timezone)} has been cancelled.`, "", `Book a new appointment: ${bookUrl}`, ].join("\n"); diff --git a/server/email/templates/owner-notification.ts b/server/email/templates/owner-notification.ts new file mode 100644 index 0000000..635e86a --- /dev/null +++ b/server/email/templates/owner-notification.ts @@ -0,0 +1,44 @@ +// server/email/templates/owner-notification.ts +import { escapeHtml } from "@/server/email/escape"; + +export interface OwnerNotification { + heading: string; + rows: Array<[label: string, value: string | null | undefined]>; + link?: { href: string; label: string }; +} + +const present = (rows: OwnerNotification["rows"]) => + rows.filter((r): r is [string, string] => Boolean(r[1])); + +export function ownerNotificationHtml(n: OwnerNotification): string { + const rows = present(n.rows) + .map( + ([label, value]) => ` + + + ` + ) + .join(""); + const link = n.link + ? `

${escapeHtml(n.link.label)}

` + : ""; + + return ` + +${escapeHtml(n.heading)} + +

${escapeHtml(n.heading)}

+
${escapeHtml(label)}${escapeHtml(value).replace(/\r?\n/g, "
")}
${rows}
+ ${link} + +`; +} + +export function ownerNotificationText(n: OwnerNotification): string { + return [ + n.heading, + "", + ...present(n.rows).map(([label, value]) => `${label}: ${value}`), + ...(n.link ? ["", `${n.link.label}: ${n.link.href}`] : []), + ].join("\n"); +} diff --git a/server/scheduling/booking-emails.ts b/server/scheduling/booking-emails.ts new file mode 100644 index 0000000..f66887b --- /dev/null +++ b/server/scheduling/booking-emails.ts @@ -0,0 +1,196 @@ +// server/scheduling/booking-emails.ts +import "server-only"; +import type { Appointment, Service } from "@prisma/client"; +import { db } from "@/server/db"; +import { sendEmail } from "@/server/email"; +import { companyEmail, notifyOwner, ownerRecipients } from "@/server/email/notify-owner"; +import { + appointmentConfirmationHtml, + appointmentConfirmationText, + appointmentCancellationHtml, + appointmentCancellationText, +} from "@/server/email/templates/appointment"; +import { ownerNotificationHtml, ownerNotificationText, type OwnerNotification } from "@/server/email/templates/owner-notification"; +import { buildIcs, type IcsEvent, type IcsOrganizer } from "@/lib/ics"; +import { formatInTz } from "@/lib/tz"; + +const baseUrl = () => process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + +// Shared by the confirmation and the reminder so the link can't drift again +// (it once pointed at a route that didn't exist). +export function appointmentCancelUrl(cancelToken: string): string { + return `${baseUrl()}/portal/appointments/cancel?token=${cancelToken}`; +} + +interface Context { + company: string; + owners: string[]; + // Only the public company address is ever shown to a booker; without it the + // booker gets no calendar event rather than an admin's login address. + bookerOrganizer?: IcsOrganizer; + ownerOrganizer?: IcsOrganizer; + businessTz: string; +} + +async function loadContext(serviceId: string): Promise { + const [companySetting, publicEmail, owners, availability] = await Promise.all([ + db.setting.findUnique({ where: { key: "company_name" } }), + companyEmail(), + ownerRecipients(), + db.staffAvailability.findFirst({ + where: { OR: [{ serviceId }, { serviceId: null }] }, + select: { timezone: true }, + }), + ]); + const company = companySetting?.value ?? "Akritos"; + const ownerAddress = publicEmail ?? owners[0]; + return { + company, + owners, + bookerOrganizer: publicEmail ? { name: company, email: publicEmail } : undefined, + ownerOrganizer: ownerAddress ? { name: company, email: ownerAddress } : undefined, + businessTz: availability?.timezone || "America/New_York", + }; +} + +// One UID per appointment so the later CANCEL removes the same calendar entry. +function calendar(appointment: Appointment, fields: Pick) { + return { + method: fields.method, + content: buildIcs({ + uid: `${appointment.id}@${new URL(baseUrl()).host}`, + start: appointment.startsAt, + end: appointment.endsAt, + stamp: new Date(), + ...fields, + }), + }; +} + +function ownerMessage(n: OwnerNotification) { + return { html: ownerNotificationHtml(n), text: ownerNotificationText(n) }; +} + +function bookerRows(appointment: Appointment, service: Service, ctx: Context): OwnerNotification["rows"] { + return [ + ["When", formatInTz(appointment.startsAt, ctx.businessTz)], + ["Service", `${service.name} (${service.duration} min)`], + ["Name", appointment.bookerName], + ["Email", appointment.bookerEmail], + ["Phone", appointment.bookerPhone], + ["Notes", appointment.notes], + ["Booker's timezone", appointment.timezone === ctx.businessTz ? null : appointment.timezone], + ]; +} + +// Each email is isolated: a failure — including a synchronous throw while +// building it — is logged with the appointment id and never blocks the other. +async function settle(appointmentId: string, sends: Record Promise>): Promise { + const labels = Object.keys(sends); + const results = await Promise.allSettled(labels.map(async (label) => sends[label]!())); + results.forEach((r, i) => { + if (r.status === "rejected") { + const message = r.reason instanceof Error ? r.reason.message : String(r.reason); + console.error(`[booking] ${labels[i]} email failed for appointment ${appointmentId}: ${message}`); + } + }); +} + +export async function sendBookingEmails(appointment: Appointment, service: Service): Promise { + const ctx = await loadContext(service.id); + const cancelUrl = appointmentCancelUrl(appointment.cancelToken); + const data = { + serviceName: service.name, + bookerName: appointment.bookerName, + startsAt: appointment.startsAt, + duration: service.duration, + timezone: appointment.timezone, + cancelUrl, + companyName: ctx.company, + notes: appointment.notes, + }; + + await settle(appointment.id, { + booker: () => + sendEmail({ + to: appointment.bookerEmail, + subject: `Appointment confirmed — ${service.name}`, + html: appointmentConfirmationHtml(data), + text: appointmentConfirmationText(data), + ...(ctx.bookerOrganizer && { + icalEvent: calendar(appointment, { + method: "PUBLISH", + summary: `${service.name} with ${ctx.company}`, + description: `To cancel, use this link (deleting the calendar event doesn't cancel): ${cancelUrl}`, + organizer: ctx.bookerOrganizer, + }), + }), + }), + owner: () => + notifyOwner( + { + subject: `New booking: ${service.name} — ${appointment.bookerName}, ${formatInTz(appointment.startsAt, ctx.businessTz)}`, + replyTo: appointment.bookerEmail, + ...ownerMessage({ + heading: "New booking", + rows: bookerRows(appointment, service, ctx), + link: { href: `${baseUrl()}/dashboard/scheduling`, label: "Open scheduling" }, + }), + icalEvent: calendar(appointment, { + method: "PUBLISH", + summary: `${service.name} — ${appointment.bookerName}`, + description: [appointment.bookerEmail, appointment.bookerPhone, appointment.notes].filter(Boolean).join("\n"), + organizer: ctx.ownerOrganizer, + }), + }, + ctx.owners + ), + }); +} + +export async function sendCancellationEmails(appointment: Appointment, service: Service): Promise { + const ctx = await loadContext(service.id); + const data = { + serviceName: service.name, + bookerName: appointment.bookerName, + startsAt: appointment.startsAt, + timezone: appointment.timezone, + bookUrl: `${baseUrl()}/book`, + companyName: ctx.company, + }; + + await settle(appointment.id, { + booker: () => + sendEmail({ + to: appointment.bookerEmail, + subject: `Appointment cancelled — ${service.name}`, + html: appointmentCancellationHtml(data), + text: appointmentCancellationText(data), + ...(ctx.bookerOrganizer && { + icalEvent: calendar(appointment, { + method: "CANCEL", + summary: `${service.name} with ${ctx.company}`, + organizer: ctx.bookerOrganizer, + }), + }), + }), + owner: () => + notifyOwner( + { + subject: `Cancelled: ${service.name} — ${appointment.bookerName}, ${formatInTz(appointment.startsAt, ctx.businessTz)}`, + replyTo: appointment.bookerEmail, + ...ownerMessage({ + heading: "Booking cancelled by the client", + rows: bookerRows(appointment, service, ctx), + link: { href: `${baseUrl()}/dashboard/scheduling`, label: "Open scheduling" }, + }), + icalEvent: calendar(appointment, { + method: "CANCEL", + summary: `${service.name} — ${appointment.bookerName}`, + organizer: ctx.ownerOrganizer, + }), + }, + ctx.owners + ), + }); +} diff --git a/server/trpc/routers/leads.ts b/server/trpc/routers/leads.ts index a58110c..4f6700a 100644 --- a/server/trpc/routers/leads.ts +++ b/server/trpc/routers/leads.ts @@ -8,6 +8,9 @@ import { createTRPCRouter, publicProcedure, adminProcedure } from "@/server/trpc import { rateLimit, getClientIp } from "@/server/rate-limit"; import { sendEmail } from "@/server/email"; import { escapeHtml } from "@/server/email/escape"; +import { lineText } from "@/lib/validations/text"; +import { notifyOwner } from "@/server/email/notify-owner"; +import { ownerNotificationHtml, ownerNotificationText } from "@/server/email/templates/owner-notification"; const SUBMIT_LIMIT = 3; const SUBMIT_WINDOW_MS = 60 * 60 * 1000; @@ -40,8 +43,8 @@ const DEFAULT_MAGNET = LEAD_MAGNETS["ownership-page"]!; const submitInput = z.object({ email: z.string().email().max(254), - name: z.string().min(1).max(80).optional(), - company: z.string().max(120).optional(), + name: lineText(80, 1).optional(), + company: lineText(120).optional(), source: z.string().min(1).max(80), // Honeypot — bots fill this, real users leave it blank website: z.string().max(0).optional().default(""), @@ -77,15 +80,17 @@ export const leadsRouter = createTRPCRouter({ // Upsert by email — same person can request the checklist multiple times, // we just want to know they came back. Don't dupe DB rows. + const leadId = `${input.source}:${email}`.slice(0, 191); + const returning = (await ctx.db.lead.count({ where: { id: leadId } })) > 0; const lead = await ctx.db.lead.upsert({ - where: { id: `${input.source}:${email}`.slice(0, 191) }, + where: { id: leadId }, update: { name: input.name?.trim(), company: input.company?.trim(), userAgent: ctx.headers.get("user-agent") ?? null, }, create: { - id: `${input.source}:${email}`.slice(0, 191), + id: leadId, email, name: input.name?.trim(), company: input.company?.trim(), @@ -123,6 +128,24 @@ export const leadsRouter = createTRPCRouter({ // Email failure is logged but doesn't break the user flow }); + const notice = { + heading: returning ? "Returning lead" : "New lead", + rows: [ + ["Email", email], + ["Name", input.name?.trim()], + ["Company", input.company?.trim()], + ["Downloaded", magnet.what], + ["Source", input.source], + ] as Array<[string, string | undefined]>, + link: { href: `${baseUrl}/dashboard/cms/leads`, label: "Open leads" }, + }; + void notifyOwner({ + subject: `${returning ? "Returning lead" : "New lead"}: ${email} — ${magnet.what}`, + replyTo: email, + html: ownerNotificationHtml(notice), + text: ownerNotificationText(notice), + }).catch((err) => console.error(`[lead] owner notification failed for ${lead.id}:`, err)); + return { ok: true, downloadUrl }; }), diff --git a/server/trpc/routers/scheduling.ts b/server/trpc/routers/scheduling.ts index 008fe87..4dad27d 100644 --- a/server/trpc/routers/scheduling.ts +++ b/server/trpc/routers/scheduling.ts @@ -3,12 +3,12 @@ import "server-only"; import { createTRPCRouter, protectedProcedure, staffProcedure, publicProcedure, adminProcedure } from "@/server/trpc/trpc"; import { rateLimit, getClientIp } from "@/server/rate-limit"; -import { sendEmail } from "@/server/email"; -import { appointmentConfirmationHtml, appointmentConfirmationText, appointmentCancellationHtml, appointmentCancellationText } from "@/server/email/templates/appointment"; +import { sendBookingEmails, sendCancellationEmails } from "@/server/scheduling/booking-emails"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { addMinutes, startOfDay, endOfDay } from "date-fns"; -import { weekdayInTz } from "@/lib/tz"; +import { isValidTimeZone, weekdayInTz } from "@/lib/tz"; +import { lineText, multilineText } from "@/lib/validations/text"; const appointmentStatusEnum = z.enum(["PENDING", "CONFIRMED", "CANCELLED", "NO_SHOW", "COMPLETED"]); @@ -212,11 +212,11 @@ export const schedulingRouter = createTRPCRouter({ serviceId: z.string().cuid(), staffId: z.string().cuid().optional(), startsAt: z.coerce.date(), - bookerName: z.string().min(1).max(255), + bookerName: lineText(255, 1), bookerEmail: z.string().email(), - bookerPhone: z.string().max(50).optional().nullable(), - notes: z.string().max(2000).optional().nullable(), - timezone: z.string(), + bookerPhone: lineText(50).optional().nullable(), + notes: multilineText(2000).optional().nullable(), + timezone: z.string().max(64).refine(isValidTimeZone, "Invalid timezone"), }) ) .mutation(async ({ ctx, input }) => { @@ -289,17 +289,10 @@ export const schedulingRouter = createTRPCRouter({ }); }); - // Send confirmation email. - const companyName = (await ctx.db.setting.findUnique({ where: { key: "company_name" } }))?.value ?? "Akritos"; - const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - const cancelUrl = `${baseUrl}/book/cancel?token=${appointment.cancelToken}`; - - sendEmail({ - to: input.bookerEmail, - subject: `Appointment confirmed — ${service.name}`, - html: appointmentConfirmationHtml({ serviceName: service.name, bookerName: input.bookerName, startsAt: appointment.startsAt, duration: service.duration, timezone: input.timezone, cancelUrl, companyName, notes: input.notes }), - text: appointmentConfirmationText({ serviceName: service.name, bookerName: input.bookerName, startsAt: appointment.startsAt, duration: service.duration, timezone: input.timezone, cancelUrl, companyName }), - }).catch(() => {}); + // The booking is saved; email trouble must never surface as a failed booking. + void sendBookingEmails(appointment, service).catch((err) => + console.error(`[booking] emails failed for appointment ${appointment.id}:`, err) + ); return { publicToken: appointment.publicToken, @@ -328,17 +321,9 @@ export const schedulingRouter = createTRPCRouter({ data: { status: "CANCELLED" }, }); - // Notify the booker of cancellation. - const companyName = (await ctx.db.setting.findUnique({ where: { key: "company_name" } }))?.value ?? "Akritos"; - const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - const bookUrl = `${baseUrl}/book`; - - sendEmail({ - to: appointment.bookerEmail, - subject: `Appointment cancelled — ${service.name}`, - html: appointmentCancellationHtml({ serviceName: service.name, bookerName: appointment.bookerName, startsAt: appointment.startsAt, timezone: appointment.timezone, bookUrl, companyName }), - text: appointmentCancellationText({ serviceName: service.name, bookerName: appointment.bookerName, startsAt: appointment.startsAt, timezone: appointment.timezone, bookUrl, companyName }), - }).catch(() => {}); + void sendCancellationEmails(appointment, service).catch((err) => + console.error(`[booking] cancellation emails failed for appointment ${appointment.id}:`, err) + ); return { ok: true }; }), diff --git a/server/trpc/routers/tickets.ts b/server/trpc/routers/tickets.ts index b061684..648aaf4 100644 --- a/server/trpc/routers/tickets.ts +++ b/server/trpc/routers/tickets.ts @@ -5,14 +5,17 @@ import { createTRPCRouter, protectedProcedure, staffProcedure, publicProcedure } import { rateLimit, getClientIp } from "@/server/rate-limit"; import { sendEmail } from "@/server/email"; import { ticketConfirmationHtml, ticketConfirmationText, ticketReplyHtml, ticketReplyText } from "@/server/email/templates/ticket"; +import { notifyOwner } from "@/server/email/notify-owner"; +import { lineText, multilineText } from "@/lib/validations/text"; +import { ownerNotificationHtml, ownerNotificationText } from "@/server/email/templates/owner-notification"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; const createTicketSchema = z.object({ - submitterName: z.string().min(1).max(255), + submitterName: lineText(255, 1), submitterEmail: z.string().email(), - subject: z.string().min(1).max(500), - body: z.string().min(1).max(10000), + subject: lineText(500, 1), + body: multilineText(10000, 1), clientId: z.string().cuid().optional(), }); @@ -64,6 +67,21 @@ export const ticketsRouter = createTRPCRouter({ text: ticketConfirmationText({ ticketNumber: ticket.ticketNumber, subject: input.subject, submitterName: input.submitterName, trackUrl, companyName }), }).catch(() => {}); + const notice = { + heading: `New support ticket ${ticket.ticketNumber}`, + rows: [ + ["Subject", input.subject], + ["From", `${input.submitterName} <${input.submitterEmail}>`], + ["Message", input.body], + ] as Array<[string, string]>, + link: { href: `${baseUrl}/dashboard/tickets/${ticket.id}`, label: "Open ticket" }, + }; + void notifyOwner({ + subject: `[${ticket.ticketNumber}] New ticket: ${input.subject}`, + html: ownerNotificationHtml(notice), + text: ownerNotificationText(notice), + }).catch((err) => console.error(`[ticket] owner notification failed for ${ticket.ticketNumber}:`, err)); + return { ticketNumber: ticket.ticketNumber, publicToken: ticket.publicToken }; }), From 57b2519061a0ba59892bb91cdcd35ed5952176e8 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 30 Sep 2026 17:19:41 -0400 Subject: [PATCH 2/2] docs: owner notifications pattern, lesson, decision --- CLAUDE.md | 3 ++- docs/DECISIONS.md | 10 ++++++++++ docs/LESSONS.md | 10 ++++++++++ docs/features/scheduling.md | 14 +++++++++----- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6bbcbaa..484eee2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,8 @@ This is a combined RMS (Resource Management System) backend + marketing site for - **Settings:** stored in DB as key/value (Setting model), fetched via `db.setting.findUnique` - **CMS blocks:** JSON array of { type, content, level, src, alt, ctaText, ctaHref } objects - **BlockRenderer:** uses bone/conviction color palette (not zinc) — headings text-bone, paragraphs text-bone/80, dividers border-bone/10, CTA bg-slate-brand/20 with conviction gold button -- **Email:** Nodemailer transport reads SMTP credentials from the Settings table (smtp_host / smtp_port / smtp_user / smtp_pass / email_from), set via `/dashboard/settings`. Falls back to env vars (`SMTP_*`, `EMAIL_FROM`) for dev (Mailpit at localhost:1025) or DB-unreachable cases. Transport is cached for 60s; rebuilds when Settings change. `sendEmail()` logs failures to stderr even when callers swallow with `.catch(() => {})`. `verifySmtp()` exists for "Test SMTP" admin tooling. +- **Email:** Nodemailer transport reads SMTP credentials from the Settings table (smtp_host / smtp_port / smtp_user / smtp_pass / email_from), set via `/dashboard/settings`. Falls back to env vars (`SMTP_*`, `EMAIL_FROM`) for dev (Mailpit at localhost:1025) or DB-unreachable cases. Transport is cached for 60s; rebuilds when Settings change. `sendEmail()` logs failures to stderr even when callers swallow with `.catch(() => {})`. `verifySmtp()` exists for "Test SMTP" admin tooling (not yet wired to a button). +- **Owner notifications:** `notifyOwner()` (`server/email/notify-owner.ts`) emails the business about every booking, client cancellation, lead, and support ticket. Recipient is Settings `company_email` (Dashboard → Settings → Company email), else every active ADMIN. Any new public form that captures a person must call it — otherwise submissions land silently in the dashboard. Set company_email to a mailbox *other than* the SMTP sending account: Gmail files mail an account sends to itself under Sent, not Inbox. Bookings also carry iCalendar events (`lib/ics.ts`): METHOD:PUBLISH to both sides, CANCEL to both on client cancellation, one UID per appointment. PUBLISH, not REQUEST, because declining a REQUEST looks like cancelling but isn't; no ATTENDEE, so a CalDAV calendar can't re-invite the booker. A booker only ever sees company_email as organizer — if it's unset they get no calendar event. Visitor input in subjects/ICS is sanitized centrally (`sendEmail` collapses CR/LF; `lib/ics.ts` escapes TEXT, strips control characters and params). - **Auth:** NextAuth credentials provider, bcrypt hashed passwords, JWT sessions - **Stripe:** Payment Links per invoice, webhook at /api/webhooks/stripe - **Timezone:** scheduling stores availability in local timezone, converts to UTC for slot calculation diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1751934..6acf913 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -210,3 +210,13 @@ The AI framework is the cleaner reference implementation of the pattern. **Why:** No live page renders an optimized image (a post-deploy sweep found zero `/_next/image` references across all 29 sitemap URLs). The optimizer was the component behind GHSA-2xp9-vwfh-vxw4 (unauthenticated RCE), decodes attacker-influenced image formats through sharp/libheif, and — with its cache dir unwritable — re-processed every request, a free CPU-amplification endpoint. With `unoptimized`, Next answers `/_next/image` with a 404 before touching the pipeline, and `next/image` renders a plain ``. **Alternatives:** keep it and only fix the cache permissions (rejected — keeps unused, historically exploitable surface). Revisit if the blog starts using large cover images. + +--- + +### 2026-09-30: Owner notifications go to `company_email`, bookings carry iCalendar events + +**Decision:** `notifyOwner()` emails the business on every booking, client cancellation, lead, and ticket — to Settings `company_email`, falling back to every active ADMIN (with a logged warning). Bookings attach iCalendar METHOD:PUBLISH events to both sides and METHOD:CANCEL on client cancellation, sharing one UID per appointment. Events carry an ORGANIZER but no ATTENDEE, and a booker only ever sees `company_email` as organizer. + +**Why:** Until now only the visitor was emailed; bookings, leads, and tickets landed silently in the dashboard and the founder missed a consult. `company_email` already exists in Settings as the business contact, so no new configuration surface is needed, and the admin fallback means a fresh install still notifies someone. PUBLISH over REQUEST: Gmail and Outlook show Decline on any REQUEST, and a booker who declines believes they cancelled while the slot stays booked — PUBLISH is a plain "add to calendar" and the emailed cancel link stays the one way to cancel. No ATTENDEE because RFC 5546 forbids it on PUBLISH and a CalDAV calendar (iCloud, Fastmail) would treat the owner as organizer inviting the booker and send them a second invite. Admin login addresses are never exposed to bookers as organizer. + +**Alternatives:** REQUEST invites (rejected — decline-isn't-cancel, above); a new `notification_email` setting (rejected — duplicates `company_email`); Google/Microsoft calendar API sync (rejected — OAuth, tokens, and a dependency for what an .ics does); a daily digest (rejected — a consult can be booked for the same day). diff --git a/docs/LESSONS.md b/docs/LESSONS.md index 0e30b6a..639aab1 100644 --- a/docs/LESSONS.md +++ b/docs/LESSONS.md @@ -276,6 +276,16 @@ Read at session start (loaded by `/boot`). Add to it whenever: --- +## 2026-09-30 — Every public form worked, and nobody was ever told + +**What happened:** The founder missed a booked consult. Booking, lead capture, and support tickets all "worked" — rows saved, visitors got confirmations — but no code path emailed the business. Each feature was built and tested from the visitor's side only. The booker's cancel link also pointed at a route that didn't exist (`/book/cancel` → 404). + +**Lesson:** A public intake feature isn't done until the owner learns about the submission. Test it as both parties: submit as a visitor, then check the owner's inbox (locally: Mailpit's API at `:8025/api/v1/messages`) and follow every link in every email. + +**Where it applies:** Any public form or booking flow — call `notifyOwner()` (see CLAUDE.md Key Patterns). + +--- + ## How to add to this file When you finish a task and a real lesson emerged, add an entry. Keep it terse. The point is to avoid repeating the mistake — not to write an essay. If the lesson is big enough to drive an architectural change, it goes in `docs/DECISIONS.md` instead. If it's about how the codebase works, update `CLAUDE.md`. If it's about how *we* work — it lives here. diff --git a/docs/features/scheduling.md b/docs/features/scheduling.md index 6c5c3ba..a7fd5e3 100644 --- a/docs/features/scheduling.md +++ b/docs/features/scheduling.md @@ -8,15 +8,19 @@ - `scheduling.createService` (admin) — new bookable service - `scheduling.setAvailability` (staff) — per service, per day, with timezone - `scheduling.availableSlots` (public) — calculates free slots considering duration, existing appointments, calendar blocks -- `scheduling.book` (public, rate-limited 10/hr) — creates appointment with conflict detection -- `scheduling.cancel` (public) — cancel via token +- `scheduling.book` (public, rate-limited 10/hr) — creates appointment with conflict detection; rejects invalid IANA timezones before saving +- `scheduling.cancel` (public) — cancel via token (link in the confirmation email: `/portal/appointments/cancel?token=`) - `scheduling.listAppointments` (protected) — filter by date/staff/status - Public booking page at /book +- Booking emails (`server/scheduling/booking-emails.ts`), fire-and-forget and isolated so a mail failure never fails a saved booking: + - Booker: confirmation with date/time in their own zone, labeled ("6:00 AM PDT"), plus an add-to-calendar event (METHOD:PUBLISH) when `company_email` is set + - Owner (`notifyOwner` → Settings `company_email`, else active admins): "New booking" email, Reply-To the booker, plus a calendar event (METHOD:PUBLISH) + - Client cancellation: both sides get a cancellation email with METHOD:CANCEL for the same UID + - The 24h reminder (never scheduled yet) shares the cancel-link builder and zone-labeled formatting ### Missing -- Confirmation email on booking -- Cancellation email -- Reminder email (24h before) +- Reminder email (24h before) — route exists but the cron has never been scheduled (see CLAUDE.md Known Issues) +- Emails when staff cancel or change an appointment from the dashboard - Reschedule (cancel + rebook) - Staff calendar view - Calendar block management UI