Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions __tests__/ics.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
67 changes: 67 additions & 0 deletions __tests__/owner-notification.test.ts
Original file line number Diff line number Diff line change
@@ -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", `<img src=x onerror=alert(1)> "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("<img");
expect(html).toContain("&lt;img src=x onerror=alert(1)&gt; &quot;Pat&quot;");
});

it("keeps line breaks in multi-line values and drops empty rows", () => {
const html = ownerNotificationHtml(notice);
expect(html).toContain("line one<br />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");
});
});
21 changes: 21 additions & 0 deletions __tests__/text-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
28 changes: 27 additions & 1 deletion __tests__/tz.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
});
});
4 changes: 2 additions & 2 deletions app/api/cron/reminders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 10 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>`.

**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).
10 changes: 10 additions & 0 deletions docs/LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 9 additions & 5 deletions docs/features/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading