Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import type { Permission } from "@/generated/prisma/enums";
|
||||
|
||||
@@ -8,8 +9,14 @@ import type { Permission } from "@/generated/prisma/enums";
|
||||
* 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 async function getStaffPermissions(userId: string): Promise<Set<Permission>> {
|
||||
export const getStaffPermissions = cache(async (userId: string): Promise<Set<Permission>> => {
|
||||
const staffProfile = await prisma.staffProfile.findUnique({
|
||||
where: { userId },
|
||||
include: { role: { include: { permissions: true } } },
|
||||
@@ -17,7 +24,7 @@ export async function getStaffPermissions(userId: string): Promise<Set<Permissio
|
||||
|
||||
if (!staffProfile) return new Set();
|
||||
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
|
||||
}
|
||||
});
|
||||
|
||||
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
|
||||
const permissions = await getStaffPermissions(userId);
|
||||
|
||||
@@ -55,9 +55,15 @@ export async function clearDraft(): Promise<void> {
|
||||
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
|
||||
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
|
||||
|
||||
export async function writeLastBookingReference(appointmentId: string): Promise<void> {
|
||||
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({ appointmentId }));
|
||||
const token = sign(JSON.stringify(reference));
|
||||
store.set(CONFIRMATION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
@@ -67,15 +73,19 @@ export async function writeLastBookingReference(appointmentId: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLastBookingReference(): Promise<string | null> {
|
||||
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 };
|
||||
return typeof parsed.appointmentId === "string" ? parsed.appointmentId : null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Builds the `mariadb` pool config from `DATABASE_URL`, 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(databaseUrl: string | undefined) {
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is not set.");
|
||||
|
||||
// Accept both `mysql://` and `mariadb://` — the `mariadb` driver's own
|
||||
// string parser only recognizes the latter, but the `URL` global parses
|
||||
// either scheme fine since we build the pool config object ourselves.
|
||||
const url = new URL(databaseUrl);
|
||||
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
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,
|
||||
};
|
||||
}
|
||||
+19
-9
@@ -1,24 +1,34 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
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
|
||||
* `@prisma/adapter-pg` — switching to MySQL later means swapping this one
|
||||
* adapter (`@prisma/adapter-mariadb`), updating `datasource.provider` in
|
||||
* the schema, and re-running `prisma migrate dev`. No other file in the
|
||||
* app touches the database driver directly.
|
||||
* 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 PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
|
||||
@@ -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,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,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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user