@@ -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]) => `
+ | ${escapeHtml(label)} |
+ ${escapeHtml(value).replace(/\r?\n/g, " ")} |
+ `
+ )
+ .join("");
+ const link = n.link
+ ? `${escapeHtml(n.link.label)} `
+ : "";
+
+ return `
+
+${escapeHtml(n.heading)}
+
+ ${escapeHtml(n.heading)}
+
+ ${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 };
}),
|