690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* A thin, typed wrapper over the (previously unused, see schema.prisma)
|
||||
* AuditLog table — used for owner actions that change shared business
|
||||
* configuration (staff, roles, services, hours, booking rules, loyalty,
|
||||
* reward redemption). Never stores passwords, invitation tokens, or other
|
||||
* secrets in `metadata` — callers pass only display-safe before/after
|
||||
* values.
|
||||
*/
|
||||
export async function writeAuditLog(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
actorUserId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
metadata?: Prisma.InputJsonValue;
|
||||
},
|
||||
): Promise<void> {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: params.actorUserId,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getNavRole } from "../permissions";
|
||||
import { cleanupTestBusiness, createTestBusiness, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* getNavRole is what both (staff)/layout.tsx and (owner)/layout.tsx use to
|
||||
* pick a merged nav item set (see lib/navigation/nav-config.ts) and to
|
||||
* gate entry to the (staff) route group at all. These tests cover the
|
||||
* three roles the merged-nav brief distinguishes, plus the "signed in but
|
||||
* neither staff nor super admin" case that must still redirect a
|
||||
* registered customer away.
|
||||
*/
|
||||
describe("getNavRole (integration)", () => {
|
||||
let business: TestBusiness | null = null;
|
||||
const extraUserIds: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
if (business) {
|
||||
await cleanupTestBusiness(business);
|
||||
business = null;
|
||||
}
|
||||
if (extraUserIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: extraUserIds } } }).catch(() => {});
|
||||
extraUserIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies an ordinary barber (no MANAGE_STAFF) as staff", async () => {
|
||||
business = await createTestBusiness();
|
||||
const { userId } = await createTestStaff(business);
|
||||
await expect(getNavRole(userId)).resolves.toBe("staff");
|
||||
});
|
||||
|
||||
it("classifies a staff member whose Role grants MANAGE_STAFF as owner", async () => {
|
||||
business = await createTestBusiness();
|
||||
const role = await prisma.role.create({
|
||||
data: { businessId: business.businessId, name: "Owner", permissions: { create: [{ permission: "MANAGE_STAFF" }] } },
|
||||
});
|
||||
const user = await prisma.user.create({
|
||||
data: { email: "owner-role@test.local", name: "Owner", staffProfile: { create: { businessId: business.businessId, roleId: role.id } } },
|
||||
});
|
||||
business.createdUserIds.push(user.id);
|
||||
await expect(getNavRole(user.id)).resolves.toBe("owner");
|
||||
});
|
||||
|
||||
it("classifies a Super Administrator as superadmin even with no StaffProfile at all", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: "superadmin-navrole@test.local", name: "Super Admin", isSuperAdmin: true },
|
||||
});
|
||||
extraUserIds.push(user.id);
|
||||
await expect(getNavRole(user.id)).resolves.toBe("superadmin");
|
||||
});
|
||||
|
||||
it("returns null for a signed-in user who is neither staff nor a super admin (a registered customer)", async () => {
|
||||
const user = await prisma.user.create({ data: { email: "customer-navrole@test.local", name: "Customer" } });
|
||||
extraUserIds.push(user.id);
|
||||
await expect(getNavRole(user.id)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import type { NavRole } from "@/lib/navigation/nav-config";
|
||||
|
||||
/**
|
||||
* Foundation for Phase 3+ role gating — not exercised by any UI yet.
|
||||
* Deliberately re-checks the database rather than trusting only the JWT:
|
||||
* permission changes (e.g. an owner demoting a barber) must take effect
|
||||
* without waiting for the token to expire. See
|
||||
* docs/implementation-plan.md §11 ("Permission enforcement happens AGAIN
|
||||
* at the server-action/service layer — never trust the route group alone").
|
||||
*
|
||||
* `cache()`-wrapped (same request-dedup pattern as getActingStaff/
|
||||
* getPrimaryBusiness) — every (owner) page checks 1-2 permissions across
|
||||
* its layout and the page itself, which without this repeated the same
|
||||
* StaffProfile+Role+RolePermission join 2-3× per request (see the Phase 7
|
||||
* performance pass).
|
||||
*/
|
||||
export const getStaffPermissions = cache(async (userId: string): Promise<Set<Permission>> => {
|
||||
const staffProfile = await prisma.staffProfile.findUnique({
|
||||
where: { userId },
|
||||
include: { role: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!staffProfile) return new Set();
|
||||
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 9 — the platform-level Super Administrator (see
|
||||
* scripts/ensure-super-admin.ts). Deliberately re-checks the database
|
||||
* rather than trusting the JWT, same rationale as getStaffPermissions.
|
||||
*/
|
||||
export const isSuperAdmin = cache(async (userId: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isSuperAdmin: true } });
|
||||
return user?.isSuperAdmin ?? false;
|
||||
});
|
||||
|
||||
/**
|
||||
* A Super Administrator has every permission an Owner has — OWNER_PERMISSIONS
|
||||
* (src/lib/roles/default-permissions.ts) already IS the full Permission enum,
|
||||
* so short-circuiting true here rather than granting a StaffProfile/Role is
|
||||
* exactly equivalent, without giving the Super Admin a StaffProfile row at
|
||||
* all (see docs/phase-9-summary.md, "Super Administrator" — this is what
|
||||
* keeps them out of the Team list, barber picker, and appointment FKs
|
||||
* structurally rather than via a filter).
|
||||
*/
|
||||
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
|
||||
if (await isSuperAdmin(userId)) return true;
|
||||
const permissions = await getStaffPermissions(userId);
|
||||
return permissions.has(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single role classification behind the merged nav (see
|
||||
* lib/navigation/nav-config.ts) — "staff" vs "owner" is just whether the
|
||||
* account's Role grants MANAGE_STAFF, and "superadmin" short-circuits both
|
||||
* (a Super Administrator has no StaffProfile at all, see isSuperAdmin).
|
||||
* Returns null for a signed-in user who is neither staff nor a super
|
||||
* admin — callers should redirect those away, same as today.
|
||||
*/
|
||||
export const getNavRole = cache(async (userId: string): Promise<NavRole | null> => {
|
||||
if (await isSuperAdmin(userId)) return "superadmin";
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId }, select: { id: true } });
|
||||
if (!staffProfile) return null;
|
||||
const permissions = await getStaffPermissions(userId);
|
||||
return permissions.has(Permission.MANAGE_STAFF) ? "owner" : "staff";
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { sign, verify } from "../sign";
|
||||
|
||||
describe("sign / verify", () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_SECRET = "test-secret-not-for-production-use-only";
|
||||
});
|
||||
|
||||
it("round-trips a payload", () => {
|
||||
const token = sign('{"serviceIds":["svc_1"]}');
|
||||
expect(verify(token)).toBe('{"serviceIds":["svc_1"]}');
|
||||
});
|
||||
|
||||
it("rejects a tampered payload", () => {
|
||||
const token = sign('{"serviceIds":["svc_1"]}');
|
||||
const [, mac] = token.split(".");
|
||||
const tamperedPayload = Buffer.from('{"serviceIds":["svc_2"]}', "utf8").toString("base64url");
|
||||
expect(verify(`${tamperedPayload}.${mac}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", () => {
|
||||
const token = sign('{"a":1}');
|
||||
const [payload] = token.split(".");
|
||||
expect(verify(`${payload}.not-a-real-signature`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed tokens without throwing", () => {
|
||||
expect(verify("")).toBeNull();
|
||||
expect(verify("no-separator")).toBeNull();
|
||||
expect(verify("...")).toBeNull();
|
||||
expect(verify("!!!not-base64!!!.mac")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a token signed with a different secret", () => {
|
||||
const token = sign('{"a":1}');
|
||||
process.env.AUTH_SECRET = "a-completely-different-secret-value";
|
||||
expect(verify(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("throws clearly if AUTH_SECRET is missing rather than signing with an empty key", () => {
|
||||
delete process.env.AUTH_SECRET;
|
||||
expect(() => sign("payload")).toThrow(/AUTH_SECRET/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { isSecureDeployment } from "@/lib/security/cookie-security";
|
||||
import { bookingDraftSchema, emptyDraft, emptyStaffDraft, staffBookingDraftSchema } from "./schema";
|
||||
import type { BookingDraft, StaffBookingDraft } from "./schema";
|
||||
import { sign, verify } from "./sign";
|
||||
|
||||
/**
|
||||
* Server-only I/O around the signed booking-draft cookie. Callable from
|
||||
* Server Components (read) and Server Actions/Route Handlers (read +
|
||||
* write) — `next/headers`'s `cookies()` enforces that boundary itself.
|
||||
*/
|
||||
|
||||
const COOKIE_NAME = "nc_booking_draft";
|
||||
const MAX_AGE_SECONDS = 60 * 60; // 1 hour — long enough to finish a booking, short enough not to linger
|
||||
|
||||
export async function readDraft(businessId: string): Promise<BookingDraft> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(COOKIE_NAME)?.value;
|
||||
if (!raw) return emptyDraft(businessId);
|
||||
|
||||
const payload = verify(raw);
|
||||
if (!payload) return emptyDraft(businessId);
|
||||
|
||||
try {
|
||||
const parsed = bookingDraftSchema.safeParse(JSON.parse(payload));
|
||||
if (!parsed.success || parsed.data.businessId !== businessId) return emptyDraft(businessId);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return emptyDraft(businessId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeDraft(draft: BookingDraft): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify(draft));
|
||||
store.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureDeployment(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearDraft(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.delete(COOKIE_NAME);
|
||||
}
|
||||
|
||||
// ── Post-booking confirmation reference ─────────────────────────────────
|
||||
// Deliberately a separate, tiny cookie rather than a URL query param, so
|
||||
// a bookmarked/shared confirmation URL never carries even a non-sensitive
|
||||
// appointment id — see docs/phase-4-summary.md.
|
||||
|
||||
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
|
||||
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
|
||||
|
||||
export interface LastBookingReference {
|
||||
appointmentId: string;
|
||||
/** Raw guest access token, present only for a guest booking — see guest-access.service.ts. Carried through this already-signed, short-lived cookie rather than a URL, for the same "never even a non-sensitive id in a bookmarkable URL" reason documented below. */
|
||||
guestAccessToken?: string;
|
||||
}
|
||||
|
||||
export async function writeLastBookingReference(reference: LastBookingReference): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify(reference));
|
||||
store.set(CONFIRMATION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureDeployment(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: CONFIRMATION_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLastBookingReference(): Promise<LastBookingReference | null> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(CONFIRMATION_COOKIE_NAME)?.value;
|
||||
if (!raw) return null;
|
||||
const payload = verify(raw);
|
||||
if (!payload) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as { appointmentId?: unknown; guestAccessToken?: unknown };
|
||||
if (typeof parsed.appointmentId !== "string") return null;
|
||||
return {
|
||||
appointmentId: parsed.appointmentId,
|
||||
guestAccessToken: typeof parsed.guestAccessToken === "string" ? parsed.guestAccessToken : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff "book for customer" draft ─────────────────────────────────────
|
||||
// A separate cookie from the customer draft above — see the schema
|
||||
// comment on StaffBookingDraft for why.
|
||||
|
||||
const STAFF_COOKIE_NAME = "nc_staff_booking_draft";
|
||||
|
||||
export async function readStaffDraft(businessId: string): Promise<StaffBookingDraft> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(STAFF_COOKIE_NAME)?.value;
|
||||
if (!raw) return emptyStaffDraft(businessId);
|
||||
|
||||
const payload = verify(raw);
|
||||
if (!payload) return emptyStaffDraft(businessId);
|
||||
|
||||
try {
|
||||
const parsed = staffBookingDraftSchema.safeParse(JSON.parse(payload));
|
||||
if (!parsed.success || parsed.data.businessId !== businessId)
|
||||
return emptyStaffDraft(businessId);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return emptyStaffDraft(businessId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStaffDraft(draft: StaffBookingDraft): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify(draft));
|
||||
store.set(STAFF_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureDeployment(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearStaffDraft(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.delete(STAFF_COOKIE_NAME);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The booking-in-progress draft. Lives in a signed, httpOnly cookie (see
|
||||
* cookie.ts) rather than only in React state, so it survives a page
|
||||
* refresh, browser back/forward, and a sign-in that happens mid-flow —
|
||||
* see docs/phase-4-summary.md for the full reasoning.
|
||||
*/
|
||||
export const guestDetailsSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().min(1, "Phone number is required"),
|
||||
});
|
||||
export type GuestDetails = z.infer<typeof guestDetailsSchema>;
|
||||
|
||||
export const bookingDraftSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).default([]),
|
||||
/** undefined = "any available barber". */
|
||||
staffId: z.string().optional(),
|
||||
date: z.string().optional(), // CalendarDate, "YYYY-MM-DD"
|
||||
startAt: z.iso.datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
guest: guestDetailsSchema.optional(),
|
||||
policyAcknowledged: z.boolean().default(false),
|
||||
/** Set by "Book again" so the services step can show which appointment this draft originated from — display only, never trusted for pricing (see BookingService, which always re-reads current Service prices). */
|
||||
rebookFromAppointmentId: z.string().optional(),
|
||||
});
|
||||
export type BookingDraft = z.infer<typeof bookingDraftSchema>;
|
||||
|
||||
export function emptyDraft(businessId: string): BookingDraft {
|
||||
return {
|
||||
businessId,
|
||||
serviceIds: [],
|
||||
policyAcknowledged: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff-facing "book for customer" draft (src/app/staff/book) — a
|
||||
* deliberately separate shape and cookie from the customer one above
|
||||
* (see cookie.ts), even though the two overlap heavily, so a staff member
|
||||
* booking on a customer's behalf can never collide with their own
|
||||
* in-progress personal booking in another tab. Reuses `guestDetailsSchema`
|
||||
* as-is for "staff is entering a brand-new customer's details" — the same
|
||||
* shape CustomerService.resolveCustomerForBooking already knows how to
|
||||
* resolve/match, so no new matching logic is needed for that path.
|
||||
*/
|
||||
export const staffBookingDraftSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).default([]),
|
||||
/** undefined = "any available barber". */
|
||||
staffId: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
startAt: z.iso.datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
/** An existing CustomerProfile the staff member picked from search. */
|
||||
customerId: z.string().optional(),
|
||||
/** A brand-new guest customer the staff member is entering inline. Ignored if customerId is set. */
|
||||
guest: guestDetailsSchema.optional(),
|
||||
/** Set by "Book Follow-Up" so the services step can show which appointment this draft originated from — display only, never trusted for pricing. */
|
||||
followUpFromAppointmentId: z.string().optional(),
|
||||
});
|
||||
export type StaffBookingDraft = z.infer<typeof staffBookingDraftSchema>;
|
||||
|
||||
export function emptyStaffDraft(businessId: string): StaffBookingDraft {
|
||||
return { businessId, serviceIds: [] };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Tamper-evident (not encrypted) signing for the booking-draft cookie —
|
||||
* an HMAC-SHA256 over the JSON payload using Node's built-in `crypto`,
|
||||
* not a hand-rolled cipher. Signing rather than encrypting is a
|
||||
* deliberate choice: the draft holds data the customer themselves just
|
||||
* typed into a form on this domain (their own name/email/phone, plus
|
||||
* non-sensitive selections), so the realistic threat is tampering
|
||||
* (editing the cookie to book at a different price or under a different
|
||||
* identity), not disclosure to the customer's own browser. httpOnly +
|
||||
* Secure + SameSite=Lax (set in cookie.ts) cover disclosure to anyone
|
||||
* else. Kept as a separate module from cookie.ts so the crypto itself is
|
||||
* unit-testable without Next's `cookies()` API.
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-booking-draft-v1";
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("AUTH_SECRET must be set to sign the booking draft cookie.");
|
||||
}
|
||||
// Derive a purpose-specific key from the shared app secret rather than
|
||||
// reusing it directly, so this cookie's signing key and NextAuth's JWT
|
||||
// key are cryptographically independent even though they start from
|
||||
// the same secret.
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
/** payload.signature, both base64url — a compact, cookie-safe format. */
|
||||
export function sign(payload: string): string {
|
||||
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
||||
return `${encodedPayload}.${mac}`;
|
||||
}
|
||||
|
||||
/** Returns the original payload if the signature is valid, otherwise null. Never throws on malformed input. */
|
||||
export function verify(token: string): string | null {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* The one place every customer/staff/owner-visible surface gets the
|
||||
* business's white-label identity from — see docs/phase-8-summary.md.
|
||||
* "NextChair" is the internal product name only (repo, Docker project,
|
||||
* package name, technical docs/logs); it must never be the fallback a
|
||||
* real person sees. Fallback order, per the brief:
|
||||
* 1. Configured business name + logo
|
||||
* 2. Configured business name + the app's own neutral icon (handled
|
||||
* naturally: leaving `logoUrl`/`appIconUrl`/`faviconUrl` null falls
|
||||
* through to the static /icon.png / /apple-icon.png files Next
|
||||
* already serves via its file-convention icons — no separate
|
||||
* "neutral icon" asset needed)
|
||||
* 3. Neutral wording with no visible product name at all — only
|
||||
* reachable before installation completes, since every other route
|
||||
* is gated behind a completed install (see
|
||||
* src/lib/installation/installation-state.ts).
|
||||
*
|
||||
* Deliberately a separate, lighter query from getPrimaryBusiness() (which
|
||||
* `findFirstOrThrow`s and eager-loads locations/hours/settings/
|
||||
* bookingRule) — this is called from `generateMetadata` and the root
|
||||
* layout on *every* request, including pre-install ones where no
|
||||
* Business row exists yet, so it must never throw.
|
||||
*/
|
||||
|
||||
/** One iOS `apple-touch-startup-image` entry — an exact per-device media query paired with its splash image, matching the shape a generator like progressier.com's ZIP bundle produces. See importAppIconZipAction. */
|
||||
export interface SplashScreenEntry {
|
||||
media: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BrandingInfo {
|
||||
configured: boolean;
|
||||
name: string;
|
||||
shortName: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below, which Android/Chrome installs read instead. */
|
||||
appIconUrl: string | null;
|
||||
themeColor: string;
|
||||
backgroundColor: string;
|
||||
/** PWA manifest `icons` array — see src/app/manifest.ts. Each falls back independently to the app's own generated icon when unset. */
|
||||
icon192Url: string | null;
|
||||
icon512Url: string | null;
|
||||
maskableIcon192Url: string | null;
|
||||
maskableIcon512Url: string | null;
|
||||
/** iOS `apple-touch-startup-image` splash screens — see src/app/layout.tsx. */
|
||||
splashScreens: SplashScreenEntry[];
|
||||
/** Icon-only vs icon+label bottom navigation — see components/navigation/bottom-nav.tsx. */
|
||||
bottomNavShowLabels: boolean;
|
||||
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
|
||||
welcomeBackgroundDesktopUrl: string | null;
|
||||
welcomeBackgroundMobileUrl: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
/** Defensive parse of the `Business.splashScreens` JSON column — never trusts its shape at compile time. Drops any entry missing a string `media`/`url` rather than throwing. */
|
||||
export function parseSplashScreens(value: unknown): SplashScreenEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(entry): entry is SplashScreenEntry =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
typeof (entry as Record<string, unknown>).media === "string" &&
|
||||
typeof (entry as Record<string, unknown>).url === "string",
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_THEME_COLOR = "#111b15";
|
||||
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
|
||||
|
||||
/** Neutral wording only — never the internal product name. */
|
||||
const UNCONFIGURED_BRANDING: BrandingInfo = {
|
||||
configured: false,
|
||||
name: "Book an Appointment",
|
||||
shortName: "Booking",
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
coverImageUrl: null,
|
||||
faviconUrl: null,
|
||||
appIconUrl: null,
|
||||
themeColor: DEFAULT_THEME_COLOR,
|
||||
backgroundColor: DEFAULT_BACKGROUND_COLOR,
|
||||
icon192Url: null,
|
||||
icon512Url: null,
|
||||
maskableIcon192Url: null,
|
||||
maskableIcon512Url: null,
|
||||
splashScreens: [],
|
||||
bottomNavShowLabels: true,
|
||||
welcomeBackgroundDesktopUrl: null,
|
||||
welcomeBackgroundMobileUrl: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
};
|
||||
|
||||
export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
||||
// Reached from the root layout's generateMetadata, which Next may
|
||||
// invoke while statically prerendering a route at *build* time (e.g.
|
||||
// /_not-found) — no real database is reachable then (see the
|
||||
// Dockerfile's placeholder build-time database env vars). A transient/
|
||||
// unreachable database is treated exactly like "not installed yet":
|
||||
// neutral fallback wording, never a crashed build or a 500 at runtime.
|
||||
let business;
|
||||
try {
|
||||
business = await prisma.business.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
shortName: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
coverImageUrl: true,
|
||||
faviconUrl: true,
|
||||
appIconUrl: true,
|
||||
themeColor: true,
|
||||
backgroundColor: true,
|
||||
icon192Url: true,
|
||||
icon512Url: true,
|
||||
maskableIcon192Url: true,
|
||||
maskableIcon512Url: true,
|
||||
splashScreens: true,
|
||||
bottomNavShowLabels: true,
|
||||
welcomeBackgroundDesktopUrl: true,
|
||||
welcomeBackgroundMobileUrl: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return UNCONFIGURED_BRANDING;
|
||||
}
|
||||
if (!business) return UNCONFIGURED_BRANDING;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
name: business.name,
|
||||
shortName: business.shortName?.trim() || business.name,
|
||||
description: business.description,
|
||||
logoUrl: business.logoUrl,
|
||||
coverImageUrl: business.coverImageUrl,
|
||||
faviconUrl: business.faviconUrl,
|
||||
appIconUrl: business.appIconUrl,
|
||||
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
|
||||
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
|
||||
icon192Url: business.icon192Url,
|
||||
icon512Url: business.icon512Url,
|
||||
maskableIcon192Url: business.maskableIcon192Url,
|
||||
maskableIcon512Url: business.maskableIcon512Url,
|
||||
splashScreens: parseSplashScreens(business.splashScreens),
|
||||
bottomNavShowLabels: business.bottomNavShowLabels,
|
||||
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||
email: business.email,
|
||||
phone: business.phone,
|
||||
};
|
||||
});
|
||||
|
||||
/** "Book an Appointment | Business Name" — the shared page-title format used in generateMetadata across the app. */
|
||||
export function brandedTitle(pageTitle: string, branding: BrandingInfo): string {
|
||||
return `${pageTitle} | ${branding.name}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
|
||||
/**
|
||||
* Resolves "which business is the signed-in user acting on behalf of" for
|
||||
* pages/actions that only need the business id/name/timezone — not full
|
||||
* staff identity (see getActingStaff for that, which stays the right
|
||||
* choice for anything that needs a real StaffProfile id, e.g. loyalty
|
||||
* stamp authorship or appointment reassignment).
|
||||
*
|
||||
* A regular staff member/owner resolves via their StaffProfile, same as
|
||||
* getActingStaff. A Super Administrator (see scripts/ensure-super-admin.ts)
|
||||
* deliberately has no StaffProfile at all — that's what keeps them out of
|
||||
* the Team list/barber picker/appointment FKs by construction rather than
|
||||
* a filter — so they resolve straight to the app's one business instead
|
||||
* (v1 is single-business, same assumption getPrimaryBusiness() already
|
||||
* makes everywhere else). Returns null only for a signed-in user who is
|
||||
* neither staff nor a super admin.
|
||||
*/
|
||||
export const getActingBusinessContext = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true, businessId: true, business: { select: { name: true, timezone: true } } },
|
||||
});
|
||||
if (staffProfile) {
|
||||
return {
|
||||
userId: session.user.id,
|
||||
staffId: staffProfile.id,
|
||||
businessId: staffProfile.businessId,
|
||||
businessName: staffProfile.business.name,
|
||||
businessTimezone: staffProfile.business.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await isSuperAdmin(session.user.id))) return null;
|
||||
|
||||
const business = await getPrimaryBusiness();
|
||||
return {
|
||||
userId: session.user.id,
|
||||
// A Super Administrator has no StaffProfile by design (see isSuperAdmin) —
|
||||
// null here is what lets callers tell "acting as real staff" apart from
|
||||
// "acting as a Super Administrator with no personal staff identity",
|
||||
// e.g. to withhold note authorship (see CustomerNotes' actingStaffId).
|
||||
staffId: null,
|
||||
businessId: business.id,
|
||||
businessName: business.name,
|
||||
businessTimezone: business.timezone,
|
||||
};
|
||||
});
|
||||
|
||||
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* v1 is single-business (see docs/implementation-plan.md §11) — every
|
||||
* page that needs "the business" fetches it through here rather than
|
||||
* hardcoding a slug or re-querying independently. Wrapped in React's
|
||||
* `cache()` so multiple Server Components in the same request (a layout
|
||||
* and its page, for instance) share one query instead of issuing it
|
||||
* once each.
|
||||
*/
|
||||
export const getPrimaryBusiness = cache(async () => {
|
||||
return prisma.business.findFirstOrThrow({
|
||||
include: {
|
||||
locations: true,
|
||||
hours: true,
|
||||
settings: true,
|
||||
bookingRule: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
export interface OpeningStatus {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DayHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
opensAtMinutes: number | null;
|
||||
closesAtMinutes: number | null;
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely presentational (read-only "open now?" label for the home page)
|
||||
* — not part of the booking engine's own open/closed check, which
|
||||
* AvailabilityService already performs independently as a real booking
|
||||
* rule. This is display-only, so it lives here rather than in
|
||||
* services/scheduling.
|
||||
*/
|
||||
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
|
||||
const today = todayIn(timezone);
|
||||
const weekday = weekdayOf(today);
|
||||
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
|
||||
|
||||
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
|
||||
return { isOpen: false, label: "Closed today" };
|
||||
}
|
||||
|
||||
const nowMinutes = minutesFromMidnight(new Date(), timezone);
|
||||
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateAppointmentIcs } from "../ics";
|
||||
|
||||
const BASE_INPUT = {
|
||||
appointmentId: "appt_123",
|
||||
businessName: "Blake Heritage Barbershop",
|
||||
businessAddress: "79 Joel Street, HA6 1LW",
|
||||
staffName: "Marcus Reed",
|
||||
serviceNames: ["Haircut & Style"],
|
||||
startAt: new Date("2026-08-10T09:00:00.000Z"),
|
||||
endAt: new Date("2026-08-10T09:30:00.000Z"),
|
||||
};
|
||||
|
||||
describe("generateAppointmentIcs", () => {
|
||||
it("produces a well-formed VCALENDAR/VEVENT block", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/);
|
||||
expect(ics).toContain("BEGIN:VEVENT\r\n");
|
||||
expect(ics).toContain("END:VEVENT\r\n");
|
||||
expect(ics.trimEnd()).toMatch(/END:VCALENDAR$/);
|
||||
});
|
||||
|
||||
it("formats DTSTART/DTEND as UTC instants regardless of local time zone", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics).toContain("DTSTART:20260810T090000Z");
|
||||
expect(ics).toContain("DTEND:20260810T093000Z");
|
||||
});
|
||||
|
||||
it("includes a UID derived from the appointment id, stable across re-generation", () => {
|
||||
const first = generateAppointmentIcs(BASE_INPUT);
|
||||
const second = generateAppointmentIcs(BASE_INPUT);
|
||||
const uidPattern = /UID:appt_123@nextchair/;
|
||||
expect(first).toMatch(uidPattern);
|
||||
expect(second).toMatch(uidPattern);
|
||||
});
|
||||
|
||||
it("combines multiple service names in the summary and description", () => {
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, serviceNames: ["Haircut & Style", "Beard Trim"] });
|
||||
expect(ics).toContain("SUMMARY:Haircut & Style + Beard Trim at Blake Heritage Barbershop");
|
||||
expect(ics).toContain("Services: Haircut & Style\\, Beard Trim");
|
||||
});
|
||||
|
||||
it("escapes commas, semicolons, and backslashes per RFC 5545", () => {
|
||||
const ics = generateAppointmentIcs({
|
||||
...BASE_INPUT,
|
||||
businessName: "Sam's Cuts; Trims, & More\\Co",
|
||||
});
|
||||
// Escaped backslash first, then semicolon, then comma — order matters so
|
||||
// the escaping backslashes themselves don't get re-escaped.
|
||||
expect(ics).toContain("Sam's Cuts\\; Trims\\, & More\\\\Co");
|
||||
});
|
||||
|
||||
it("escapes newlines in notes as literal \\n rather than breaking the content line", () => {
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: "Line one\nLine two" });
|
||||
expect(ics).toContain("Line one\\nLine two");
|
||||
// No raw, unescaped newline should appear inside the DESCRIPTION value itself.
|
||||
const descriptionLine = ics.split("\r\n").find((l) => l.startsWith("DESCRIPTION"));
|
||||
expect(descriptionLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("omits the Notes line entirely when there are no notes", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics).not.toContain("Notes:");
|
||||
});
|
||||
|
||||
it("falls back to just the business name when no address is given", () => {
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, businessAddress: undefined });
|
||||
expect(ics).toContain("LOCATION:Blake Heritage Barbershop");
|
||||
});
|
||||
|
||||
it("folds content lines longer than 75 octets per RFC 5545", () => {
|
||||
const longNotes = "N".repeat(200);
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: longNotes });
|
||||
const rawLines = ics.split("\r\n");
|
||||
// No single physical line (before folding continuation) should exceed 75 octets.
|
||||
for (const line of rawLines) {
|
||||
expect(line.length).toBeLessThanOrEqual(75);
|
||||
}
|
||||
// Continuation lines start with a single space, per the spec.
|
||||
const descriptionStart = rawLines.findIndex((l) => l.startsWith("DESCRIPTION"));
|
||||
expect(rawLines[descriptionStart + 1]?.startsWith(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses CRLF line endings throughout", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics.includes("\r\n")).toBe(true);
|
||||
// Every bare \n should be immediately preceded by \r.
|
||||
for (let i = 0; i < ics.length; i++) {
|
||||
if (ics[i] === "\n") {
|
||||
expect(ics[i - 1]).toBe("\r");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* RFC 5545 (.ics) generation — provider-independent, works with every
|
||||
* calendar app without any Google/Apple API access, per the Phase 4
|
||||
* brief ("Do not require Google Calendar API access for the basic
|
||||
* feature"). Pure string formatting, no I/O — the caller (a route
|
||||
* handler) supplies already-validated, already-loaded data.
|
||||
*
|
||||
* Time zone handling: DTSTART/DTEND are emitted as UTC instants
|
||||
* (`YYYYMMDDTHHMMSSZ`). This is the simplest *correct* approach — every
|
||||
* calendar app converts a UTC-suffixed time to the viewer's own local
|
||||
* time zone automatically, so there's no VTIMEZONE block to get wrong
|
||||
* and no ambiguity around DST for whoever's viewing the event.
|
||||
*/
|
||||
|
||||
export interface AppointmentIcsInput {
|
||||
appointmentId: string;
|
||||
businessName: string;
|
||||
businessAddress?: string;
|
||||
staffName: string;
|
||||
serviceNames: string[];
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function pad(n: number, width = 2): string {
|
||||
return String(n).padStart(width, "0");
|
||||
}
|
||||
|
||||
function formatUtc(date: Date): string {
|
||||
return (
|
||||
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
|
||||
`T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
|
||||
);
|
||||
}
|
||||
|
||||
/** Escapes text per RFC 5545 §3.3.11 — backslash, semicolon, comma, then newlines. Order matters: backslash must be escaped first, or the escaping backslashes we add for the other characters would themselves get re-escaped. */
|
||||
function escapeText(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
|
||||
}
|
||||
|
||||
/** Folds a content line at 75 octets per RFC 5545 §3.1, continuation lines prefixed with a single space. */
|
||||
function foldLine(line: string): string {
|
||||
const CRLF_FOLD = "\r\n ";
|
||||
if (line.length <= 75) return line;
|
||||
|
||||
const chunks: string[] = [];
|
||||
let rest = line;
|
||||
let first = true;
|
||||
while (rest.length > 0) {
|
||||
const limit = first ? 75 : 74; // continuation lines lose one column to the leading space
|
||||
chunks.push(rest.slice(0, limit));
|
||||
rest = rest.slice(limit);
|
||||
first = false;
|
||||
}
|
||||
return chunks.join(CRLF_FOLD);
|
||||
}
|
||||
|
||||
export function generateAppointmentIcs(input: AppointmentIcsInput): string {
|
||||
const summary = escapeText(`${input.serviceNames.join(" + ")} at ${input.businessName}`);
|
||||
const descriptionParts = [
|
||||
`Barber: ${input.staffName}`,
|
||||
`Services: ${input.serviceNames.join(", ")}`,
|
||||
`Booking reference: ${input.appointmentId}`,
|
||||
];
|
||||
if (input.notes) descriptionParts.push(`Notes: ${input.notes}`);
|
||||
const description = escapeText(descriptionParts.join("\n"));
|
||||
const location = input.businessAddress ? escapeText(`${input.businessName}, ${input.businessAddress}`) : escapeText(input.businessName);
|
||||
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Booking Calendar//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${input.appointmentId}@nextchair`,
|
||||
`DTSTAMP:${formatUtc(new Date())}`,
|
||||
`DTSTART:${formatUtc(input.startAt)}`,
|
||||
`DTEND:${formatUtc(input.endAt)}`,
|
||||
`SUMMARY:${summary}`,
|
||||
`DESCRIPTION:${description}`,
|
||||
`LOCATION:${location}`,
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
];
|
||||
|
||||
return lines.map(foldLine).join("\r\n") + "\r\n";
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Builds the `mariadb` pool config from the discrete `DATABASE_HOST` /
|
||||
* `DATABASE_PORT` / `DATABASE_NAME` / `DATABASE_USER` / `DATABASE_PASSWORD`
|
||||
* env vars, shared by every Prisma Client instance in the app (the main
|
||||
* app client, the seed script, and the integration-test fixtures) so the
|
||||
* UTC-timezone and connection-limit handling can't drift between them. See
|
||||
* src/lib/db/prisma.ts for why `timezone`/`sessionVariables` matter — this
|
||||
* file exists only because that reasoning needs to apply to all three
|
||||
* call sites, not because the logic itself is complex.
|
||||
*/
|
||||
export function buildMysqlPoolConfig() {
|
||||
const host = process.env.DATABASE_HOST;
|
||||
const database = process.env.DATABASE_NAME;
|
||||
const user = process.env.DATABASE_USER;
|
||||
const password = process.env.DATABASE_PASSWORD;
|
||||
|
||||
if (!host || !database || !user || password === undefined) {
|
||||
throw new Error(
|
||||
"Database connection is not configured. Set DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD (DATABASE_PORT is optional, defaults to 3306).",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
connectionLimit: Number(process.env.DATABASE_CONNECTION_LIMIT ?? 5),
|
||||
connectTimeout: Number(process.env.DATABASE_CONNECT_TIMEOUT_MS ?? 10_000),
|
||||
// Load-bearing, not cosmetic — see src/lib/db/prisma.ts's file header.
|
||||
timezone: "Etc/UTC",
|
||||
sessionVariables: { time_zone: "+00:00" },
|
||||
ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: true } : undefined,
|
||||
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
|
||||
// the client its RSA public key to encrypt the password, and the
|
||||
// `mariadb` driver refuses to fetch it over a plain connection unless
|
||||
// told to — surfacing as "Public Key Retrieval is not allowed" on
|
||||
// every connection attempt (not intermittent, not env-specific: it
|
||||
// fires against any caching_sha2_password server whenever DATABASE_SSL
|
||||
// isn't "true"). Opting in doesn't weaken authentication itself, only
|
||||
// lets the client ask for that public key; a connection that's already
|
||||
// on `ssl` above doesn't need this exchange in the first place.
|
||||
allowPublicKeyRetrieval: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `mysql://` connection string from the same discrete env vars,
|
||||
* for the one place that genuinely needs a URL rather than a pool config
|
||||
* object: prisma.config.ts, whose `datasource.url` is a plain string
|
||||
* because the Prisma CLI (migrate/introspect) doesn't accept discrete
|
||||
* connection fields.
|
||||
*/
|
||||
export function buildMysqlConnectionUrl(): string | undefined {
|
||||
const host = process.env.DATABASE_HOST;
|
||||
const database = process.env.DATABASE_NAME;
|
||||
const user = process.env.DATABASE_USER;
|
||||
const password = process.env.DATABASE_PASSWORD;
|
||||
if (!host || !database || !user || password === undefined) return undefined;
|
||||
|
||||
const port = process.env.DATABASE_PORT || "3306";
|
||||
return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { buildMysqlPoolConfig } from "./mysql-pool-config";
|
||||
|
||||
/**
|
||||
* Single Prisma Client instance for the whole app.
|
||||
*
|
||||
* Prisma 7 requires a driver adapter instead of a `datasource.url` in the
|
||||
* schema (see prisma/schema.prisma). This is the ONLY file that imports a
|
||||
* database driver directly — every other file goes through `prisma`.
|
||||
*
|
||||
* Migrated from PostgreSQL (`@prisma/adapter-pg`) to MySQL in Phase 7 — see
|
||||
* docs/phase-7-summary.md for the full record. `@prisma/adapter-mariadb` is
|
||||
* Prisma's one official driver adapter for BOTH MySQL and MariaDB (there is
|
||||
* no separate "adapter-mysql" package): the underlying `mariadb` npm driver
|
||||
* speaks the MySQL wire protocol and is Prisma's documented, supported way
|
||||
* to connect to a genuine MySQL server. The package name is not a sign
|
||||
* we're accidentally targeting MariaDB — `datasource.provider` in
|
||||
* schema.prisma is `"mysql"`, and every environment this app runs against
|
||||
* (local Docker, CI, and the documented production target) is real MySQL.
|
||||
*
|
||||
* See mysql-pool-config.ts for why the pool is built from a parsed object
|
||||
* rather than passed the raw connection string — the short version is that
|
||||
* the UTC-timezone handling it sets is load-bearing, not cosmetic.
|
||||
*/
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
||||
});
|
||||
|
||||
// Reuse the client across hot-reloads in dev so we don't exhaust the
|
||||
// connection pool every time a file changes.
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faAward,
|
||||
faBell,
|
||||
faBolt,
|
||||
faBriefcase,
|
||||
faBrush,
|
||||
faCalendarCheck,
|
||||
faCamera,
|
||||
faCheck,
|
||||
faCircleCheck,
|
||||
faClock,
|
||||
faCompass,
|
||||
faCrown,
|
||||
faDiamond,
|
||||
faFire,
|
||||
faGem,
|
||||
faGift,
|
||||
faGlassCheers,
|
||||
faHandSparkles,
|
||||
faHatCowboy,
|
||||
faHeart,
|
||||
faLeaf,
|
||||
faLightbulb,
|
||||
faMedal,
|
||||
faMugSaucer,
|
||||
faPaintbrush,
|
||||
faRibbon,
|
||||
faScissors,
|
||||
faShieldHeart,
|
||||
faSprayCanSparkles,
|
||||
faStamp,
|
||||
faStar,
|
||||
faStarHalfStroke,
|
||||
faSun,
|
||||
faTag,
|
||||
faThumbsUp,
|
||||
faTicket,
|
||||
faTrophy,
|
||||
faUserTie,
|
||||
faWandMagicSparkles,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
/**
|
||||
* Curated, not the full ~2000-icon Font Awesome solid set — deliberately:
|
||||
* a searchable picker over every icon Font Awesome ships (razors,
|
||||
* spreadsheets, ambulances...) is a worse experience for "pick a loyalty
|
||||
* stamp icon" than a relevant shortlist, and named imports here (rather
|
||||
* than `import { fas } from "..."`) keep only these icons' path data in
|
||||
* the bundle — this component is only ever loaded on the owner Loyalty
|
||||
* settings page, never the customer bundle, but there's no reason to
|
||||
* bundle 2000 unused icons regardless. Add to this list as new use cases
|
||||
* come up; nothing about IconPicker itself is tied to this specific set.
|
||||
*/
|
||||
export const ICON_CATALOG: IconDefinition[] = [
|
||||
faScissors,
|
||||
faStar,
|
||||
faStarHalfStroke,
|
||||
faHeart,
|
||||
faGift,
|
||||
faCrown,
|
||||
faGem,
|
||||
faDiamond,
|
||||
faAward,
|
||||
faMedal,
|
||||
faTrophy,
|
||||
faRibbon,
|
||||
faStamp,
|
||||
faTag,
|
||||
faTicket,
|
||||
faThumbsUp,
|
||||
faCheck,
|
||||
faCircleCheck,
|
||||
faFire,
|
||||
faBolt,
|
||||
faSun,
|
||||
faLeaf,
|
||||
faMugSaucer,
|
||||
faGlassCheers,
|
||||
faHandSparkles,
|
||||
faSprayCanSparkles,
|
||||
faWandMagicSparkles,
|
||||
faBrush,
|
||||
faPaintbrush,
|
||||
faCamera,
|
||||
faHatCowboy,
|
||||
faUserTie,
|
||||
faBriefcase,
|
||||
faShieldHeart,
|
||||
faLightbulb,
|
||||
faCompass,
|
||||
faClock,
|
||||
faCalendarCheck,
|
||||
faBell,
|
||||
];
|
||||
|
||||
const ICON_BY_NAME = new Map<string, IconDefinition>(ICON_CATALOG.map((icon) => [icon.iconName, icon]));
|
||||
|
||||
/** Falls back to a neutral default for anything not in the catalogue — covers legacy free-text values from before this picker existed. */
|
||||
export function findCatalogIcon(iconName: string | null | undefined): IconDefinition {
|
||||
return (iconName && ICON_BY_NAME.get(iconName)) || faScissors;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* ApplicationInstallation always uses this fixed id — see the model's own
|
||||
* header in prisma/schema.prisma for why a fixed id (not a lookup by "any
|
||||
* row") is the concurrency-safety mechanism, not just a convention.
|
||||
*/
|
||||
export const INSTALLATION_SINGLETON_ID = "singleton";
|
||||
|
||||
export interface InstallationState {
|
||||
installed: boolean;
|
||||
status: InstallationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Component-scoped read (React `cache()`-deduped per request) — use
|
||||
* this from pages/layouts. `proxy.ts` has its own process-lifetime-cached
|
||||
* check instead (see isInstalledForRequest below): a Server Component
|
||||
* dedupes per-request, but the proxy runs on every single request and a
|
||||
* fresh DB round trip each time would be real, avoidable cost once the app
|
||||
* is (permanently, for the life of the process) installed.
|
||||
*/
|
||||
export const getInstallationState = cache(async (): Promise<InstallationState> => {
|
||||
const row = await prisma.applicationInstallation.findUnique({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
select: { status: true },
|
||||
});
|
||||
const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
|
||||
return { installed: status === InstallationStatus.INSTALLED, status };
|
||||
});
|
||||
|
||||
/**
|
||||
* Module-level (not request-level) cache for `proxy.ts`. Installation is a
|
||||
* one-way, one-time transition for the life of a deployment — once this
|
||||
* process has observed `INSTALLED`, it can never legitimately become
|
||||
* un-installed again (there is no "reset" action, per the brief's own
|
||||
* "no unauthenticated reset button" requirement), so re-querying the
|
||||
* database on every single request forever after is pure waste. Before
|
||||
* install, every request still does the real check, which is exactly the
|
||||
* window where correctness (redirecting to /install) actually matters.
|
||||
*/
|
||||
let processObservedInstalled = false;
|
||||
|
||||
export async function isInstalledForRequest(): Promise<boolean> {
|
||||
if (processObservedInstalled) return true;
|
||||
const row = await prisma.applicationInstallation.findUnique({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
select: { status: true },
|
||||
});
|
||||
const installed = row?.status === InstallationStatus.INSTALLED;
|
||||
if (installed) processObservedInstalled = true;
|
||||
return installed;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Real filesystem I/O against a throwaway temp directory (via
|
||||
* MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting
|
||||
* real file paths right, so a mocked `fs` would test nothing useful. No
|
||||
* database/Prisma dependency here (media-storage.ts is DB-free), so
|
||||
* this stays safe to run in the plain unit suite. The module is
|
||||
* dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads
|
||||
* that env var once at module-load time.
|
||||
*/
|
||||
describe("media-storage", () => {
|
||||
let tempRoot: string;
|
||||
let storage: typeof import("../media-storage");
|
||||
|
||||
beforeAll(async () => {
|
||||
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-test-"));
|
||||
process.env.MEDIA_STORAGE_ROOT = tempRoot;
|
||||
storage = await import("../media-storage");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.MEDIA_STORAGE_ROOT;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const PNG_BYTES = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
it("stores a file and returns a stable /api/media/ URL", async () => {
|
||||
const result = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
|
||||
expect(result.url).toMatch(/^\/api\/media\/branding\/[a-f0-9-]+\.png$/);
|
||||
});
|
||||
|
||||
it("readStoredMediaFile resolves the stored file back with the right bytes and content type", async () => {
|
||||
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
|
||||
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
|
||||
|
||||
const file = await storage.readStoredMediaFile(segments);
|
||||
expect(file).not.toBeNull();
|
||||
expect(file?.mimeType).toBe("image/png");
|
||||
expect(file?.buffer.equals(PNG_BYTES)).toBe(true);
|
||||
});
|
||||
|
||||
it("readStoredMediaFile returns null for a file that was never written", async () => {
|
||||
const file = await storage.readStoredMediaFile(["branding", "does-not-exist.png"]);
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteMediaFile removes the file — a subsequent read returns null", async () => {
|
||||
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "staff" });
|
||||
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
|
||||
|
||||
expect(await storage.readStoredMediaFile(segments)).not.toBeNull();
|
||||
await storage.deleteMediaFile(stored.url);
|
||||
expect(await storage.readStoredMediaFile(segments)).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteMediaFile on an external (non-/api/media/) URL is a safe no-op", async () => {
|
||||
await expect(storage.deleteMediaFile("https://example.com/logo.png")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects unsupported mime types before ever touching the filesystem", async () => {
|
||||
await expect(storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/svg+xml", category: "branding" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("blocks path traversal via '..' segments", () => {
|
||||
expect(storage.resolveMediaFilePath(["..", "..", "etc", "passwd"])).toBeNull();
|
||||
expect(storage.resolveMediaFilePath(["branding", ".."])).toBeNull();
|
||||
expect(storage.resolveMediaFilePath(["branding", "..", "..", "package.json"])).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks segments that smuggle a path separator", () => {
|
||||
expect(storage.resolveMediaFilePath(["branding/../../../etc/passwd"])).toBeNull();
|
||||
expect(storage.resolveMediaFilePath(["a\\b"])).toBeNull();
|
||||
});
|
||||
|
||||
it("readStoredMediaFile returns null for a path-traversal attempt even if a file exists there", async () => {
|
||||
// Prove the guard rejects it via the real read path, not just the resolver in isolation.
|
||||
const file = await storage.readStoredMediaFile(["..", "..", "..", "etc", "passwd"]);
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
it("mimeTypeForFilename covers every allowed extension and rejects unknown ones", () => {
|
||||
expect(storage.mimeTypeForFilename("x.png")).toBe("image/png");
|
||||
expect(storage.mimeTypeForFilename("x.jpg")).toBe("image/jpeg");
|
||||
expect(storage.mimeTypeForFilename("x.jpeg")).toBe("image/jpeg");
|
||||
expect(storage.mimeTypeForFilename("x.webp")).toBe("image/webp");
|
||||
expect(storage.mimeTypeForFilename("x.gif")).toBe("image/gif");
|
||||
expect(storage.mimeTypeForFilename("x.svg")).toBeNull();
|
||||
expect(storage.mimeTypeForFilename("x")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Local-disk storage for the Media Manager (see src/services/media).
|
||||
*
|
||||
* Files live under a storage root OUTSIDE `public/` (`storage/media` by
|
||||
* default — `/app/storage/media` in the Docker image, matching the
|
||||
* `next-chair-media-data` volume in docker-compose.local.yaml). This was
|
||||
* previously `public/media/`, served by Next's own static file handling
|
||||
* — that turned out not to work: verified directly against a real
|
||||
* production build/container that Next.js 16's `next start` does NOT
|
||||
* dynamically serve files added to `public/` after the build (a
|
||||
* build-time-present file served 200; a file written into `public/media`
|
||||
* at runtime served 404, even though it existed on disk with correct
|
||||
* permissions). See src/app/api/media/[...path]/route.ts, the dedicated
|
||||
* route that now serves these files itself instead of relying on Next's
|
||||
* public-asset resolution.
|
||||
*
|
||||
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
|
||||
* <script>, making it a stored-XSS vector if ever served inline.
|
||||
*
|
||||
* Kept provider-neutral by design: every caller only ever sees a
|
||||
* MediaAsset.url string and calls storeMediaFile/readStoredMediaFile/
|
||||
* deleteMediaFile — swapping this file for an S3-compatible backend
|
||||
* later doesn't require touching any caller.
|
||||
*/
|
||||
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
};
|
||||
|
||||
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
};
|
||||
|
||||
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
|
||||
|
||||
/** Overridable so a non-Docker deployment can point this somewhere else; defaults to a directory next to the app, outside public/. */
|
||||
const MEDIA_ROOT = path.resolve(process.env.MEDIA_STORAGE_ROOT || "storage/media");
|
||||
|
||||
export const MEDIA_URL_PREFIX = "/api/media/";
|
||||
|
||||
export function isAllowedMediaMimeType(mimeType: string): boolean {
|
||||
return mimeType in ALLOWED_MIME_EXTENSIONS;
|
||||
}
|
||||
|
||||
export interface StoredMediaFile {
|
||||
/** Public path — what gets saved on MediaAsset.url and rendered directly. Resolved by src/app/api/media/[...path]/route.ts, not Next's static handling. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
function sanitizeCategory(category: string): string {
|
||||
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a `/api/media/<...segments>` route param array to an absolute
|
||||
* path, refusing anything that would escape MEDIA_ROOT — the only
|
||||
* defense that actually matters against path traversal (`..` segments,
|
||||
* absolute-path segments, encoded separators once decoded by Next's own
|
||||
* routing). Returns null for anything unsafe rather than throwing, so
|
||||
* callers can turn that straight into a 404 without distinguishing
|
||||
* "malicious" from "malformed."
|
||||
*/
|
||||
export function resolveMediaFilePath(segments: string[]): string | null {
|
||||
if (segments.length === 0) return null;
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
|
||||
return null;
|
||||
}
|
||||
const absolutePath = path.resolve(MEDIA_ROOT, ...segments);
|
||||
if (absolutePath !== MEDIA_ROOT && !absolutePath.startsWith(MEDIA_ROOT + path.sep)) return null;
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
export function mimeTypeForFilename(filename: string): string | null {
|
||||
const extension = filename.split(".").pop()?.toLowerCase();
|
||||
if (!extension) return null;
|
||||
return EXTENSION_MIME_TYPES[extension] ?? null;
|
||||
}
|
||||
|
||||
export interface StoredMediaFileContent {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/** Used only by GET /api/media/[...path] — returns null for "doesn't exist" or "not a regular file," never throws, so the route can turn that into a clean 404. */
|
||||
export async function readStoredMediaFile(segments: string[]): Promise<StoredMediaFileContent | null> {
|
||||
const absolutePath = resolveMediaFilePath(segments);
|
||||
if (!absolutePath) return null;
|
||||
|
||||
const mimeType = mimeTypeForFilename(absolutePath);
|
||||
if (!mimeType) return null;
|
||||
|
||||
try {
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) return null;
|
||||
const buffer = await readFile(absolutePath);
|
||||
return { buffer, mimeType };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeMediaFile(params: {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
category: string;
|
||||
}): Promise<StoredMediaFile> {
|
||||
const extension = ALLOWED_MIME_EXTENSIONS[params.mimeType];
|
||||
if (!extension) throw new Error(`Unsupported media type: ${params.mimeType}`);
|
||||
|
||||
const safeCategory = sanitizeCategory(params.category);
|
||||
const filename = `${randomUUID()}.${extension}`;
|
||||
const dir = path.join(MEDIA_ROOT, safeCategory);
|
||||
const absolutePath = path.join(dir, filename);
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(absolutePath, params.buffer);
|
||||
|
||||
// Never report success on a write we can't actually read back — see
|
||||
// this file's header. A silent permission/filesystem issue on write
|
||||
// (e.g. a volume mounted read-only) should surface as a failed upload,
|
||||
// not a broken URL discovered later.
|
||||
try {
|
||||
await access(absolutePath);
|
||||
} catch {
|
||||
throw new Error("Upload failed: the file could not be verified after writing.");
|
||||
}
|
||||
|
||||
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
|
||||
}
|
||||
|
||||
/** Best-effort — a missing file (already deleted, or a URL that was never ours) never blocks removing the DB row. */
|
||||
export async function deleteMediaFile(url: string): Promise<void> {
|
||||
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
|
||||
const relative = url.slice(MEDIA_URL_PREFIX.length);
|
||||
const absolutePath = resolveMediaFilePath(relative.split("/"));
|
||||
if (!absolutePath) return;
|
||||
await unlink(absolutePath).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNavItems, resolveActiveHref } from "../nav-config";
|
||||
|
||||
describe("getNavItems", () => {
|
||||
it("gives a barber exactly Calendar/Now/Clients/Settings", () => {
|
||||
const labels = getNavItems("staff").map((i) => i.label);
|
||||
expect(labels).toEqual(["Calendar", "Now", "Clients", "Settings"]);
|
||||
});
|
||||
|
||||
it("gives an owner Calendar/Now/Clients/Manage/Settings — barber tools and business tools in one menu", () => {
|
||||
const labels = getNavItems("owner").map((i) => i.label);
|
||||
expect(labels).toEqual(["Calendar", "Now", "Clients", "Manage", "Settings"]);
|
||||
});
|
||||
|
||||
it("gives a Super Administrator no 'Now' tab — there is no personal barber schedule to show", () => {
|
||||
const labels = getNavItems("superadmin").map((i) => i.label);
|
||||
expect(labels).toEqual(["Calendar", "Clients", "Manage", "Settings"]);
|
||||
expect(labels).not.toContain("Now");
|
||||
});
|
||||
|
||||
it("points owner's Now/Clients/Settings at the same routes a barber uses — no duplicate business-wide pages", () => {
|
||||
const owner = getNavItems("owner");
|
||||
const staff = getNavItems("staff");
|
||||
expect(owner.find((i) => i.label === "Now")?.href).toBe(staff.find((i) => i.label === "Now")?.href);
|
||||
expect(owner.find((i) => i.label === "Clients")?.href).toBe(staff.find((i) => i.label === "Clients")?.href);
|
||||
expect(owner.find((i) => i.label === "Settings")?.href).toBe(staff.find((i) => i.label === "Settings")?.href);
|
||||
});
|
||||
|
||||
it("gives owner and super-admin a business-wide Calendar, distinct from the barber's personal one", () => {
|
||||
expect(getNavItems("owner").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
|
||||
expect(getNavItems("superadmin").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
|
||||
expect(getNavItems("staff").find((i) => i.label === "Calendar")?.href).toBe("/staff");
|
||||
});
|
||||
|
||||
it("caps every role's nav at 5 items", () => {
|
||||
for (const role of ["staff", "owner", "superadmin"] as const) {
|
||||
expect(getNavItems(role).length).toBeLessThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActiveHref", () => {
|
||||
const staffItems = getNavItems("staff");
|
||||
const ownerItems = getNavItems("owner");
|
||||
|
||||
it("activates Calendar for the barber's own calendar root", () => {
|
||||
expect(resolveActiveHref("/staff", staffItems)).toBe("/staff");
|
||||
});
|
||||
|
||||
it("activates Now for /staff/current, not Calendar — the classic longest-prefix trap", () => {
|
||||
expect(resolveActiveHref("/staff/current", staffItems)).toBe("/staff/current");
|
||||
});
|
||||
|
||||
it("activates Clients for a nested client detail route", () => {
|
||||
expect(resolveActiveHref("/staff/clients/abc123", staffItems)).toBe("/staff/clients");
|
||||
});
|
||||
|
||||
it("activates Settings for a nested personal-settings route", () => {
|
||||
expect(resolveActiveHref("/staff/settings/calendar", staffItems)).toBe("/staff/settings");
|
||||
});
|
||||
|
||||
it("activates Manage for every /owner/* business-management route", () => {
|
||||
expect(resolveActiveHref("/owner/team", ownerItems)).toBe("/owner");
|
||||
expect(resolveActiveHref("/owner/services", ownerItems)).toBe("/owner");
|
||||
expect(resolveActiveHref("/owner/settings/loyalty", ownerItems)).toBe("/owner");
|
||||
});
|
||||
|
||||
it("activates the business-wide Calendar for /owner/calendar, not Manage", () => {
|
||||
expect(resolveActiveHref("/owner/calendar", ownerItems)).toBe("/owner/calendar");
|
||||
});
|
||||
|
||||
it("activates Now for /staff/current even from the owner nav (shared personal route)", () => {
|
||||
expect(resolveActiveHref("/staff/current", ownerItems)).toBe("/staff/current");
|
||||
});
|
||||
|
||||
it("returns null for a route no item covers", () => {
|
||||
expect(resolveActiveHref("/sign-in", staffItems)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
|
||||
|
||||
/**
|
||||
* Single source of truth for the merged staff/owner/super-admin bottom nav
|
||||
* and desktop sidebar (see docs/implementation-plan.md and the Phase 11
|
||||
* "merged navigation" brief). Owner is a permission on a staff account, not
|
||||
* a separate app — Owner and SuperAdmin nav reuse the exact same personal
|
||||
* routes (`/staff/current`, `/staff/clients`, `/staff/settings`) a barber
|
||||
* uses, rather than duplicating them under `/owner`. This is what lets
|
||||
* owners reach barber tools and business tools from one menu with zero new
|
||||
* routes.
|
||||
*
|
||||
* A Super Administrator has no StaffProfile by construction (see
|
||||
* isSuperAdmin/getActingBusinessContext) — they get no "Now" tab, since
|
||||
* there is no personal schedule to show, per the brief's explicit "do not
|
||||
* fabricate a personal barber schedule for the Super Administrator".
|
||||
*/
|
||||
export type NavRole = "staff" | "owner" | "superadmin";
|
||||
|
||||
const CALENDAR: BottomNavItem = { href: "/staff", label: "Calendar", icon: "calendar-days" };
|
||||
const OWNER_CALENDAR: BottomNavItem = { href: "/owner/calendar", label: "Calendar", icon: "calendar-days" };
|
||||
const NOW: BottomNavItem = { href: "/staff/current", label: "Now", icon: "scissors" };
|
||||
const CLIENTS: BottomNavItem = { href: "/staff/clients", label: "Clients", icon: "users" };
|
||||
const MANAGE: BottomNavItem = { href: "/owner", label: "Manage", icon: "briefcase" };
|
||||
const SETTINGS: BottomNavItem = { href: "/staff/settings", label: "Settings", icon: "settings" };
|
||||
|
||||
const NAV_ITEMS_BY_ROLE: Record<NavRole, BottomNavItem[]> = {
|
||||
staff: [CALENDAR, NOW, CLIENTS, SETTINGS],
|
||||
owner: [OWNER_CALENDAR, NOW, CLIENTS, MANAGE, SETTINGS],
|
||||
superadmin: [OWNER_CALENDAR, CLIENTS, MANAGE, SETTINGS],
|
||||
};
|
||||
|
||||
export function getNavItems(role: NavRole): BottomNavItem[] {
|
||||
return NAV_ITEMS_BY_ROLE[role];
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest-matching-href wins, rather than "every item whose href is a
|
||||
* pathname prefix" (the previous per-component logic) — that scheme let
|
||||
* `/staff/current` mark both "Calendar" (href `/staff`) and "Now" active at
|
||||
* once, since `/staff/current` also starts with `/staff`. Sorting by href
|
||||
* length and taking the first match makes only the most specific item win,
|
||||
* which is what makes nested routes (e.g. `/owner/settings/loyalty`,
|
||||
* `/staff/clients/[id]`) activate exactly one, correct primary tab.
|
||||
*/
|
||||
export function resolveActiveHref(pathname: string, items: BottomNavItem[]): string | null {
|
||||
const matches = items.filter((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
||||
if (matches.length === 0) return null;
|
||||
return matches.reduce((longest, item) => (item.href.length > longest.href.length ? item : longest)).href;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* The two system roles' permission sets — single source of truth, used by
|
||||
* both `prisma/seed.ts` (dev/demo fixtures) and the installation wizard
|
||||
* (`src/features/installation/installer.service.ts`, the real "first
|
||||
* owner" creation path). Previously duplicated only in seed.ts; extracted
|
||||
* here in Phase 8 so a permission added to one path can't silently miss
|
||||
* the other (see docs/phase-8-summary.md).
|
||||
*/
|
||||
export const OWNER_PERMISSIONS: Permission[] = [
|
||||
Permission.MANAGE_BUSINESS_PROFILE,
|
||||
Permission.MANAGE_BOOKING_RULES,
|
||||
Permission.MANAGE_SERVICES,
|
||||
Permission.MANAGE_STAFF,
|
||||
Permission.MANAGE_LOYALTY_PROGRAMME,
|
||||
Permission.MANAGE_NOTIFICATION_SETTINGS,
|
||||
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
|
||||
Permission.MANAGE_ALL_APPOINTMENTS,
|
||||
Permission.MANAGE_OWN_APPOINTMENTS,
|
||||
Permission.CHECK_IN_CUSTOMERS,
|
||||
Permission.MANAGE_LOYALTY_STAMPS,
|
||||
Permission.MANAGE_OWN_AVAILABILITY,
|
||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||
Permission.VIEW_REPORTING,
|
||||
Permission.MANAGE_MEDIA_LIBRARY,
|
||||
Permission.MANAGE_REVIEWS,
|
||||
];
|
||||
|
||||
export const BARBER_PERMISSIONS: Permission[] = [
|
||||
Permission.MANAGE_OWN_APPOINTMENTS,
|
||||
Permission.CHECK_IN_CUSTOMERS,
|
||||
Permission.MANAGE_LOYALTY_STAMPS,
|
||||
Permission.MANAGE_OWN_AVAILABILITY,
|
||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||
];
|
||||
|
||||
/**
|
||||
* `role.upsert`'s `update: {}` deliberately does nothing on an existing
|
||||
* role, so a plain nested `permissions: { create: [...] }` only fires on
|
||||
* the `create` branch — this syncs RolePermission rows to exactly match
|
||||
* the target list every time, safe to call repeatedly. See prisma/seed.ts
|
||||
* for the original bug this fixed.
|
||||
*/
|
||||
export async function syncRolePermissions(
|
||||
prisma: Pick<PrismaClient, "rolePermission">,
|
||||
roleId: string,
|
||||
permissions: Permission[],
|
||||
): Promise<void> {
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((permission) => ({ roleId, permission })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isSecureDeployment } from "../cookie-security";
|
||||
|
||||
describe("isSecureDeployment", () => {
|
||||
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl;
|
||||
});
|
||||
|
||||
it("is true when the deployment's own URL is https", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://barbershop.example.com";
|
||||
expect(isSecureDeployment()).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a plain-http deployment — e.g. the documented default self-hosted setup", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
expect(isSecureDeployment()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a plain-http LAN address, regardless of NODE_ENV", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://192.168.1.50:3000";
|
||||
expect(isSecureDeployment()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when unset", () => {
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
expect(isSecureDeployment()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOAuthState, verifyOAuthState } from "../oauth-state";
|
||||
|
||||
describe("oauth-state", () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_SECRET = "test-auth-secret-not-for-production";
|
||||
});
|
||||
|
||||
it("verifies a state token for the staff member it was issued to", () => {
|
||||
const state = createOAuthState("staff-123");
|
||||
expect(verifyOAuthState(state, "staff-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a state token issued to a different staff member", () => {
|
||||
const state = createOAuthState("staff-123");
|
||||
expect(verifyOAuthState(state, "staff-999")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered token", () => {
|
||||
const state = createOAuthState("staff-123");
|
||||
const tampered = `${state.slice(0, -2)}xx`;
|
||||
expect(verifyOAuthState(tampered, "staff-123")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed token", () => {
|
||||
expect(verifyOAuthState("not-a-real-token", "staff-123")).toBe(false);
|
||||
expect(verifyOAuthState("", "staff-123")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an expired token", () => {
|
||||
vi.useFakeTimers();
|
||||
const state = createOAuthState("staff-123");
|
||||
vi.advanceTimersByTime(11 * 60 * 1000); // past the 10-minute max age
|
||||
expect(verifyOAuthState(state, "staff-123")).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { checkRateLimit } from "../rate-limit";
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("allows requests under the limit", () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks the request once the limit is exceeded within the window", () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
|
||||
}
|
||||
const blocked = checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tracks each key independently", () => {
|
||||
const keyA = `test:${crypto.randomUUID()}`;
|
||||
const keyB = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
checkRateLimit(keyA, { maxAttempts: 3, windowMs: 60_000 });
|
||||
}
|
||||
// keyA is now exhausted, but keyB has never been touched.
|
||||
expect(checkRateLimit(keyA, { maxAttempts: 3, windowMs: 60_000 }).allowed).toBe(false);
|
||||
expect(checkRateLimit(keyB, { maxAttempts: 3, windowMs: 60_000 }).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("resets once the window elapses", () => {
|
||||
vi.useFakeTimers();
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 });
|
||||
}
|
||||
expect(checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 }).allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1_001);
|
||||
|
||||
expect(checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 }).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { decryptToken, encryptToken } from "../token-encryption";
|
||||
|
||||
describe("token-encryption", () => {
|
||||
beforeEach(() => {
|
||||
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
|
||||
});
|
||||
|
||||
it("round-trips a plaintext value", () => {
|
||||
const plaintext = "1//0gExampleGoogleRefreshToken";
|
||||
const encrypted = encryptToken(plaintext);
|
||||
expect(encrypted).not.toBe(plaintext);
|
||||
expect(decryptToken(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("produces a different ciphertext for the same plaintext each time (random IV)", () => {
|
||||
const a = encryptToken("same-value");
|
||||
const b = encryptToken("same-value");
|
||||
expect(a).not.toBe(b);
|
||||
expect(decryptToken(a)).toBe("same-value");
|
||||
expect(decryptToken(b)).toBe("same-value");
|
||||
});
|
||||
|
||||
it("throws if CALENDAR_TOKEN_ENCRYPTION_KEY is not set", () => {
|
||||
delete process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
|
||||
expect(() => encryptToken("x")).toThrow(/CALENDAR_TOKEN_ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it("fails to decrypt with a tampered ciphertext (authentication tag mismatch)", () => {
|
||||
const encrypted = encryptToken("secret-value");
|
||||
const tampered = encrypted.slice(0, -4) + "AAAA";
|
||||
expect(() => decryptToken(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Whether this deployment is actually served over HTTPS — the correct
|
||||
* signal for a cookie's `Secure` attribute, which every browser enforces
|
||||
* by silently refusing to store if the response wasn't delivered over
|
||||
* HTTPS (see MDN/RFC 6265bis). `NODE_ENV === "production"` is NOT that
|
||||
* signal: this app's own Dockerfile hard-sets `NODE_ENV=production`
|
||||
* regardless of whether the operator has put TLS in front of it, and
|
||||
* docker-compose.local.yaml ships with no TLS termination at all — a
|
||||
* self-hosted instance reached at "http://<lan-ip>:3000" (the default,
|
||||
* documented setup) is exactly `NODE_ENV=production` *and* plain HTTP.
|
||||
* A cookie marked `Secure` there is silently dropped by the browser on
|
||||
* write, with no error surfaced anywhere — this was the root cause of a
|
||||
* real bug where the staff "book for customer" flow's draft cookie
|
||||
* vanished between steps, bouncing the user back to customer search with
|
||||
* no explanation. NextAuth's own session cookie doesn't hit this because
|
||||
* it adapts via `AUTH_TRUST_HOST`/its own request-aware cookie config;
|
||||
* this app's hand-rolled cookies (booking draft, staff booking draft,
|
||||
* last-booking reference, Google Calendar OAuth state) need the same
|
||||
* request-aware treatment, derived here from the deployer-declared
|
||||
* `NEXT_PUBLIC_APP_URL` (see .env.local.example — defaults to
|
||||
* "http://localhost:3000") rather than blindly trusting NODE_ENV.
|
||||
*/
|
||||
export function isSecureDeployment(): boolean {
|
||||
return (process.env.NEXT_PUBLIC_APP_URL ?? "").startsWith("https://");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Signed, short-lived OAuth `state` for the Google Calendar connect flow
|
||||
* (see src/app/api/staff/calendar/google/{authorize,callback}/route.ts).
|
||||
* Same HMAC-over-JSON approach as src/lib/booking-draft/sign.ts, with its
|
||||
* own context string — kept as a separate module rather than reusing that
|
||||
* one directly, so the two signing keys stay cryptographically
|
||||
* independent (a leaked booking-draft signature should tell an attacker
|
||||
* nothing about this one, and vice versa).
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-calendar-oauth-state-v1";
|
||||
const MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
interface StatePayload {
|
||||
nonce: string;
|
||||
staffId: string;
|
||||
issuedAt: number;
|
||||
}
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) throw new Error("AUTH_SECRET must be set to sign the calendar OAuth state.");
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
export function createOAuthState(staffId: string): string {
|
||||
const payload: StatePayload = { nonce: randomBytes(16).toString("hex"), staffId, issuedAt: Date.now() };
|
||||
const json = JSON.stringify(payload);
|
||||
const mac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
|
||||
return `${Buffer.from(json, "utf8").toString("base64url")}.${mac}`;
|
||||
}
|
||||
|
||||
/** Returns the staffId if `token` is a validly-signed, non-expired state for this exact staff member — null otherwise. Never throws. */
|
||||
export function verifyOAuthState(token: string, expectedStaffId: string): boolean {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return false;
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return false;
|
||||
|
||||
let json: string;
|
||||
try {
|
||||
json = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) return false;
|
||||
|
||||
let payload: StatePayload;
|
||||
try {
|
||||
payload = JSON.parse(json);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.staffId !== expectedStaffId) return false;
|
||||
if (Date.now() - payload.issuedAt > MAX_AGE_MS) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* In-memory, single-process rate limiting — appropriate for this app's
|
||||
* deployment shape (one Node.js server, no Redis/shared cache in the
|
||||
* stack; see docs/implementation-plan.md). Explicit trade-off: a
|
||||
* multi-instance deployment would need a shared store instead, since each
|
||||
* instance would otherwise track its own counters. Fixed-window counter
|
||||
* per key, not sliding-window — simpler, and the imprecision at window
|
||||
* boundaries doesn't matter for the abuse patterns this defends against
|
||||
* (credential stuffing, registration/booking spam).
|
||||
*/
|
||||
|
||||
interface WindowState {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const buckets = new Map<string, WindowState>();
|
||||
|
||||
// Bounds memory growth from an unbounded set of keys (e.g. one per
|
||||
// attacker-controlled IP/email) — a lazy sweep on every call is cheap
|
||||
// relative to the rest of the request and needs no background timer.
|
||||
const MAX_TRACKED_KEYS = 50_000;
|
||||
|
||||
function sweepExpired(now: number): void {
|
||||
if (buckets.size < MAX_TRACKED_KEYS) return;
|
||||
for (const [key, state] of buckets) {
|
||||
if (state.resetAt <= now) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `key` should already encode the thing being limited (e.g.
|
||||
* `"signin:alex@example.com"`, `"register:203.0.113.4"`) — this module
|
||||
* has no concept of the caller's domain, just fixed-window counters.
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
params: { maxAttempts: number; windowMs: number },
|
||||
): RateLimitResult {
|
||||
const now = Date.now();
|
||||
sweepExpired(now);
|
||||
|
||||
const existing = buckets.get(key);
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + params.windowMs });
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= params.maxAttempts) {
|
||||
return { allowed: false, retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* Best-effort caller IP for rate-limiting Server Actions, which (unlike
|
||||
* Route Handlers) never receive a `Request` object directly. Reads the
|
||||
* headers a reverse proxy/hosting platform sets — `x-forwarded-for` first
|
||||
* (its first entry is the original client, per the standard convention),
|
||||
* falling back to `x-real-ip`. Returns a constant placeholder if neither
|
||||
* is present (e.g. a direct connection with no proxy in front, or a test
|
||||
* environment) — that collapses everyone onto one shared bucket rather
|
||||
* than silently disabling the limit, which is the safer failure mode.
|
||||
*/
|
||||
export async function getRequestIp(): Promise<string> {
|
||||
const headerList = await headers();
|
||||
const forwardedFor = headerList.get("x-forwarded-for");
|
||||
if (forwardedFor) return forwardedFor.split(",")[0]!.trim();
|
||||
|
||||
const realIp = headerList.get("x-real-ip");
|
||||
if (realIp) return realIp.trim();
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Constant-time string comparison for secrets (cron bearer tokens, the
|
||||
* installation token) — a plain `!==` leaks a secret's length and correct
|
||||
* prefix via response timing. Shared by
|
||||
* src/app/api/cron/process-notifications/route.ts and
|
||||
* src/features/installation/actions/install.ts; see also
|
||||
* src/lib/booking-draft/sign.ts, which uses the same primitive directly
|
||||
* for HMAC comparison rather than through this helper.
|
||||
*/
|
||||
export function secretsMatch(provided: string, expected: string): boolean {
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* AES-256-GCM at-rest encryption for Google Calendar OAuth tokens (see
|
||||
* StaffCalendarConnection in prisma/schema.prisma) — refresh tokens are
|
||||
* long-lived credentials for a third-party account and must never be
|
||||
* stored in plain text. Output format: base64(iv[12] + authTag[16] +
|
||||
* ciphertext) — everything needed to decrypt in one column, no separate
|
||||
* iv/tag columns to keep in sync.
|
||||
*
|
||||
* CALENDAR_TOKEN_ENCRYPTION_KEY is expected to be a 32-byte value,
|
||||
* hex-encoded (64 hex chars) — generate with `openssl rand -hex 32`. A
|
||||
* SHA-256 of the raw env value is used as the actual AES key (not the
|
||||
* raw bytes directly) so a key of any length/format still yields a
|
||||
* valid 32-byte AES-256 key, rather than throwing at startup for a
|
||||
* slightly-wrong-length env var.
|
||||
*/
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
|
||||
if (!secret) {
|
||||
throw new Error("CALENDAR_TOKEN_ENCRYPTION_KEY must be set to store Google Calendar tokens.");
|
||||
}
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
export function encryptToken(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
|
||||
}
|
||||
|
||||
export function decryptToken(encoded: string): string {
|
||||
const raw = Buffer.from(encoded, "base64");
|
||||
const iv = raw.subarray(0, IV_LENGTH);
|
||||
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* The signed-in staff member's own profile, request-deduped via React's
|
||||
* `cache()` the same way getPrimaryBusiness() is — every staff page needs
|
||||
* this, and several call it more than once per render (page + nested
|
||||
* layout-level checks), so this avoids repeating the query.
|
||||
*
|
||||
* Returns null for a guest or a customer-only account; (staff)/layout.tsx
|
||||
* already redirects those away, but every page re-checks anyway per the
|
||||
* "permission enforcement happens again at each layer" rule established in
|
||||
* Phase 2 (see src/lib/auth/permissions.ts).
|
||||
*/
|
||||
export const getActingStaff = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
return prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
|
||||
business: { select: { id: true, name: true, timezone: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export type ActingStaffProfile = NonNullable<Awaited<ReturnType<typeof getActingStaff>>>;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { wallClockToInstant } from "../business-time";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Phase 7's MySQL migration made the connection/session timezone handling
|
||||
* load-bearing (see src/lib/db/mysql-pool-config.ts) — a driver defaulting
|
||||
* to the Node process's local timezone, or a MySQL session not forced to
|
||||
* UTC, would silently corrupt `Appointment.startAt`/`endAt` around a DST
|
||||
* boundary specifically (a fixed-offset zone would just be wrong by a
|
||||
* constant, easy to miss in a spot check; DST exposes it because the
|
||||
* *same* wall-clock local time produces two *different* UTC instants a
|
||||
* few days apart). These tests write real appointments spanning the UK's
|
||||
* 2026 DST transitions through Prisma and read them back, proving the
|
||||
* round-trip survives the real database, not just the pure `business-time`
|
||||
* helpers already covered in business-time.test.ts (business-time.test.ts
|
||||
* has no database in the loop at all).
|
||||
*/
|
||||
describe("DST boundary round-trip through MySQL (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("preserves the exact UTC instant across the UK spring-forward transition (2026-03-29)", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
// Same 10:00 local wall-clock time either side of the transition —
|
||||
// GMT (UTC+0) before, BST (UTC+1) after — see business-time.test.ts
|
||||
// for why these specific instants are correct.
|
||||
const beforeInstant = wallClockToInstant("2026-03-28", 10 * 60, business.timezone);
|
||||
const afterInstant = wallClockToInstant("2026-03-30", 10 * 60, business.timezone);
|
||||
expect(beforeInstant.toISOString()).toBe("2026-03-28T10:00:00.000Z");
|
||||
expect(afterInstant.toISOString()).toBe("2026-03-30T09:00:00.000Z");
|
||||
|
||||
const before = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: beforeInstant,
|
||||
endAt: new Date(beforeInstant.getTime() + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const after = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: afterInstant,
|
||||
endAt: new Date(afterInstant.getTime() + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [readBefore, readAfter] = await Promise.all([
|
||||
prisma.appointment.findUniqueOrThrow({ where: { id: before.id } }),
|
||||
prisma.appointment.findUniqueOrThrow({ where: { id: after.id } }),
|
||||
]);
|
||||
|
||||
expect(readBefore.startAt.getTime()).toBe(beforeInstant.getTime());
|
||||
expect(readAfter.startAt.getTime()).toBe(afterInstant.getTime());
|
||||
// The two rows are 47 real-world hours apart (a wall-clock hour was
|
||||
// skipped), not the naive 48 — this only holds if MySQL round-tripped
|
||||
// the actual UTC instants rather than silently applying a host-local
|
||||
// timezone conversion on the way in or out.
|
||||
expect((readAfter.startAt.getTime() - readBefore.startAt.getTime()) / 3_600_000).toBe(47);
|
||||
});
|
||||
|
||||
it("preserves the exact UTC instant across the UK autumn-back transition (2026-10-25)", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
// 10:00 local either side of the autumn transition — BST (UTC+1) before, GMT (UTC+0) after.
|
||||
const beforeInstant = wallClockToInstant("2026-10-24", 10 * 60, business.timezone);
|
||||
const afterInstant = wallClockToInstant("2026-10-26", 10 * 60, business.timezone);
|
||||
expect(beforeInstant.toISOString()).toBe("2026-10-24T09:00:00.000Z");
|
||||
expect(afterInstant.toISOString()).toBe("2026-10-26T10:00:00.000Z");
|
||||
|
||||
const before = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: beforeInstant,
|
||||
endAt: new Date(beforeInstant.getTime() + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const after = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: afterInstant,
|
||||
endAt: new Date(afterInstant.getTime() + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [readBefore, readAfter] = await Promise.all([
|
||||
prisma.appointment.findUniqueOrThrow({ where: { id: before.id } }),
|
||||
prisma.appointment.findUniqueOrThrow({ where: { id: after.id } }),
|
||||
]);
|
||||
|
||||
expect(readBefore.startAt.getTime()).toBe(beforeInstant.getTime());
|
||||
expect(readAfter.startAt.getTime()).toBe(afterInstant.getTime());
|
||||
// An extra wall-clock hour was repeated this time — 49 real-world hours apart, not 48.
|
||||
expect((readAfter.startAt.getTime() - readBefore.startAt.getTime()) / 3_600_000).toBe(49);
|
||||
});
|
||||
|
||||
it("createdAt/updatedAt (SQL CURRENT_TIMESTAMP) land within seconds of the real time, not skewed by a server-side timezone", async () => {
|
||||
const beforeWrite = Date.now();
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
|
||||
const afterWrite = Date.now();
|
||||
|
||||
// A server session not forced to UTC (see mysql-pool-config.ts) would skew
|
||||
// this by whole hours (the local UTC offset), not by network/test latency.
|
||||
expect(staff.createdAt.getTime()).toBeGreaterThanOrEqual(beforeWrite - 5_000);
|
||||
expect(staff.createdAt.getTime()).toBeLessThanOrEqual(afterWrite + 5_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
addDays,
|
||||
calendarDateOf,
|
||||
compareCalendarDates,
|
||||
eachCalendarDate,
|
||||
isAfterCalendarDate,
|
||||
isBeforeCalendarDate,
|
||||
minutesFromMidnight,
|
||||
todayIn,
|
||||
wallClockToInstant,
|
||||
weekdayOf,
|
||||
} from "../business-time";
|
||||
|
||||
describe("weekdayOf", () => {
|
||||
it("maps calendar dates to the correct Weekday", () => {
|
||||
// 2026-08-03 is a Monday.
|
||||
expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
|
||||
expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
|
||||
expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
|
||||
expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
|
||||
it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
|
||||
const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
|
||||
expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
|
||||
expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
|
||||
});
|
||||
|
||||
it("produces the documented UTC instant for a known GMT (winter) date", () => {
|
||||
// 2026-01-15 is outside BST — Europe/London is UTC+0.
|
||||
const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
|
||||
expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
|
||||
it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
|
||||
// Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
|
||||
const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
||||
expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
|
||||
|
||||
// After the transition: Europe/London is BST (UTC+1).
|
||||
const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
||||
expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
|
||||
|
||||
// A naive "always UTC+0" implementation would put these exactly 48h
|
||||
// apart; DST-correct math puts them 47h apart because a wall-clock
|
||||
// hour was skipped.
|
||||
const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
|
||||
expect(diffHours).toBe(47);
|
||||
});
|
||||
|
||||
it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
|
||||
const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
||||
const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
||||
expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
|
||||
expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar date arithmetic", () => {
|
||||
it("addDays is pure calendar-date math, independent of any zone", () => {
|
||||
expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
|
||||
expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
|
||||
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
|
||||
});
|
||||
|
||||
it("compares calendar dates correctly", () => {
|
||||
expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
|
||||
expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
|
||||
expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
|
||||
expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
|
||||
expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
|
||||
});
|
||||
|
||||
it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
|
||||
expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
|
||||
"2026-01-01",
|
||||
"2026-01-02",
|
||||
"2026-01-03",
|
||||
]);
|
||||
expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
|
||||
});
|
||||
|
||||
it("eachCalendarDate guards against absurdly large ranges", () => {
|
||||
expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("todayIn", () => {
|
||||
it("returns a well-formed calendar date", () => {
|
||||
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* All scheduling math (working hours, breaks, booking rules) happens in
|
||||
* two currencies: a `CalendarDate` ("2026-08-12", no time zone attached —
|
||||
* a calendar date is a calendar date regardless of zone) and
|
||||
* minutes-since-local-midnight (0–1439) for wall-clock times within a
|
||||
* day. Appointments themselves are stored as UTC `Date` instants (see
|
||||
* prisma/schema.prisma, Appointment.startAt/endAt). This module is the
|
||||
* ONLY place that converts between the two, via Luxon, so DST transitions
|
||||
* are handled correctly in exactly one place instead of scattered
|
||||
* `getHours()`/`setHours()` calls that silently assume the server's own
|
||||
* time zone. See docs/implementation-plan.md §14.
|
||||
*/
|
||||
|
||||
export type CalendarDate = string; // "YYYY-MM-DD"
|
||||
|
||||
const ISO_WEEKDAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function requireISODate(dt: DateTime, context: string): string {
|
||||
const iso = dt.toISODate();
|
||||
if (!iso) throw new Error(`business-time: invalid date in ${context}`);
|
||||
return iso;
|
||||
}
|
||||
|
||||
/** Which Weekday a calendar date falls on, independent of time zone (a calendar date has no time-of-day ambiguity). */
|
||||
export function weekdayOf(date: CalendarDate): Weekday {
|
||||
const dt = DateTime.fromISO(date, { zone: "utc" });
|
||||
return ISO_WEEKDAY_ORDER[dt.weekday - 1]!;
|
||||
}
|
||||
|
||||
/** Minutes since local midnight (0–1439) for a UTC instant, as observed in `zone`. */
|
||||
export function minutesFromMidnight(instant: Date, zone: string): number {
|
||||
const dt = DateTime.fromJSDate(instant, { zone });
|
||||
return dt.hour * 60 + dt.minute;
|
||||
}
|
||||
|
||||
/** The business-local calendar date a UTC instant falls on. */
|
||||
export function calendarDateOf(instant: Date, zone: string): CalendarDate {
|
||||
return requireISODate(DateTime.fromJSDate(instant, { zone }), "calendarDateOf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a calendar date + minutes-from-midnight (a business-local wall
|
||||
* clock time) into the correct UTC instant. DST-safe: Luxon resolves the
|
||||
* wall clock against the zone's actual offset for that specific date, not
|
||||
* a fixed offset — the same "10:00" on either side of a DST transition
|
||||
* produces different UTC instants, correctly.
|
||||
*/
|
||||
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
|
||||
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
|
||||
return dt.toJSDate();
|
||||
}
|
||||
|
||||
export function todayIn(zone: string): CalendarDate {
|
||||
return requireISODate(DateTime.now().setZone(zone), "todayIn");
|
||||
}
|
||||
|
||||
/** The UTC instant at which `date` begins in `zone` — the inclusive lower bound for "appointments that fall on this business day". */
|
||||
export function startOfBusinessDay(date: CalendarDate, zone: string): Date {
|
||||
return wallClockToInstant(date, 0, zone);
|
||||
}
|
||||
|
||||
/** The UTC instant at which `date` ends in `zone` (= the start of the next day) — the exclusive upper bound for "appointments that fall on this business day". */
|
||||
export function endOfBusinessDay(date: CalendarDate, zone: string): Date {
|
||||
return wallClockToInstant(date, 24 * 60, zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TimeOff`/date-only columns (`@db.Date`) are compared as UTC midnight
|
||||
* regardless of business time zone — a day off is the same calendar day
|
||||
* everywhere, it isn't a wall-clock-in-a-zone concept the way appointment
|
||||
* times are. Always go through this helper when reading or writing a
|
||||
* `@db.Date` column so the convention stays consistent both directions.
|
||||
*/
|
||||
export function calendarDateToUtcMidnight(date: CalendarDate): Date {
|
||||
return new Date(`${date}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Pure calendar-date arithmetic — deliberately zone-independent (adding whole days never crosses a DST boundary ambiguity the way wall-clock-to-instant conversion can). */
|
||||
export function addDays(date: CalendarDate, days: number): CalendarDate {
|
||||
return requireISODate(DateTime.fromISO(date, { zone: "utc" }).plus({ days }), "addDays");
|
||||
}
|
||||
|
||||
export function compareCalendarDates(a: CalendarDate, b: CalendarDate): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
export function isBeforeCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
|
||||
return compareCalendarDates(a, b) < 0;
|
||||
}
|
||||
|
||||
export function isAfterCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
|
||||
return compareCalendarDates(a, b) > 0;
|
||||
}
|
||||
|
||||
export function diffMinutes(a: Date, b: Date): number {
|
||||
return (a.getTime() - b.getTime()) / 60_000;
|
||||
}
|
||||
|
||||
/** Every CalendarDate from `from` to `to` inclusive. Throws if the range is absurdly large, as a guard against a caller accidentally passing unbounded input. */
|
||||
export function* eachCalendarDate(from: CalendarDate, to: CalendarDate): Generator<CalendarDate> {
|
||||
if (isAfterCalendarDate(from, to)) return;
|
||||
let cursor = from;
|
||||
let guard = 0;
|
||||
while (!isAfterCalendarDate(cursor, to)) {
|
||||
if (++guard > 366) throw new Error("eachCalendarDate: range exceeds 366 days");
|
||||
yield cursor;
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safeRedirectPath } from "../safe-redirect";
|
||||
|
||||
describe("safeRedirectPath", () => {
|
||||
it("accepts a plain internal path", () => {
|
||||
expect(safeRedirectPath("/book/details", "/home")).toBe("/book/details");
|
||||
});
|
||||
|
||||
it("falls back for null/undefined/empty input", () => {
|
||||
expect(safeRedirectPath(null, "/home")).toBe("/home");
|
||||
expect(safeRedirectPath(undefined, "/home")).toBe("/home");
|
||||
expect(safeRedirectPath("", "/home")).toBe("/home");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs", () => {
|
||||
expect(safeRedirectPath("//evil.com", "/home")).toBe("/home");
|
||||
});
|
||||
|
||||
it("rejects absolute URLs to another host", () => {
|
||||
expect(safeRedirectPath("https://evil.com/phish", "/home")).toBe("/home");
|
||||
expect(safeRedirectPath("http://evil.com", "/home")).toBe("/home");
|
||||
});
|
||||
|
||||
it("rejects a path not starting with a single slash", () => {
|
||||
expect(safeRedirectPath("book/details", "/home")).toBe("/home");
|
||||
expect(safeRedirectPath("javascript:alert(1)", "/home")).toBe("/home");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Merge Tailwind class lists safely — later classes win over earlier ones
|
||||
* for the same utility group (e.g. `cn("px-4", condition && "px-6")`).
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Validates a caller-supplied return path (a `callbackUrl` query param,
|
||||
* a booking-draft continuation target) is a safe, internal, relative
|
||||
* path before it's ever used in a redirect — see the brief's "Accept
|
||||
* only safe internal return paths. Prevent open redirects." Auth.js's
|
||||
* own `signIn()`/`redirectTo` already restricts to same-origin
|
||||
* internally, but this is checked explicitly rather than relied on
|
||||
* implicitly, since it guards a value that reached this code from a URL
|
||||
* query string, not from a trusted internal source.
|
||||
*/
|
||||
export function safeRedirectPath(path: string | null | undefined, fallback: string): string {
|
||||
if (!path) return fallback;
|
||||
// Must start with exactly one "/" — rejects "//evil.com" (protocol-relative),
|
||||
// "https://evil.com", and "javascript:..." in one check.
|
||||
if (!path.startsWith("/") || path.startsWith("//")) return fallback;
|
||||
if (path.includes("://")) return fallback;
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Generic version of the {valid, issues} shape scheduling/types.ts
|
||||
* pioneered in Phase 3 — new Phase 5 services (Staff, Loyalty) want the
|
||||
* same "typed error code + human message, never throw for an expected
|
||||
* business-rule failure" pattern but with their own closed code unions.
|
||||
* scheduling/types.ts itself is left untouched (its BookingValidationErrorCode
|
||||
* union is already relied on across Phases 3-4) rather than widened to
|
||||
* cover unrelated domains.
|
||||
*/
|
||||
export interface ValidationIssue<Code extends string> {
|
||||
code: Code;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult<Code extends string> {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue<Code>[];
|
||||
}
|
||||
|
||||
export function ok<Code extends string>(): ValidationResult<Code> {
|
||||
return { valid: true, issues: [] };
|
||||
}
|
||||
|
||||
export function fail<Code extends string>(
|
||||
...issues: ValidationIssue<Code>[]
|
||||
): ValidationResult<Code> {
|
||||
return { valid: false, issues };
|
||||
}
|
||||
|
||||
export function mergeValidation<Code extends string>(
|
||||
...results: ValidationResult<Code>[]
|
||||
): ValidationResult<Code> {
|
||||
const issues = results.flatMap((r) => r.issues);
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
Reference in new issue
Block a user