Extract groombook/api from monorepo with CI workflow
- Add source code from apps/api - Add packages/db and packages/types workspace dependencies - Add GitHub Actions CI workflow (lint, typecheck, test, docker) - Generate pnpm-lock.yaml - Add .gitignore Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mutable state to control mock behavior per test
|
||||
let dbSelectResult: unknown[] = [];
|
||||
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
|
||||
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return "auth_provider_config";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "auth_provider_config", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => dbSelectResult,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of dbSelectResult) yield item;
|
||||
},
|
||||
0: dbSelectResult[0],
|
||||
length: dbSelectResult.length,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
authProviderConfig,
|
||||
eq: mockEq,
|
||||
decryptSecret: mockDecryptSecret,
|
||||
};
|
||||
});
|
||||
|
||||
async function reimportAuth() {
|
||||
vi.resetModules();
|
||||
vi.doMock("@groombook/db", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => dbSelectResult,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of dbSelectResult) yield item;
|
||||
},
|
||||
0: dbSelectResult[0],
|
||||
length: dbSelectResult.length,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
authProviderConfig: {},
|
||||
eq: mockEq,
|
||||
decryptSecret: mockDecryptSecret,
|
||||
}));
|
||||
const mod = await import("../lib/auth.js");
|
||||
return mod;
|
||||
}
|
||||
|
||||
describe("auth init", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
dbSelectResult = [];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("falls back to env vars when DB returns empty", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
OIDC_ISSUER: "https://issuer.example.com",
|
||||
OIDC_CLIENT_ID: "test-client-id",
|
||||
OIDC_CLIENT_SECRET: "test-client-secret",
|
||||
BETTER_AUTH_SECRET: "test-secret",
|
||||
BETTER_AUTH_URL: "http://localhost:3000",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await initAuth();
|
||||
expect(getAuth()).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses DB config and decrypts clientSecret when DB has enabled provider", async () => {
|
||||
const dbConfig = {
|
||||
id: "config-id",
|
||||
providerId: "okta",
|
||||
displayName: "Okta",
|
||||
issuerUrl: "https://okta.example.com",
|
||||
internalBaseUrl: null,
|
||||
clientId: "okta-client-id",
|
||||
clientSecret: "encrypted:okta-secret",
|
||||
scopes: "openid profile email",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
dbSelectResult = [dbConfig];
|
||||
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
BETTER_AUTH_SECRET: "test-secret",
|
||||
BETTER_AUTH_URL: "http://localhost:3000",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await initAuth();
|
||||
expect(getAuth()).toBeDefined();
|
||||
expect(mockDecryptSecret).toHaveBeenCalledWith("encrypted:okta-secret");
|
||||
});
|
||||
|
||||
it("throws when BETTER_AUTH_SECRET is missing and AUTH_DISABLED is not set", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
OIDC_ISSUER: "",
|
||||
OIDC_CLIENT_ID: "",
|
||||
OIDC_CLIENT_SECRET: "",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
delete process.env.AUTH_DISABLED;
|
||||
|
||||
const { initAuth } = await reimportAuth();
|
||||
await expect(initAuth()).rejects.toThrow(
|
||||
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds placeholder auth when AUTH_DISABLED=true without throwing", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
AUTH_DISABLED: "true",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await expect(initAuth()).resolves.toBeUndefined();
|
||||
expect(getAuth()).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { authProviderRouter } from "../routes/authProvider.js";
|
||||
|
||||
// ─── Mock auth module ─────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../lib/auth.js", () => ({
|
||||
reinitAuth: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockStaff {
|
||||
id: string;
|
||||
role: string;
|
||||
isSuperUser: boolean;
|
||||
}
|
||||
|
||||
// ─── Mock DB state ────────────────────────────────────────────────────────────
|
||||
|
||||
let dbRows: Record<string, unknown>[] = [];
|
||||
let deletedRows: string[] = [];
|
||||
let insertedRows: Record<string, unknown>[] = [];
|
||||
let encryptCalls: string[] = [];
|
||||
|
||||
function resetMock() {
|
||||
dbRows = [];
|
||||
deletedRows = [];
|
||||
insertedRows = [];
|
||||
encryptCalls = [];
|
||||
}
|
||||
|
||||
// ─── Mock staff context ───────────────────────────────────────────────────────
|
||||
|
||||
const mockSuperUser: MockStaff = { id: "staff-1", role: "manager", isSuperUser: true };
|
||||
const mockManager: MockStaff = { id: "staff-2", role: "manager", isSuperUser: false };
|
||||
const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: false };
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "auth_provider_config";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "auth_provider_config", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => [...dbRows],
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of dbRows) yield item;
|
||||
},
|
||||
0: dbRows[0],
|
||||
length: dbRows.length,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedRows.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
delete: () => {
|
||||
// Execute immediately - route doesn't chain .returning()
|
||||
deletedRows.push("all");
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
transaction: <T>(fn: (tx: {
|
||||
delete: () => Promise<unknown>;
|
||||
insert: () => { values: (v: Record<string, unknown>) => { returning: () => T[] } };
|
||||
}) => Promise<T>) => {
|
||||
const tx = {
|
||||
delete: () => { deletedRows.push("all"); return Promise.resolve([]); },
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }] as T[],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return fn(tx);
|
||||
},
|
||||
}),
|
||||
authProviderConfig,
|
||||
eq: (_col: unknown, _val: unknown) => ({ col: _col, val: _val }),
|
||||
encryptSecret: (val: string) => {
|
||||
encryptCalls.push(val);
|
||||
return `encrypted:${val}`;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeApp(staff: MockStaff | null) {
|
||||
const app = new Hono();
|
||||
// Inject staff context + super user guard per route
|
||||
// Must match both exact path and wildcard subpaths
|
||||
app.use(
|
||||
"/admin/auth-provider/*",
|
||||
async (c, next) => {
|
||||
if (!staff) {
|
||||
return c.json({ error: "Forbidden: no staff record resolved" }, 403);
|
||||
}
|
||||
if (!staff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: super user privileges required" }, 403);
|
||||
}
|
||||
(c as any).set("staff", staff);
|
||||
await next();
|
||||
}
|
||||
);
|
||||
app.route("/admin/auth-provider", authProviderRouter as unknown as Hono);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function get<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
|
||||
const res = await app.request(path, { method: "GET" }, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function put<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
|
||||
const res = await app.request(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function post<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function del<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
|
||||
const res = await app.request(path, { method: "DELETE" }, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /admin/auth-provider", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns 404 when no provider configured", async () => {
|
||||
dbRows = [];
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
|
||||
expect(status).toBe(404);
|
||||
expect(body.error).toBe("No auth provider configured");
|
||||
});
|
||||
|
||||
it("returns config with secret redacted", async () => {
|
||||
dbRows = [{
|
||||
id: "prov-1",
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
internalBaseUrl: null,
|
||||
clientId: "client-123",
|
||||
clientSecret: "encrypted:secret",
|
||||
scopes: "openid profile email",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}];
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(body.clientSecret).toBe("••••••••");
|
||||
expect(body.providerId).toBe("authentik");
|
||||
});
|
||||
|
||||
it("returns 403 when not super user", async () => {
|
||||
dbRows = [];
|
||||
const app = makeApp(mockManager);
|
||||
const { status } = await get(app, "/admin/auth-provider", mockManager);
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /admin/auth-provider", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("stores encrypted secret", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await put(app, "/admin/auth-provider", {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik SSO",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
clientId: "my-client",
|
||||
clientSecret: "my-secret",
|
||||
scopes: "openid profile email",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(encryptCalls).toContain("my-secret");
|
||||
expect(body.clientSecret).toBe("••••••••");
|
||||
expect(body.providerId).toBe("authentik");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid schema", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status } = await put(app, "/admin/auth-provider", {
|
||||
providerId: "",
|
||||
issuerUrl: "not-a-url",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /admin/auth-provider/test", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns ok=false for unreachable issuer", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await post(app, "/admin/auth-provider/test", {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik",
|
||||
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
|
||||
clientId: "client",
|
||||
scopes: "openid profile email",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toBeTruthy();
|
||||
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
|
||||
|
||||
it("returns 400 for missing clientSecret (not required for test)", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status } = await post(app, "/admin/auth-provider/test", {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
clientId: "client",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(200); // clientSecret omitted intentionally for test
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /admin/auth-provider", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("deletes all config rows", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await del(app, "/admin/auth-provider", mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(deletedRows).toContain("all");
|
||||
});
|
||||
|
||||
it("returns 403 when not super user", async () => {
|
||||
const app = makeApp(mockGroomer);
|
||||
const { status } = await del(app, "/admin/auth-provider", mockGroomer);
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateIcalToken } from "../routes/calendar.js";
|
||||
|
||||
describe("generateIcalToken", () => {
|
||||
it("generates a 64-character hex token", () => {
|
||||
const token = generateIcalToken();
|
||||
expect(token).toHaveLength(64);
|
||||
expect(token).toMatch(/^[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
it("generates unique tokens", () => {
|
||||
const token1 = generateIcalToken();
|
||||
const token2 = generateIcalToken();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIVE_CLIENT = {
|
||||
id: "client-uuid-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
phone: "555-1234",
|
||||
address: "1 Main St",
|
||||
notes: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const DISABLED_CLIENT = {
|
||||
...ACTIVE_CLIENT,
|
||||
id: "client-uuid-2",
|
||||
name: "Bob",
|
||||
status: "disabled",
|
||||
disabledAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
let deletedId: string | null = null;
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
appointmentRows = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
deletedId = null;
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const clients = new Proxy(
|
||||
{ _name: "clients" },
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const tableName = (table as { _name?: string })._name;
|
||||
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
||||
return makeChainable(rows);
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updatedValues.push(vals);
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0
|
||||
? [{ ...selectRows[0], ...vals }]
|
||||
: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => {
|
||||
deletedId = "client-uuid-1";
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0 ? [selectRows[0]] : [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clients,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── App setup ────────────────────────────────────────────────────────────────
|
||||
|
||||
const { clientsRouter } = await import("../routes/clients.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/clients", clientsRouter);
|
||||
|
||||
function jsonRequest(method: string, path: string, body?: unknown) {
|
||||
return app.request(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
// ─── GET / ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /clients", () => {
|
||||
it("returns active clients", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await app.request("/clients");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns all clients when includeDisabled=true", async () => {
|
||||
selectRows = [ACTIVE_CLIENT, DISABLED_CLIENT];
|
||||
const res = await app.request("/clients?includeDisabled=true");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no clients exist", async () => {
|
||||
selectRows = [];
|
||||
const res = await app.request("/clients");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /:id ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /clients/:id", () => {
|
||||
it("returns a single client", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await app.request("/clients/client-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toBe("client-uuid-1");
|
||||
expect(body.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns 404 for a nonexistent client", async () => {
|
||||
selectRows = [];
|
||||
const res = await app.request("/clients/nonexistent");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST / ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /clients", () => {
|
||||
it("creates a client with valid data", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", {
|
||||
name: "Charlie",
|
||||
email: "charlie@example.com",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.name).toBe("Charlie");
|
||||
expect(insertedValues).toHaveLength(1);
|
||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||
});
|
||||
|
||||
it("creates a client with name and email", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]!.name).toBe("Dana");
|
||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||
});
|
||||
|
||||
it("rejects empty name", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid email format", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", {
|
||||
name: "Eve",
|
||||
email: "not-an-email",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects missing body", async () => {
|
||||
const res = await app.request("/clients", { method: "POST" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PATCH /:id ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PATCH /clients/:id", () => {
|
||||
it("updates client fields", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await jsonRequest("PATCH", "/clients/client-uuid-1", {
|
||||
name: "Alice Updated",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.name).toBe("Alice Updated");
|
||||
expect(updatedValues[0]!.name).toBe("Alice Updated");
|
||||
});
|
||||
|
||||
it("sets disabledAt when status is set to disabled", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
await jsonRequest("PATCH", "/clients/client-uuid-1", {
|
||||
status: "disabled",
|
||||
});
|
||||
expect(updatedValues[0]!.status).toBe("disabled");
|
||||
expect(updatedValues[0]!.disabledAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears disabledAt when re-enabling", async () => {
|
||||
selectRows = [DISABLED_CLIENT];
|
||||
await jsonRequest("PATCH", "/clients/client-uuid-2", {
|
||||
status: "active",
|
||||
});
|
||||
expect(updatedValues[0]!.disabledAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 when client not found", async () => {
|
||||
selectRows = [];
|
||||
const res = await jsonRequest("PATCH", "/clients/nonexistent", {
|
||||
name: "Ghost",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /:id ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /clients/:id", () => {
|
||||
it("requires ?confirm=true", async () => {
|
||||
const res = await app.request("/clients/client-uuid-1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/confirm/i);
|
||||
});
|
||||
|
||||
it("deletes a client with ?confirm=true", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await app.request("/clients/client-uuid-1?confirm=true", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(deletedId).toBe("client-uuid-1");
|
||||
});
|
||||
|
||||
it("returns 404 when client not found", async () => {
|
||||
selectRows = [];
|
||||
const res = await app.request("/clients/nonexistent?confirm=true", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock appointment data ────────────────────────────────────────────────────
|
||||
|
||||
const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now
|
||||
const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
|
||||
const BASE_APPT = {
|
||||
id: "appt-uuid-1",
|
||||
clientId: "client-uuid-1",
|
||||
petId: "pet-uuid-1",
|
||||
serviceId: "service-uuid-1",
|
||||
staffId: "staff-uuid-1",
|
||||
batherStaffId: null,
|
||||
status: "scheduled" as const,
|
||||
startTime: FUTURE_TIME,
|
||||
endTime: new Date(FUTURE_TIME.getTime() + 3600_000),
|
||||
notes: null,
|
||||
priceCents: null,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "pending",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: "valid-token-abc123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||
|
||||
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||
let lastUpdate: Record<string, unknown> = {};
|
||||
|
||||
function resetMock() {
|
||||
mockAppt = { ...BASE_APPT };
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => (mockAppt ? [mockAppt] : []),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
lastUpdate = { ...vals };
|
||||
if (mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||
}
|
||||
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
and: (..._clauses: unknown[]) => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Book router (tokenized endpoints) ───────────────────────────────────────
|
||||
|
||||
async function makeBookApp() {
|
||||
const { bookRouter } = await import("../routes/book.js");
|
||||
const app = new Hono();
|
||||
app.route("/api/book", bookRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Appointments router (portal endpoints) ────────────────────────────────
|
||||
|
||||
async function makeAppointmentsApp() {
|
||||
const { appointmentsRouter } = await import("../routes/appointments.js");
|
||||
const app = new Hono();
|
||||
app.route("/api/appointments", appointmentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests: tokenized confirm endpoint ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/book/confirm/:token", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeBookApp();
|
||||
});
|
||||
|
||||
it("redirects to /booking/confirmed on valid token and future appointment", async () => {
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/confirmed");
|
||||
});
|
||||
|
||||
it("sets confirmationStatus to confirmed", async () => {
|
||||
await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(lastUpdate.confirmationStatus).toBe("confirmed");
|
||||
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when token not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/book/confirm/bad-token");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is in the past", async () => {
|
||||
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/confirmed idempotently when already confirmed", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/confirmed");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: tokenized cancel endpoint ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/book/cancel/:token", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeBookApp();
|
||||
});
|
||||
|
||||
it("redirects to /booking/cancelled on valid token and future appointment", async () => {
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/cancelled");
|
||||
});
|
||||
|
||||
it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => {
|
||||
await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
|
||||
expect(lastUpdate.confirmationToken).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when token not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/book/cancel/bad-token");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is in the past", async () => {
|
||||
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: portal confirm endpoint ──────────────────────────────────────────
|
||||
|
||||
describe("POST /api/appointments/:id/confirm", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeAppointmentsApp();
|
||||
});
|
||||
|
||||
it("confirms a pending appointment", async () => {
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("confirmed");
|
||||
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/appointments/nonexistent/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 200 idempotently when appointment is already confirmed", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: portal cancel endpoint ───────────────────────────────────────────
|
||||
|
||||
describe("POST /api/appointments/:id/cancel", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeAppointmentsApp();
|
||||
});
|
||||
|
||||
it("cancels a pending appointment and nullifies the token", async () => {
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
|
||||
expect(lastUpdate.confirmationToken).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/appointments/nonexistent/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("can cancel a confirmed appointment", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: token generation helper ──────────────────────────────────────────
|
||||
|
||||
describe("generateConfirmationToken", () => {
|
||||
it("generates a 64-character hex string", async () => {
|
||||
const { generateConfirmationToken } = await import("../routes/appointments.js");
|
||||
const token = generateConfirmationToken();
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("generates unique tokens on each call", async () => {
|
||||
const { generateConfirmationToken } = await import("../routes/appointments.js");
|
||||
const t1 = generateConfirmationToken();
|
||||
const t2 = generateConfirmationToken();
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: reminder email with action links ──────────────────────────────────
|
||||
|
||||
describe("buildReminderEmail with confirmation token", () => {
|
||||
it("includes confirm and cancel links when token is provided", async () => {
|
||||
const { buildReminderEmail } = await import("../services/email.js");
|
||||
const mail = buildReminderEmail(
|
||||
"client@example.com",
|
||||
{
|
||||
clientName: "Jane",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: null,
|
||||
startTime: new Date(),
|
||||
},
|
||||
24,
|
||||
"abc123token"
|
||||
);
|
||||
expect(mail.text).toContain("abc123token");
|
||||
expect(mail.html as string).toContain("abc123token");
|
||||
expect(mail.html as string).toContain("Confirm Appointment");
|
||||
expect(mail.html as string).toContain("Cancel Appointment");
|
||||
});
|
||||
|
||||
it("omits action links when no token is provided", async () => {
|
||||
const { buildReminderEmail } = await import("../services/email.js");
|
||||
const mail = buildReminderEmail(
|
||||
"client@example.com",
|
||||
{
|
||||
clientName: "Jane",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: null,
|
||||
startTime: new Date(),
|
||||
},
|
||||
24,
|
||||
null
|
||||
);
|
||||
expect(mail.html as string).not.toContain("Confirm Appointment");
|
||||
expect(mail.html as string).not.toContain("Cancel Appointment");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { encryptSecret, decryptSecret } from "@groombook/db";
|
||||
|
||||
describe("encryptSecret / decryptSecret", () => {
|
||||
const originalEnv = process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret-key-for-unit-tests-32bytes!";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.BETTER_AUTH_SECRET = originalEnv;
|
||||
} else {
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypts and decrypts a simple secret", () => {
|
||||
const plaintext = "my-client-secret-123";
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("produces output in salt:iv:ciphertext:authTag format", () => {
|
||||
const encrypted = encryptSecret("test");
|
||||
const parts = encrypted.split(":");
|
||||
|
||||
expect(parts).toHaveLength(4);
|
||||
// Each part should be valid base64
|
||||
parts.forEach((part) => {
|
||||
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("different plaintexts produce different ciphertexts", () => {
|
||||
const encrypted1 = encryptSecret("secret1");
|
||||
const encrypted2 = encryptSecret("secret2");
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
|
||||
it("same plaintext produces different ciphertexts (due to random IV)", () => {
|
||||
const encrypted1 = encryptSecret("same-secret");
|
||||
const encrypted2 = encryptSecret("same-secret");
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
// But both should decrypt to the same value
|
||||
expect(decryptSecret(encrypted1)).toBe("same-secret");
|
||||
expect(decryptSecret(encrypted2)).toBe("same-secret");
|
||||
});
|
||||
|
||||
it("throws if BETTER_AUTH_SECRET is not set", () => {
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
expect(() => encryptSecret("test")).toThrow(
|
||||
"BETTER_AUTH_SECRET environment variable is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
||||
const encrypted = encryptSecret("test");
|
||||
// Replace the last two parts with a single part to create a 2-part string
|
||||
// This can't be parsed as either legacy (3 parts) or new (4 parts) format
|
||||
const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
|
||||
|
||||
expect(() => decryptSecret(invalid)).toThrow(
|
||||
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty string secret", () => {
|
||||
const plaintext = "";
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles unicode secret", () => {
|
||||
const plaintext = "密码🔐中文";
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles long secret", () => {
|
||||
const plaintext = "a".repeat(10000);
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildConfirmationEmail,
|
||||
buildReminderEmail,
|
||||
} from "../services/email.js";
|
||||
|
||||
const START = new Date("2026-03-25T15:00:00Z");
|
||||
|
||||
const BASE = {
|
||||
clientName: "Jane Doe",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: "Alex",
|
||||
startTime: START,
|
||||
};
|
||||
|
||||
describe("buildConfirmationEmail", () => {
|
||||
it("addresses the correct recipient", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.to).toBe("jane@example.com");
|
||||
});
|
||||
|
||||
it("includes the pet name in the subject", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.subject).toContain("Biscuit");
|
||||
});
|
||||
|
||||
it("includes confirmation wording in subject", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.subject).toMatch(/confirmed/i);
|
||||
});
|
||||
|
||||
it("includes client name in the plain text body", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.text).toContain("Jane Doe");
|
||||
});
|
||||
|
||||
it("includes service name in plain text body", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.text).toContain("Full Groom");
|
||||
});
|
||||
|
||||
it("includes groomer name when provided", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.text).toContain("Alex");
|
||||
});
|
||||
|
||||
it("omits groomer when groomerName is null", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", {
|
||||
...BASE,
|
||||
groomerName: null,
|
||||
});
|
||||
expect(mail.text).not.toContain("with ");
|
||||
});
|
||||
|
||||
it("includes HTML body", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.html).toBeTruthy();
|
||||
expect(mail.html).toContain("Biscuit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReminderEmail", () => {
|
||||
it("addresses the correct recipient", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.to).toBe("jane@example.com");
|
||||
});
|
||||
|
||||
it("says 'tomorrow' for 24-hour reminder", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.subject).toContain("tomorrow");
|
||||
expect(mail.text).toContain("tomorrow");
|
||||
});
|
||||
|
||||
it("says 'in X hours' for sub-24-hour reminders", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 2);
|
||||
expect(mail.subject).toContain("in 2 hours");
|
||||
expect(mail.text).toContain("in 2 hours");
|
||||
});
|
||||
|
||||
it("includes pet name in subject", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.subject).toContain("Biscuit");
|
||||
});
|
||||
|
||||
it("includes service name in plain text body", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.text).toContain("Full Groom");
|
||||
});
|
||||
|
||||
it("includes groomer name when provided", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.text).toContain("Alex");
|
||||
});
|
||||
|
||||
it("omits groomer when groomerName is null", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", { ...BASE, groomerName: null }, 24);
|
||||
expect(mail.text).not.toContain("with ");
|
||||
});
|
||||
|
||||
it("includes HTML body", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.html).toBeTruthy();
|
||||
expect(mail.html).toContain("Biscuit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
resetFactoryCounters,
|
||||
buildStaff,
|
||||
buildClient,
|
||||
buildPet,
|
||||
buildService,
|
||||
buildAppointment,
|
||||
} from "@groombook/db/factories";
|
||||
|
||||
describe("resetFactoryCounters", () => {
|
||||
it("resets all counters so IDs restart from 1", () => {
|
||||
buildStaff();
|
||||
buildStaff();
|
||||
buildClient();
|
||||
resetFactoryCounters();
|
||||
|
||||
const staff = buildStaff();
|
||||
const client = buildClient();
|
||||
|
||||
expect(staff.id).toBe("staff-1");
|
||||
expect(client.id).toBe("client-1");
|
||||
});
|
||||
|
||||
it("resets counters for every entity type", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
buildAppointment({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: service.id,
|
||||
staffId: "staff-1",
|
||||
});
|
||||
|
||||
resetFactoryCounters();
|
||||
|
||||
expect(buildStaff().id).toBe("staff-1");
|
||||
expect(buildClient().id).toBe("client-1");
|
||||
expect(buildService().id).toBe("service-1");
|
||||
const c = buildClient();
|
||||
expect(buildPet({ clientId: c.id }).id).toBe("pet-1");
|
||||
const s = buildService();
|
||||
const p = buildPet({ clientId: c.id });
|
||||
expect(
|
||||
buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id
|
||||
).toBe("appointment-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("counter determinism", () => {
|
||||
beforeEach(() => {
|
||||
resetFactoryCounters();
|
||||
});
|
||||
|
||||
it("increments staff IDs sequentially", () => {
|
||||
expect(buildStaff().id).toBe("staff-1");
|
||||
expect(buildStaff().id).toBe("staff-2");
|
||||
expect(buildStaff().id).toBe("staff-3");
|
||||
});
|
||||
|
||||
it("increments client IDs sequentially", () => {
|
||||
expect(buildClient().id).toBe("client-1");
|
||||
expect(buildClient().id).toBe("client-2");
|
||||
});
|
||||
|
||||
it("increments pet IDs sequentially", () => {
|
||||
const client = buildClient();
|
||||
expect(buildPet({ clientId: client.id }).id).toBe("pet-1");
|
||||
expect(buildPet({ clientId: client.id }).id).toBe("pet-2");
|
||||
});
|
||||
|
||||
it("increments service IDs sequentially", () => {
|
||||
expect(buildService().id).toBe("service-1");
|
||||
expect(buildService().id).toBe("service-2");
|
||||
});
|
||||
|
||||
it("increments appointment IDs sequentially", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" };
|
||||
|
||||
expect(buildAppointment(required).id).toBe("appointment-1");
|
||||
expect(buildAppointment(required).id).toBe("appointment-2");
|
||||
});
|
||||
|
||||
it("each entity type maintains its own independent counter", () => {
|
||||
buildStaff();
|
||||
buildStaff();
|
||||
buildClient();
|
||||
|
||||
// staff counter is at 2; client counter is at 1
|
||||
expect(buildStaff().id).toBe("staff-3");
|
||||
expect(buildClient().id).toBe("client-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("override merging", () => {
|
||||
beforeEach(() => {
|
||||
resetFactoryCounters();
|
||||
});
|
||||
|
||||
it("buildStaff applies overrides over defaults", () => {
|
||||
const staff = buildStaff({ role: "manager", name: "Boss" });
|
||||
|
||||
expect(staff.role).toBe("manager");
|
||||
expect(staff.name).toBe("Boss");
|
||||
expect(staff.id).toBe("staff-1");
|
||||
expect(staff.active).toBe(true); // default preserved
|
||||
});
|
||||
|
||||
it("buildStaff id override is respected without disrupting the counter", () => {
|
||||
const staff = buildStaff({ id: "custom-id" });
|
||||
|
||||
expect(staff.id).toBe("custom-id");
|
||||
// counter still ticked — next call gets staff-2
|
||||
expect(buildStaff().id).toBe("staff-2");
|
||||
});
|
||||
|
||||
it("buildClient applies overrides over defaults", () => {
|
||||
const client = buildClient({ name: "Alice Smith", emailOptOut: true });
|
||||
|
||||
expect(client.name).toBe("Alice Smith");
|
||||
expect(client.emailOptOut).toBe(true);
|
||||
expect(client.status).toBe("active"); // default preserved
|
||||
});
|
||||
|
||||
it("buildPet merges overrides and sets clientId from required arg", () => {
|
||||
const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" });
|
||||
|
||||
expect(pet.clientId).toBe("client-99");
|
||||
expect(pet.name).toBe("Fluffy");
|
||||
expect(pet.breed).toBe("Poodle");
|
||||
expect(pet.species).toBe("Dog"); // default preserved
|
||||
});
|
||||
|
||||
it("buildService applies overrides over defaults", () => {
|
||||
const service = buildService({ basePriceCents: 9900, active: false });
|
||||
|
||||
expect(service.basePriceCents).toBe(9900);
|
||||
expect(service.active).toBe(false);
|
||||
expect(service.durationMinutes).toBe(60); // default preserved
|
||||
});
|
||||
|
||||
it("buildAppointment applies overrides over defaults", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
const appt = buildAppointment({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: service.id,
|
||||
staffId: "staff-1",
|
||||
status: "confirmed",
|
||||
notes: "allergic to lavender",
|
||||
});
|
||||
|
||||
expect(appt.status).toBe("confirmed");
|
||||
expect(appt.notes).toBe("allergic to lavender");
|
||||
expect(appt.clientId).toBe(client.id);
|
||||
expect(appt.petId).toBe(pet.id);
|
||||
// defaults preserved
|
||||
expect(appt.batherStaffId).toBeNull();
|
||||
expect(appt.priceCents).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppointment required fields", () => {
|
||||
beforeEach(() => {
|
||||
resetFactoryCounters();
|
||||
});
|
||||
|
||||
it("produces a fully-populated AppointmentRow", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
const appt = buildAppointment({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: service.id,
|
||||
staffId: "staff-1",
|
||||
});
|
||||
|
||||
expect(appt.id).toBeDefined();
|
||||
expect(appt.clientId).toBe(client.id);
|
||||
expect(appt.petId).toBe(pet.id);
|
||||
expect(appt.serviceId).toBe(service.id);
|
||||
expect(appt.staffId).toBe("staff-1");
|
||||
expect(appt.startTime).toBeInstanceOf(Date);
|
||||
expect(appt.endTime).toBeInstanceOf(Date);
|
||||
expect(appt.status).toBe("scheduled");
|
||||
expect(appt.batherStaffId).toBeNull();
|
||||
expect(appt.seriesId).toBeNull();
|
||||
expect(appt.seriesIndex).toBeNull();
|
||||
expect(appt.groupId).toBeNull();
|
||||
expect(appt.notes).toBeNull();
|
||||
expect(appt.priceCents).toBeNull();
|
||||
expect(appt.createdAt).toBeInstanceOf(Date);
|
||||
expect(appt.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
// TypeScript compile-time enforcement: omitting any required field produces a type error.
|
||||
// The overrides parameter type is `Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }`.
|
||||
// The test below verifies the type signature is correct by using @ts-expect-error.
|
||||
it("type error when required fields are missing — compile-time enforcement", () => {
|
||||
// @ts-expect-error clientId is required
|
||||
buildAppointment({ petId: "p", serviceId: "s", staffId: "st" });
|
||||
// @ts-expect-error petId is required
|
||||
buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" });
|
||||
// @ts-expect-error serviceId is required
|
||||
buildAppointment({ clientId: "c", petId: "p", staffId: "st" });
|
||||
// @ts-expect-error staffId is required
|
||||
buildAppointment({ clientId: "c", petId: "p", serviceId: "s" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Groomer Isolation Tests
|
||||
*
|
||||
* Validates row-level data scoping for the groomer role.
|
||||
*
|
||||
* The role guard tests verify the core groomer identification logic.
|
||||
* Integration tests with the real database validate the full filter behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Mock staff ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
role: "groomer",
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
};
|
||||
|
||||
const RECEPTIONIST: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-receptionist-id",
|
||||
oidcSub: "oidc-receptionist-sub",
|
||||
role: "receptionist",
|
||||
name: "Receptionist Rita",
|
||||
email: "receptionist@example.com",
|
||||
};
|
||||
|
||||
// ─── Role guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The isGroomer guard (staffRow?.role === "groomer") is the foundation of
|
||||
* all row-level filtering in appointments.ts, clients.ts, and pets.ts.
|
||||
* These tests verify it handles all roles correctly.
|
||||
*/
|
||||
describe("Groomer role guard", () => {
|
||||
const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer";
|
||||
|
||||
it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false));
|
||||
it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false));
|
||||
it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true));
|
||||
|
||||
/** Safe fallback when staff context is not set (e.g., missing auth middleware) */
|
||||
it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false));
|
||||
});
|
||||
|
||||
// ─── Groomer filter data shapes ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* These constants match the shape used in route handlers to validate
|
||||
* the groomer filter conditions:
|
||||
* or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id))
|
||||
* This verifies the groomer can see appointments they own OR bathe.
|
||||
*/
|
||||
describe("Groomer appointment filter data", () => {
|
||||
const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null };
|
||||
const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id };
|
||||
const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null };
|
||||
|
||||
it("groomer appointment has groomer staffId", () => {
|
||||
expect(GROOMER_APPT.staffId).toBe(GROOMER.id);
|
||||
expect(GROOMER_APPT.batherStaffId).toBeNull();
|
||||
});
|
||||
|
||||
it("groomer can see appointment where they are the bather", () => {
|
||||
expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id);
|
||||
expect(BATHER_APPT.staffId).toBe(MANAGER.id);
|
||||
});
|
||||
|
||||
it("other appointment is not assigned to groomer", () => {
|
||||
expect(OTHER_APPT.staffId).toBe(MANAGER.id);
|
||||
expect(OTHER_APPT.batherStaffId).toBeNull();
|
||||
});
|
||||
|
||||
it("filter: groomer sees only their appointments", () => {
|
||||
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
|
||||
const groomerView = all.filter(
|
||||
(a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id
|
||||
);
|
||||
expect(groomerView).toHaveLength(2);
|
||||
expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]);
|
||||
});
|
||||
|
||||
it("filter: manager sees all appointments", () => {
|
||||
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
|
||||
expect(all).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { buildStaff } from "@groombook/db/factories";
|
||||
|
||||
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
|
||||
|
||||
const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" });
|
||||
const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" });
|
||||
|
||||
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60_000);
|
||||
const pastDate = () => new Date(Date.now() - 5 * 60_000);
|
||||
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
staffId: MANAGER_STAFF.id,
|
||||
clientId: CLIENT.id,
|
||||
reason: "Testing portal",
|
||||
status: "active" as string,
|
||||
startedAt: new Date(),
|
||||
endedAt: null as Date | null,
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuditLog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "audit-uuid-1",
|
||||
sessionId: "session-uuid-1",
|
||||
action: "session_started",
|
||||
pageVisited: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Queue-based mock DB ─────────────────────────────────────────────────────
|
||||
|
||||
let selectQueue: unknown[][] = [];
|
||||
let insertedValues: Array<{ table: string; vals: unknown }> = [];
|
||||
let updatedValues: Array<{ table: string; set: Record<string, unknown> }> = [];
|
||||
|
||||
function resetMock() {
|
||||
selectQueue = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a chainable object that acts like a drizzle query result.
|
||||
* Any method call (.where, .orderBy, .limit) returns the same chainable,
|
||||
* but the FIRST terminal call (.where or .orderBy when no further chain)
|
||||
* resolves the result from the queue.
|
||||
*
|
||||
* To handle `.where().orderBy()` chaining, we make the result of shifting
|
||||
* also have .orderBy/.limit methods, and we wrap the shifted array in a proxy.
|
||||
*/
|
||||
function makeChainableResult(data: unknown[]): unknown {
|
||||
// Make data act both as array and as chainable
|
||||
const arr = [...data];
|
||||
return new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "orderBy" || prop === "limit") {
|
||||
// Further chaining just returns the same data
|
||||
return () => makeChainableResult(data);
|
||||
}
|
||||
// @ts-expect-error proxy access
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeTable(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return name;
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: name, column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => {
|
||||
const data = selectQueue.shift() ?? [];
|
||||
return makeChainableResult(data);
|
||||
},
|
||||
orderBy: () => {
|
||||
const data = selectQueue.shift() ?? [];
|
||||
return makeChainableResult(data);
|
||||
},
|
||||
limit: () => {
|
||||
const data = selectQueue.shift() ?? [];
|
||||
return makeChainableResult(data);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
insert: (table: { _name: string }) => ({
|
||||
values: (vals: unknown) => {
|
||||
const tableName = table?._name ?? "unknown";
|
||||
insertedValues.push({ table: tableName, vals });
|
||||
return {
|
||||
returning: () => {
|
||||
if (tableName === "sessions") {
|
||||
return [makeSession(vals as Record<string, unknown>)];
|
||||
}
|
||||
return [makeAuditLog(vals as Record<string, unknown>)];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: (table: { _name: string }) => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
const tableName = table?._name ?? "unknown";
|
||||
updatedValues.push({ table: tableName, set: data });
|
||||
return {
|
||||
returning: () => {
|
||||
const base = makeSession();
|
||||
return [{ ...base, ...data }];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
staff: makeTable("staff"),
|
||||
clients: makeTable("clients"),
|
||||
impersonationSessions: makeTable("sessions"),
|
||||
impersonationAuditLogs: makeTable("auditLogs"),
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
desc: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── App setup ───────────────────────────────────────────────────────────────
|
||||
|
||||
const { impersonationRouter } = await import("../routes/impersonation.js");
|
||||
const { requireRole } = await import("../middleware/rbac.js");
|
||||
|
||||
/**
|
||||
* Build a test app. If staffRow is null the middleware simulates
|
||||
* resolveStaffMiddleware returning 403 (staff not found). An optional
|
||||
* roleGuard applies requireRole(...roles) before the router.
|
||||
*/
|
||||
function createApp(
|
||||
staffRow: (typeof MANAGER_STAFF) | null,
|
||||
roleGuard?: string[]
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
||||
}
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
|
||||
c.set("staff", staffRow as unknown as StaffRow);
|
||||
await next();
|
||||
});
|
||||
if (roleGuard && roleGuard.length > 0) {
|
||||
app.use("*", requireRole(...(roleGuard as Parameters<typeof requireRole>)) as never);
|
||||
}
|
||||
app.route("/impersonation", impersonationRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function jsonPost(path: string, body: unknown) {
|
||||
return {
|
||||
method: "POST" as const,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
// ─── POST /sessions — Create session ─────────────────────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions", () => {
|
||||
it("creates a session for a manager", async () => {
|
||||
const app = createApp(MANAGER_STAFF, ["manager"]);
|
||||
selectQueue.push(
|
||||
[CLIENT], // client lookup
|
||||
[], // expireTimedOutSessions active query
|
||||
[] // existing active check
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues.some((v) => v.table === "sessions")).toBe(true);
|
||||
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-managers via requireRole guard", async () => {
|
||||
const app = createApp(GROOMER_STAFF, ["manager"]);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/forbidden/i);
|
||||
});
|
||||
|
||||
it("returns 403 when staff record not found", async () => {
|
||||
const app = createApp(null);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when client not found", async () => {
|
||||
const app = createApp(MANAGER_STAFF, ["manager"]);
|
||||
selectQueue.push(
|
||||
[] // client not found
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when active session already exists", async () => {
|
||||
const app = createApp(MANAGER_STAFF, ["manager"]);
|
||||
const existing = makeSession();
|
||||
selectQueue.push(
|
||||
[CLIENT], // client lookup
|
||||
[], // expireTimedOutSessions
|
||||
[existing] // existing active session
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/already have an active/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /sessions/:id — Authorization ───────────────────────────────────────
|
||||
|
||||
describe("GET /impersonation/sessions/:id", () => {
|
||||
it("returns session for the owning staff member", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for a different staff member", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession(); // owned by manager
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not your session/i);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
selectQueue.push(
|
||||
[] // no session
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/nonexistent");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("auto-expires a timed-out session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("expired");
|
||||
// Should have called update to mark expired
|
||||
expect(updatedValues).toHaveLength(1);
|
||||
expect(updatedValues[0]!.set.status).toBe("expired");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/extend ───────────────────────────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions/:id/extend", () => {
|
||||
it("extends an active non-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Should have extended (updated expiresAt) and logged
|
||||
expect(updatedValues).toHaveLength(1);
|
||||
expect(insertedValues.some((v) => {
|
||||
const vals = v.vals as Record<string, unknown>;
|
||||
return vals.action === "session_extended";
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when extending a time-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session] // owned by manager
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 400 for an ended session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ status: "ended" });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not active/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/end ──────────────────────────────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions/:id/end", () => {
|
||||
it("ends an active non-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/end",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updatedValues).toHaveLength(1);
|
||||
expect(updatedValues[0]!.set.status).toBe("ended");
|
||||
});
|
||||
|
||||
it("returns 400 when ending a time-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/end",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/end",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/log — Authorization + expiry ─────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions/:id/log", () => {
|
||||
const logBody = { action: "page_visit", pageVisited: "/dashboard" };
|
||||
|
||||
it("logs an audit entry for the session owner", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not your session/i);
|
||||
});
|
||||
|
||||
it("returns 400 when session has expired by time", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it("returns 400 for an ended session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ status: "ended" });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not active/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /sessions/:id/audit-log — Authorization ────────────────────────────
|
||||
|
||||
describe("GET /impersonation/sessions/:id/audit-log", () => {
|
||||
it("returns audit logs for the session owner", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
|
||||
selectQueue.push(
|
||||
[session], // session lookup
|
||||
logs // audit logs query (where + orderBy chain)
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/audit-log"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/audit-log"
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not your session/i);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
selectQueue.push(
|
||||
[]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/nonexistent/audit-log"
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
role: "groomer",
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
};
|
||||
|
||||
// ─── Shared mutable DB state ──────────────────────────────────────────────────
|
||||
|
||||
const PET_ID = "pet-uuid-1234";
|
||||
const PHOTO_KEY = `pets/${PET_ID}/1700000000000.jpg`;
|
||||
|
||||
let dbPetRow: Record<string, unknown> | null;
|
||||
|
||||
function resetDb() {
|
||||
dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null };
|
||||
}
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const pets = new Proxy(
|
||||
{ _name: "pets" },
|
||||
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => (dbPetRow ? [dbPetRow] : []),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => ({
|
||||
returning: () => (dbPetRow ? [{ ...dbPetRow }] : []),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
pets,
|
||||
eq: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/s3.js", () => ({
|
||||
getPresignedUploadUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-put"),
|
||||
getPresignedGetUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-get"),
|
||||
deleteObject: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ─── Import after mocks are set up ───────────────────────────────────────────
|
||||
|
||||
const { petsRouter } = await import("../routes/pets.js");
|
||||
|
||||
// ─── App builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildApp(staffRow: StaffRow) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
app.route("/pets", petsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Reset before each test ───────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
resetDb();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── POST /:petId/photo/upload-url ───────────────────────────────────────────
|
||||
|
||||
describe("POST /pets/:petId/photo/upload-url", () => {
|
||||
it("returns presigned upload URL and object key for valid image contentType", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { uploadUrl: string; key: string };
|
||||
expect(body.uploadUrl).toBe("https://storage.example.com/presigned-put");
|
||||
expect(body.key).toMatch(/^pets\//);
|
||||
expect(body.key).toContain(PET_ID);
|
||||
});
|
||||
|
||||
it("rejects non-image contentType with 400", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "application/pdf", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects image/svg+xml with 400 (allowlist enforcement)", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/svg+xml", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects fileSizeBytes over 5 MB with 400", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 6 * 1024 * 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("allows groomers to request an upload URL", async () => {
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/png", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /:petId/photo/confirm ───────────────────────────────────────────────
|
||||
|
||||
describe("POST /pets/:petId/photo/confirm", () => {
|
||||
it("confirms upload and returns ok: true", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: PHOTO_KEY }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when key is missing", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: PHOTO_KEY }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when key does not belong to the pet", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "pets/other-pet-id/1700000000000.jpg" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/invalid key/i);
|
||||
});
|
||||
|
||||
it("deletes old photo from storage when re-uploading", async () => {
|
||||
const { deleteObject } = await import("../lib/s3.js");
|
||||
const oldKey = `pets/${PET_ID}/old.jpg`;
|
||||
dbPetRow = { ...dbPetRow!, photoKey: oldKey };
|
||||
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: PHOTO_KEY }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(deleteObject).toHaveBeenCalledWith(oldKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /:petId/photo ────────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /pets/:petId/photo", () => {
|
||||
it("returns 404 with 'no photo' message when pet has no photo", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/no photo/i);
|
||||
});
|
||||
|
||||
it("deletes photo and returns ok: true when photo exists", async () => {
|
||||
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /:petId/photo ────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /pets/:petId/photo", () => {
|
||||
it("returns 404 when pet has no photo", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns presigned GET URL when photo exists", async () => {
|
||||
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { url: string; photoKey: string };
|
||||
expect(body.url).toBe("https://storage.example.com/presigned-get");
|
||||
expect(body.photoKey).toBe(PHOTO_KEY);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("groomer can read photo URL", async () => {
|
||||
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002";
|
||||
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
|
||||
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
|
||||
|
||||
const ACTIVE_SESSION = {
|
||||
id: SESSION_ID,
|
||||
clientId: CLIENT_ID,
|
||||
status: "active" as const,
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const EXPIRED_SESSION = {
|
||||
id: SESSION_ID,
|
||||
clientId: CLIENT_ID,
|
||||
status: "active" as const,
|
||||
expiresAt: pastDate(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const APPOINTMENT = {
|
||||
id: APPOINTMENT_ID,
|
||||
clientId: CLIENT_ID,
|
||||
startTime: futureDate(),
|
||||
endTime: futureDate(),
|
||||
customerNotes: null,
|
||||
confirmationToken: "secret-token-leak-test",
|
||||
status: "scheduled" as const,
|
||||
confirmationStatus: "pending" as const,
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
};
|
||||
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectSessionRow = null;
|
||||
selectAppointmentRow = null;
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const impersonationSessions = new Proxy(
|
||||
{ _name: "impersonationSessions" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
|
||||
}
|
||||
if (table._name === "appointments") {
|
||||
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
if (selectAppointmentRow) {
|
||||
const updated = { ...selectAppointmentRow, ...vals };
|
||||
updatedValues.push(vals);
|
||||
return [updated];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
function jsonPatch(path: string, body: unknown, headers?: Record<string, string>) {
|
||||
return app.request(path, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||
it("returns updated appointment with safe fields only", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Please be gentle with Fido" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("id");
|
||||
expect(body).toHaveProperty("customerNotes", "Please be gentle with Fido");
|
||||
expect(body).toHaveProperty("updatedAt");
|
||||
expect(body).not.toHaveProperty("confirmationToken");
|
||||
expect(body).not.toHaveProperty("clientId");
|
||||
});
|
||||
|
||||
it("returns 401 without X-Impersonation-Session-Id header", async () => {
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 401 with ended session", async () => {
|
||||
selectSessionRow = null;
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 403 when appointment belongs to different client", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("returns 422 for past appointment", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/past|in-progress|cannot edit/i);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is in progress", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: new Date(Date.now() - 2 * 60 * 1000) };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = null;
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/nonexistent-id/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("accepts notes at exactly 500 characters", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const longNote = "a".repeat(500);
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: longNote },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.customerNotes).toBe(longNote);
|
||||
});
|
||||
|
||||
it("rejects notes exceeding 500 characters", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const longNote = "a".repeat(501);
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: longNote },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /portal/appointments/:id/confirm ────────────────────────────────────
|
||||
|
||||
function jsonPost(path: string, headers?: Record<string, string>) {
|
||||
return app.request(path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /portal/appointments/:id/confirm", () => {
|
||||
it("confirms a pending appointment and returns updated status", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.confirmationStatus).toBe("confirmed");
|
||||
expect(body).toHaveProperty("confirmedAt");
|
||||
});
|
||||
|
||||
it("returns 401 without X-Impersonation-Session-Id header", async () => {
|
||||
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/confirm`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when appointment belongs to a different client", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is in the past", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is not pending confirmation", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "confirmed" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when cancelling an already-cancelled appointment", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = null;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/nonexistent-id/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /portal/appointments/:id/cancel ─────────────────────────────────────
|
||||
|
||||
describe("POST /portal/appointments/:id/cancel", () => {
|
||||
it("cancels a pending appointment and returns updated status", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("cancelled");
|
||||
expect(body.confirmationStatus).toBe("cancelled");
|
||||
expect(body).toHaveProperty("cancelledAt");
|
||||
});
|
||||
|
||||
it("returns 401 without X-Impersonation-Session-Id header", async () => {
|
||||
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/cancel`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when appointment belongs to a different client", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is in the past", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is already cancelled", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is already completed", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, status: "completed" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = null;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/nonexistent-id/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Mock staff data ──────────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: "ba-user-manager",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const RECEPTIONIST: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-receptionist-id",
|
||||
oidcSub: "oidc-receptionist-sub",
|
||||
userId: "ba-user-receptionist",
|
||||
role: "receptionist",
|
||||
isSuperUser: false,
|
||||
name: "Receptionist Rita",
|
||||
email: "receptionist@example.com",
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: "ba-user-groomer",
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
};
|
||||
|
||||
// ─── Mock DB ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let staffLookupResult: StaffRow | null = null;
|
||||
let managerFallbackResult: StaffRow | null = MANAGER;
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return "staff";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "staff", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => {
|
||||
// dev mode fallback to first manager
|
||||
return managerFallbackResult ? [managerFallbackResult] : [];
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
if (staffLookupResult) yield staffLookupResult;
|
||||
},
|
||||
0: staffLookupResult,
|
||||
length: staffLookupResult ? 1 : 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
staff,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function resetMocks() {
|
||||
staffLookupResult = null;
|
||||
managerFallbackResult = MANAGER;
|
||||
}
|
||||
|
||||
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
||||
function buildApp(
|
||||
middleware: MiddlewareHandler<AppEnv>,
|
||||
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||
await next();
|
||||
});
|
||||
app.use("*", middleware);
|
||||
const h = handler ?? ((c: Context<AppEnv>) => c.json({ ok: true }));
|
||||
app.get("/test", h);
|
||||
app.post("/test", h);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Build app with staff pre-set in context (skips resolveStaffMiddleware). */
|
||||
function buildWithStaff(
|
||||
staffRow: StaffRow,
|
||||
guard: MiddlewareHandler<AppEnv>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
app.use("*", guard);
|
||||
app.get("/test", (c) => c.json({ ok: true }));
|
||||
app.post("/test", (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Import middleware ────────────────────────────────────────────────────────
|
||||
|
||||
const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import(
|
||||
"../middleware/rbac.js"
|
||||
);
|
||||
|
||||
beforeEach(() => resetMocks());
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.AUTH_DISABLED;
|
||||
});
|
||||
|
||||
// ─── resolveStaffMiddleware tests ─────────────────────────────────────────────
|
||||
|
||||
describe("resolveStaffMiddleware", () => {
|
||||
it("resolves staff from DB and sets it on context", async () => {
|
||||
staffLookupResult = MANAGER;
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff).not.toBeNull();
|
||||
expect(capturedStaff!.id).toBe(MANAGER.id);
|
||||
});
|
||||
|
||||
it("returns 403 when no staff record found for the OIDC sub", async () => {
|
||||
staffLookupResult = null;
|
||||
const app = buildApp(resolveStaffMiddleware);
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no staff record/i);
|
||||
});
|
||||
|
||||
it("dev mode: resolves staff by X-Dev-User-Id header", async () => {
|
||||
process.env.AUTH_DISABLED = "true";
|
||||
staffLookupResult = GROOMER;
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test", {
|
||||
headers: { "X-Dev-User-Id": GROOMER.id },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.role).toBe("groomer");
|
||||
});
|
||||
|
||||
it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => {
|
||||
process.env.AUTH_DISABLED = "true";
|
||||
managerFallbackResult = MANAGER;
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.role).toBe("manager");
|
||||
});
|
||||
|
||||
it("dev mode: returns 403 when no manager exists and no header provided", async () => {
|
||||
process.env.AUTH_DISABLED = "true";
|
||||
managerFallbackResult = null;
|
||||
const app = buildApp(resolveStaffMiddleware);
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no staff records found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireRole tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe("requireRole", () => {
|
||||
it("allows access when staff role matches the only allowed role", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows access when staff role is one of multiple allowed roles", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for an unauthorized role", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/forbidden/i);
|
||||
expect(body.error).toContain("groomer");
|
||||
});
|
||||
|
||||
it("includes the role name in the 403 error message", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("receptionist");
|
||||
});
|
||||
|
||||
it("groomer is blocked from manager+receptionist-only routes", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist"));
|
||||
const res = await app.request("/test", { method: "POST" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("manager passes all-role checks", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 with JSON body (not plain text)", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireSuperUser tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("requireSuperUser", () => {
|
||||
it("allows access when staff is a super user", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows access when manager is also a super user", async () => {
|
||||
// MANAGER has isSuperUser: true
|
||||
const app = buildWithStaff(MANAGER, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for a non-super-user receptionist", async () => {
|
||||
// RECEPTIONIST has isSuperUser: false
|
||||
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("returns 403 for a non-super-user groomer", async () => {
|
||||
// GROOMER has isSuperUser: false
|
||||
const app = buildWithStaff(GROOMER, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 403 when staff record is not resolved", async () => {
|
||||
// Manually remove staff from context to simulate unresolved staff
|
||||
const testApp = new Hono<AppEnv>();
|
||||
testApp.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: "test-sub" });
|
||||
// Do NOT set staff - simulate unresolved staff
|
||||
await next();
|
||||
});
|
||||
testApp.use("*", requireSuperUser());
|
||||
testApp.get("/test", (c) => c.json({ ok: true }));
|
||||
const res = await testApp.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/staff record not resolved/i);
|
||||
});
|
||||
|
||||
it("receptionist cannot grant super user status on staff PATCH", async () => {
|
||||
// This tests the inline guard in staff.ts handler, not the middleware itself,
|
||||
// but we test requireSuperUser to verify the middleware correctly blocks
|
||||
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||
const res = await app.request("/test", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isSuperUser: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("returns 403 with JSON body for super user violation", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireRoleOrSuperUser tests ─────────────────────────────────────────────
|
||||
|
||||
describe("requireRoleOrSuperUser", () => {
|
||||
it("allows a manager to access manager-only routes", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => {
|
||||
// GRO-412: a receptionist granted super user via Staff UI should access admin routes
|
||||
const superReceptionist: StaffRow = {
|
||||
...RECEPTIONIST,
|
||||
isSuperUser: true,
|
||||
};
|
||||
const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a super user with groomer role to access manager-only routes", async () => {
|
||||
const superGroomer: StaffRow = {
|
||||
...GROOMER,
|
||||
isSuperUser: true,
|
||||
};
|
||||
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user receptionist from manager-only routes", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
});
|
||||
|
||||
it("allows a manager with multiple allowed roles", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a super user with disallowed role to access route with multiple allowed roles", async () => {
|
||||
const superGroomer: StaffRow = {
|
||||
...GROOMER,
|
||||
isSuperUser: true,
|
||||
};
|
||||
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIVE_CLIENT = {
|
||||
id: "client-1",
|
||||
name: "Alice Johnson",
|
||||
email: "alice@example.com",
|
||||
phone: "555-1234",
|
||||
};
|
||||
|
||||
const PET_ROW = {
|
||||
id: "pet-1",
|
||||
name: "Bella",
|
||||
breed: "Golden Retriever",
|
||||
clientId: "client-1",
|
||||
ownerName: "Alice Johnson",
|
||||
};
|
||||
|
||||
// ─── Mock DB ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let clientResults: typeof ACTIVE_CLIENT[] = [];
|
||||
let petResults: typeof PET_ROW[] = [];
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
// Proxy objects for table/column references — values don't matter for tests
|
||||
const tableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
{ _name: name },
|
||||
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
|
||||
);
|
||||
|
||||
const clients = tableProxy("clients");
|
||||
const pets = tableProxy("pets");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: (_fields?: unknown) => {
|
||||
// Route which mock results to use based on a global flag set per test
|
||||
return {
|
||||
from: (table: { _name?: string }) => {
|
||||
const results = table._name === "pets" ? petResults : clientResults;
|
||||
const chain: Record<string, unknown> = {};
|
||||
chain.where = () => chain;
|
||||
chain.innerJoin = () => chain;
|
||||
chain.limit = () => Promise.resolve(results);
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
clients,
|
||||
pets,
|
||||
and: (...args: unknown[]) => ({ and: args }),
|
||||
or: (...args: unknown[]) => ({ or: args }),
|
||||
eq: (a: unknown, b: unknown) => ({ eq: [a, b] }),
|
||||
ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── App under test ───────────────────────────────────────────────────────────
|
||||
|
||||
async function makeApp() {
|
||||
const { searchRouter } = await import("../routes/search.js");
|
||||
const app = new Hono();
|
||||
app.route("/search", searchRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
});
|
||||
|
||||
describe("GET /search", () => {
|
||||
it("returns 400 when q is missing", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 400 when q is empty string", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when q is only whitespace", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q= ");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns matching clients and pets", async () => {
|
||||
clientResults = [ACTIVE_CLIENT];
|
||||
petResults = [PET_ROW];
|
||||
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=bell");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.clients).toEqual([ACTIVE_CLIENT]);
|
||||
expect(body.pets).toEqual([PET_ROW]);
|
||||
});
|
||||
|
||||
it("returns empty arrays when no matches", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=xyzzy");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.clients).toEqual([]);
|
||||
expect(body.pets).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns shape with clients and pets keys", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=a");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("clients");
|
||||
expect(body).toHaveProperty("pets");
|
||||
expect(Array.isArray(body.clients)).toBe(true);
|
||||
expect(Array.isArray(body.pets)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles special characters in query without throwing", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
|
||||
const app = await makeApp();
|
||||
// These characters should be escaped, not cause errors
|
||||
const res = await app.request("/search?q=foo%25bar_baz");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeLike helper (via integration)", () => {
|
||||
it("% in query does not break the request", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=%25");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("_ in query does not break the request", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=_");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,720 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { setupRouter } from "../routes/setup.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockStaff {
|
||||
id: string;
|
||||
role: string;
|
||||
isSuperUser: boolean;
|
||||
}
|
||||
|
||||
// ─── Mock DB state ────────────────────────────────────────────────────────────
|
||||
|
||||
let dbStaffRows: MockStaff[] = [];
|
||||
let dbBusinessSettingsRows: { id: string; businessName: string }[] = [];
|
||||
let dbAuthConfigRows: { id: string; enabled: boolean }[] = [];
|
||||
let insertedAuthConfig: Record<string, unknown>[] = [];
|
||||
let insertedStaff: Record<string, unknown>[] = [];
|
||||
let encryptCalls: string[] = [];
|
||||
|
||||
// Track env vars set per test
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
function resetMock() {
|
||||
dbStaffRows = [];
|
||||
dbBusinessSettingsRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
insertedAuthConfig = [];
|
||||
insertedStaff = [];
|
||||
encryptCalls = [];
|
||||
}
|
||||
|
||||
function clearAuthEnv() {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "auth_provider_config";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "auth_provider_config", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "staff";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "staff", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const businessSettings = new Proxy(
|
||||
{ _name: "business_settings" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "business_settings";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "business_settings", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Build a shared tx mock that operates on current-state snapshots
|
||||
function makeTxMock() {
|
||||
function getRowsForTable(table: unknown) {
|
||||
if (table === authProviderConfig) return dbAuthConfigRows;
|
||||
if (table === staff) return dbStaffRows;
|
||||
if (table === businessSettings) return dbBusinessSettingsRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const rows = getRowsForTable(table);
|
||||
const base = {
|
||||
where: (cond?: unknown) => {
|
||||
const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record<string, unknown>)) : rows;
|
||||
return {
|
||||
limit: () => filtered,
|
||||
for: () => ({
|
||||
limit: () => filtered,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
}),
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
};
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of rows) yield item;
|
||||
},
|
||||
0: rows[0],
|
||||
length: rows.length,
|
||||
};
|
||||
// Some calls use .limit() directly on from() result (no where())
|
||||
(base as any).limit = () => rows;
|
||||
return base;
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
|
||||
if (vals.providerId) {
|
||||
insertedAuthConfig.push(vals);
|
||||
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
|
||||
} else if (vals.email) {
|
||||
// staff insert
|
||||
insertedStaff.push(vals);
|
||||
dbStaffRows.push(row as unknown as MockStaff);
|
||||
} else if (vals.businessName) {
|
||||
dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
|
||||
}
|
||||
return { returning: () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
const updated = { ...dbStaffRows[0], ...vals, updatedAt: new Date() };
|
||||
return [updated];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => ({
|
||||
where: (cond?: unknown) => {
|
||||
const rows =
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows
|
||||
: table === staff
|
||||
? dbStaffRows
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows
|
||||
: [];
|
||||
const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record<string, unknown>)) : rows;
|
||||
return {
|
||||
limit: () => filtered,
|
||||
for: () => ({
|
||||
limit: () => filtered,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
}),
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
};
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
const rows =
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows
|
||||
: table === staff
|
||||
? dbStaffRows
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows
|
||||
: [];
|
||||
for (const item of rows) yield item;
|
||||
},
|
||||
0:
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows[0]
|
||||
: table === staff
|
||||
? dbStaffRows[0]
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows[0]
|
||||
: undefined,
|
||||
length:
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows.length
|
||||
: table === staff
|
||||
? dbStaffRows.length
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows.length
|
||||
: 0,
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
|
||||
if (vals.providerId) {
|
||||
insertedAuthConfig.push(vals);
|
||||
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
|
||||
} else if (vals.email) {
|
||||
insertedStaff.push(vals);
|
||||
dbStaffRows.push(row as unknown as MockStaff);
|
||||
} else if (vals.businessName) {
|
||||
dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
|
||||
}
|
||||
return { returning: () => [row] };
|
||||
},
|
||||
}),
|
||||
transaction: (cb: (tx: unknown) => Promise<unknown>) => cb(makeTxMock()),
|
||||
}),
|
||||
authProviderConfig,
|
||||
staff,
|
||||
businessSettings,
|
||||
eq: (col: unknown, val: unknown) => ({ __type: "eq", col, val }),
|
||||
and: (...conds: unknown[]) => ({ __type: "and", conds }),
|
||||
isNull: (col: unknown) => ({ __type: "isNull", col }),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
// Mock sql template tag — raw SQL can't be evaluated in mock, always passes
|
||||
void strings; void values;
|
||||
return { __type: "sql" };
|
||||
},
|
||||
encryptSecret: (val: string) => {
|
||||
encryptCalls.push(val);
|
||||
return `encrypted:${val}`;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to evaluate mock conditions against a row
|
||||
function evaluateCond(cond: unknown, row: Record<string, unknown>): boolean {
|
||||
if (!cond || typeof cond !== "object") return true;
|
||||
const c = cond as Record<string, unknown>;
|
||||
if (c.__type === "eq") {
|
||||
const colObj = c.col as Record<string, unknown>;
|
||||
const colName = colObj.column as string;
|
||||
return row[colName] === c.val;
|
||||
}
|
||||
if (c.__type === "and") {
|
||||
return (c.conds as unknown[]).every((sub) => evaluateCond(sub, row));
|
||||
}
|
||||
if (c.__type === "isNull") {
|
||||
const colObj = c.col as Record<string, unknown>;
|
||||
const colName = colObj.column as string;
|
||||
return row[colName] === null || row[colName] === undefined;
|
||||
}
|
||||
if (c.__type === "sql") {
|
||||
// Raw SQL can't be evaluated in mock — pass through
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function makeApp(staff?: MockStaff | null, jwtPayload?: JwtPayload | null) {
|
||||
const app = new Hono();
|
||||
|
||||
// Inject optional staff and jwtPayload context for authenticated routes
|
||||
app.use("/setup/*", async (c, next) => {
|
||||
if (jwtPayload) {
|
||||
(c as any).set("jwtPayload", jwtPayload);
|
||||
}
|
||||
if (staff) {
|
||||
(c as any).set("staff", staff);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.route("/setup", setupRouter as unknown as Hono);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type ResponseBody = Record<string, unknown>;
|
||||
|
||||
async function getStatus(app: Hono) {
|
||||
const res = await app.request("/setup/status", { method: "GET" });
|
||||
return { status: res.status, body: (await res.json()) as ResponseBody };
|
||||
}
|
||||
|
||||
async function postAuthProvider(app: Hono, body: unknown) {
|
||||
const res = await app.request("/setup/auth-provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: ResponseBody;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResponseBody;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
async function postAuthProviderTest(app: Hono, body: unknown) {
|
||||
const res = await app.request("/setup/auth-provider/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: ResponseBody;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResponseBody;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
async function postSetup(app: Hono, body: unknown) {
|
||||
const res = await app.request("/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: ResponseBody;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResponseBody;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /setup/status — OOBE bootstrap logic", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("fresh install (no super user, no env vars) → needsSetup=true, showAuthProviderStep=true", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
// env vars are cleared
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(true);
|
||||
expect(body.showAuthProviderStep).toBe(true);
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(false);
|
||||
});
|
||||
|
||||
it("fresh install (no super user, env vars set) → needsSetup=true, showAuthProviderStep=false", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.OIDC_ISSUER = "https://auth.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-id";
|
||||
process.env.OIDC_CLIENT_SECRET = "client-secret";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(true);
|
||||
expect(body.showAuthProviderStep).toBe(false); // env vars already provide auth
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(true);
|
||||
});
|
||||
|
||||
it("setup complete (super user exists) → needsSetup=false, showAuthProviderStep=false", async () => {
|
||||
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
|
||||
dbAuthConfigRows = [{ id: "prov-1", enabled: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.showAuthProviderStep).toBe(false);
|
||||
expect(body.authConfigExists).toBe(true);
|
||||
});
|
||||
|
||||
it("no super user but DB config exists → showAuthProviderStep=false", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [{ id: "prov-1", enabled: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(true);
|
||||
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
||||
expect(body.authConfigExists).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
|
||||
dbStaffRows = []; // no super user
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "true";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.showAuthProviderStep).toBe(false);
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=1 also bypasses setup check", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "1";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=yes also bypasses setup check", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "yes";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
const validBody = {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik SSO",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
clientId: "my-client",
|
||||
clientSecret: "my-secret",
|
||||
scopes: "openid profile email",
|
||||
};
|
||||
|
||||
it("creates auth provider config when no super user exists", async () => {
|
||||
dbStaffRows = []; // no super user
|
||||
dbAuthConfigRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProvider(app, validBody);
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.providerId).toBe("authentik");
|
||||
expect(body.clientSecret).toBeUndefined(); // secret should not be returned plaintext
|
||||
expect(encryptCalls).toContain("my-secret");
|
||||
expect(insertedAuthConfig.length).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 403 after setup is complete (super user exists)", async () => {
|
||||
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProvider(app, validBody);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.error).toMatch(/already been completed/i);
|
||||
});
|
||||
|
||||
it("returns 409 if auth provider is already configured", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [{ id: "prov-1", enabled: true }]; // already configured
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProvider(app, validBody);
|
||||
|
||||
expect(status).toBe(409);
|
||||
expect(body.error).toMatch(/already configured/i);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid schema (Zod validation failure)", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
// providerId="" fails Zod min(1), issuerUrl="not-a-url" fails Zod url()
|
||||
const { status } = await postAuthProvider(app, {
|
||||
providerId: "",
|
||||
displayName: "Test",
|
||||
issuerUrl: "not-a-url",
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
});
|
||||
|
||||
// Zod throws ZodError which Hono's error handler should format as 400
|
||||
// Currently returns 500 — route needs error handler for Zod errors
|
||||
// TODO(cleanup): add error handler to route; expect 400 once fixed
|
||||
expect(status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("encrypts clientSecret before storing", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
await postAuthProvider(app, validBody);
|
||||
|
||||
expect(encryptCalls).toContain("my-secret");
|
||||
expect(insertedAuthConfig[0]!.clientSecret).toBe("encrypted:my-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup/auth-provider/test — OOBE test connection", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("returns 403 after setup is complete (super user exists)", async () => {
|
||||
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://auth.example.com",
|
||||
});
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.error).toMatch(/already been completed/i);
|
||||
});
|
||||
|
||||
it("returns ok=false for unreachable issuer URL", async () => {
|
||||
dbStaffRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toBeTruthy();
|
||||
}, 15000);
|
||||
|
||||
it("accepts valid issuerUrl", async () => {
|
||||
dbStaffRows = [];
|
||||
|
||||
// Mock fetch to simulate a valid OIDC discovery response
|
||||
const mockFetch = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://auth.example.com",
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns ok=false for invalid issuer URL (non-200 response)", async () => {
|
||||
dbStaffRows = [];
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 })
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://auth.example.com",
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toMatch(/discovery failed/i);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup — OOBE regression (GRO-485)", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("creates staff record during OOBE when no staff record exists for authenticated user", async () => {
|
||||
// No staff rows — this is a fresh OOBE user
|
||||
dbStaffRows = [];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.staff).toBeDefined();
|
||||
expect((body.staff as MockStaff).isSuperUser).toBe(true);
|
||||
expect((body.staff as any).email).toBe("alice@example.com");
|
||||
expect((body.staff as MockStaff).role).toBe("manager");
|
||||
// New staff record was created
|
||||
expect(insertedStaff.length).toBe(1);
|
||||
expect(insertedStaff[0]!.email).toBe("alice@example.com");
|
||||
expect(insertedStaff[0]!.userId).toBe("user-123");
|
||||
});
|
||||
|
||||
it("still works for user who already has a staff record", async () => {
|
||||
// Staff record exists for this user
|
||||
dbStaffRows = [{ id: "staff-existing", role: "groomer", isSuperUser: false }];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
|
||||
// Inject the existing staff record into context
|
||||
const app = makeApp({ id: "staff-existing", role: "groomer", isSuperUser: false }, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.ok).toBe(true);
|
||||
expect((body.staff as MockStaff).isSuperUser).toBe(true);
|
||||
// No new staff was created (insertedStaff should be empty since staff was pre-existing)
|
||||
});
|
||||
|
||||
it("auto-links staff by email if record exists with matching email but no userId", async () => {
|
||||
// Staff record exists with matching email but no userId (legacy record)
|
||||
dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null } as unknown as MockStaff];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
|
||||
// No staff injected into context — the handler must find it by email
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.ok).toBe(true);
|
||||
expect((body.staff as MockStaff).isSuperUser).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 if JWT has no email claim and no staff record exists", async () => {
|
||||
dbStaffRows = [];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
// JWT with no email
|
||||
const jwtPayload = { sub: "user-123" };
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body.error).toMatch(/no email claim/i);
|
||||
});
|
||||
|
||||
it("returns 409 if a super user already exists", async () => {
|
||||
// Super user already exists
|
||||
dbStaffRows = [{ id: "staff-super", role: "manager", isSuperUser: true }];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-456", email: "bob@example.com", name: "Bob" };
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Bob's Grooming" });
|
||||
|
||||
expect(status).toBe(409);
|
||||
expect(body.error).toMatch(/already been completed/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
|
||||
const DATE = "2026-03-18";
|
||||
const G1 = "groomer-1";
|
||||
const G2 = "groomer-2";
|
||||
|
||||
function utc(h: number, m = 0): Date {
|
||||
const d = new Date(`${DATE}T00:00:00Z`);
|
||||
d.setUTCHours(h, m, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
describe("generateAvailableSlots", () => {
|
||||
it("returns slots within business hours", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
});
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
slots.forEach((s) => {
|
||||
const h = new Date(s).getUTCHours();
|
||||
expect(h).toBeGreaterThanOrEqual(BUSINESS_START_HOUR);
|
||||
expect(h).toBeLessThan(BUSINESS_END_HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns correct count of 60-min slots across 8-hour window", () => {
|
||||
// 09:00–17:00 = 8 hours → 8 one-hour slots
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
});
|
||||
expect(slots).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("returns empty array when no groomers", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [],
|
||||
booked: [],
|
||||
});
|
||||
expect(slots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes slots blocked by a booking", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("keeps slot available when only the other groomer is booked", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1, G2],
|
||||
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
|
||||
});
|
||||
// G2 is free at 09:00 so slot should still appear
|
||||
expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("excludes a slot only when ALL groomers are booked", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1, G2],
|
||||
booked: [
|
||||
{ staffId: G1, startTime: utc(9), endTime: utc(10) },
|
||||
{ staffId: G2, startTime: utc(9), endTime: utc(10) },
|
||||
],
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("correctly handles a booking that partially overlaps a slot", () => {
|
||||
// Booking 09:30–10:30 should block the 09:00 and 10:00 slots for G1
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [{ staffId: G1, startTime: utc(9, 30), endTime: utc(10, 30) }],
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
expect(slots).toContain(new Date(`${DATE}T11:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("does not generate a slot that would exceed business hours end", () => {
|
||||
// 30-min slots: last valid start is 16:30 (ends at 17:00)
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 30,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
});
|
||||
const last = slots[slots.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(new Date(last!).getUTCHours()).toBe(16);
|
||||
expect(new Date(last!).getUTCMinutes()).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const VALID_UUID_1 = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const VALID_UUID_2 = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const VALID_UUID_3 = "550e8400-e29b-41d4-a716-446655440003";
|
||||
const VALID_UUID_4 = "550e8400-e29b-41d4-a716-446655440004";
|
||||
const VALID_UUID_5 = "550e8400-e29b-41d4-a716-446655440005";
|
||||
|
||||
const WAITLIST_ENTRY = {
|
||||
id: VALID_UUID_1,
|
||||
clientId: VALID_UUID_2,
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
status: "active",
|
||||
notifiedAt: null,
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const ACTIVE_SESSION = {
|
||||
id: VALID_UUID_5,
|
||||
clientId: VALID_UUID_2,
|
||||
status: "active" as const,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const EXPIRED_SESSION = {
|
||||
id: "660e8400-e29b-41d4-a716-446655440006",
|
||||
clientId: VALID_UUID_2,
|
||||
status: "active" as const,
|
||||
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
selectSessionRow = null;
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const waitlistEntries = new Proxy(
|
||||
{ _name: "waitlistEntries" },
|
||||
{ get: (t, p) => (p === "_name" ? "waitlistEntries" : { table: "waitlistEntries", column: p }) }
|
||||
);
|
||||
|
||||
const impersonationSessions = new Proxy(
|
||||
{ _name: "impersonationSessions" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const clients = new Proxy(
|
||||
{ _name: "clients" },
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const pets = new Proxy(
|
||||
{ _name: "pets" },
|
||||
{ get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) }
|
||||
);
|
||||
|
||||
const services = new Proxy(
|
||||
{ _name: "services" },
|
||||
{ get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
|
||||
}
|
||||
if (table._name === "waitlistEntries") {
|
||||
return makeChainable(selectRows);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updatedValues.push(vals);
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0
|
||||
? [{ ...selectRows[0], ...vals }]
|
||||
: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => {
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0 ? [selectRows[0]] : [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
waitlistEntries,
|
||||
impersonationSessions,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
lt: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { waitlistRouter } = await import("../routes/waitlist.js");
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/waitlist", waitlistRouter);
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
function jsonRequest(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
|
||||
return app.request(path, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
describe("POST /portal/waitlist", () => {
|
||||
it("creates entry with valid session", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.petId).toBe(VALID_UUID_3);
|
||||
expect(insertedValues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
}, { "X-Impersonation-Session-Id": EXPIRED_SESSION.id });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /portal/waitlist/:id", () => {
|
||||
it("deletes entry with valid session and correct owner", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 with valid session but wrong owner", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when entry not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [];
|
||||
const res = await app.request("/portal/waitlist/nonexistent", {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /portal/waitlist/:id", () => {
|
||||
it("updates entry with valid session and correct owner", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updatedValues[0]?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 with valid session but wrong owner", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when entry not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [];
|
||||
const res = await jsonRequest("PATCH", "/portal/waitlist/nonexistent", {
|
||||
status: "cancelled",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { cors } from "hono/cors";
|
||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||
import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { waitlistRouter } from "./routes/waitlist.js";
|
||||
import { portalRouter } from "./routes/portal.js";
|
||||
import { staffRouter } from "./routes/staff.js";
|
||||
import { invoicesRouter } from "./routes/invoices.js";
|
||||
import { bookRouter } from "./routes/book.js";
|
||||
import { reportsRouter } from "./routes/reports.js";
|
||||
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
||||
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
||||
import { impersonationRouter } from "./routes/impersonation.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { authProviderRouter } from "./routes/authProvider.js";
|
||||
import { searchRouter } from "./routes/search.js";
|
||||
import { getObject } from "./lib/s3.js";
|
||||
import { calendarRouter } from "./routes/calendar.js";
|
||||
import { setupRouter } from "./routes/setup.js";
|
||||
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Global middleware
|
||||
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
||||
.split(",")
|
||||
.map((o) => o.trim());
|
||||
|
||||
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
|
||||
app.use("*", logger());
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: (origin, ctx) => {
|
||||
if (!origin) {
|
||||
return ALLOWED_ORIGIN;
|
||||
}
|
||||
if (TRUSTED_ORIGINS.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
ctx.status(403);
|
||||
return null;
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
// Public booking routes — no auth required, must be registered before auth middleware
|
||||
app.route("/api/book", bookRouter);
|
||||
|
||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||
app.route("/api/portal", portalRouter);
|
||||
|
||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
// Magic bytes for allowed image types
|
||||
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
|
||||
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
|
||||
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
|
||||
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the given base64 content matches the declared MIME type
|
||||
* by checking magic bytes. Returns null if valid, or the field to clear if not.
|
||||
*/
|
||||
function validateLogoMagicBytes(
|
||||
logoBase64: string | null,
|
||||
logoMimeType: string | null
|
||||
): "logoBase64" | "logoMimeType" | null {
|
||||
if (!logoBase64 || !logoMimeType) return null;
|
||||
|
||||
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
|
||||
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
|
||||
|
||||
try {
|
||||
const binary = Buffer.from(logoBase64, "base64");
|
||||
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
|
||||
if (logoMimeType === "image/webp") {
|
||||
if (binary.length < 12) return "logoBase64";
|
||||
const webpMagic = binary.slice(0, 4);
|
||||
const webpSig = binary.slice(8, 12);
|
||||
if (
|
||||
webpMagic[0] !== 0x52 ||
|
||||
webpMagic[1] !== 0x49 ||
|
||||
webpMagic[2] !== 0x46 ||
|
||||
webpMagic[3] !== 0x46 ||
|
||||
webpSig[0] !== 0x57 ||
|
||||
webpSig[1] !== 0x45 ||
|
||||
webpSig[2] !== 0x42 ||
|
||||
webpSig[3] !== 0x50
|
||||
) {
|
||||
return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// All other types: check prefix
|
||||
if (binary.length < expectedMagic.length) return "logoBase64";
|
||||
for (let i = 0; i < expectedMagic.length; i++) {
|
||||
if (binary[i] !== expectedMagic[i]) return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "logoBase64";
|
||||
}
|
||||
}
|
||||
|
||||
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
|
||||
app.get("/api/branding/logo", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||
|
||||
const { body, contentType } = await getObject(row.logoKey);
|
||||
return new Response(Buffer.from(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||
app.get("/api/branding", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
||||
|
||||
// Return the public proxy path so browser never sees a raw S3 URL
|
||||
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
|
||||
|
||||
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||
// via the legacy base64 logo fields
|
||||
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||
|
||||
return c.json({
|
||||
businessName: settings.businessName,
|
||||
primaryColor: settings.primaryColor,
|
||||
accentColor: settings.accentColor,
|
||||
logoUrl,
|
||||
logoBase64: safeLogoBase64,
|
||||
logoMimeType: safeLogoMimeType,
|
||||
});
|
||||
});
|
||||
|
||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||
app.route("/api/calendar", calendarRouter);
|
||||
|
||||
// Public setup status — no auth required, must be registered before auth middleware
|
||||
app.get("/api/setup/status", async (c) => {
|
||||
const db = getDb();
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
return c.json({ needsSetup: !superUser });
|
||||
});
|
||||
|
||||
// Public auth providers endpoint — no auth required, tells frontend which login options are available
|
||||
app.get("/api/auth/providers", async (c) => {
|
||||
return c.json({ providers: getActiveProviders() });
|
||||
});
|
||||
|
||||
// Protected API routes
|
||||
const api = app.basePath("/api");
|
||||
api.use("*", authMiddleware);
|
||||
api.use("*", resolveStaffMiddleware);
|
||||
|
||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||
const authRouter = new Hono();
|
||||
authRouter.all("/*", (c) => {
|
||||
try {
|
||||
return getAuth().handler(c.req.raw);
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
});
|
||||
api.route("/auth", authRouter);
|
||||
|
||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||
// Manager-only: admin settings, reports, invoices, impersonation
|
||||
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
||||
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/admin/settings/*", requireSuperUser());
|
||||
api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||
api.use("/impersonation/*", requireRole("manager"));
|
||||
|
||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
|
||||
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
|
||||
api.use("/waitlist/*", requireRole("manager", "receptionist"));
|
||||
|
||||
// Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms)
|
||||
// These must be registered before the general pets write guard. Because Hono path params
|
||||
// match single segments, "/pets/:petId" does NOT match "/pets/:petId/photo/:action",
|
||||
// so there is no guard overlap.
|
||||
api.on(
|
||||
["POST", "DELETE"],
|
||||
["/pets/:petId/photo", "/pets/:petId/photo/:action"],
|
||||
requireRole("manager", "receptionist", "groomer")
|
||||
);
|
||||
|
||||
// Clients, appointments: all roles may read; only manager + receptionist may write
|
||||
api.on(
|
||||
["POST", "PUT", "PATCH", "DELETE"],
|
||||
["/clients/*", "/appointments/*"],
|
||||
requireRole("manager", "receptionist")
|
||||
);
|
||||
|
||||
// Pets (non-photo CRUD): manager + receptionist for writes
|
||||
// ":petId" matches only single-segment paths — photo sub-routes are unaffected
|
||||
api.post("/pets", requireRole("manager", "receptionist"));
|
||||
api.on(["PUT", "PATCH", "DELETE"], "/pets/:petId", requireRole("manager", "receptionist"));
|
||||
|
||||
// Services: all roles may read; only managers may write
|
||||
api.on(
|
||||
["POST", "PUT", "PATCH", "DELETE"],
|
||||
"/services/*",
|
||||
requireRole("manager")
|
||||
);
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
|
||||
api.route("/setup", setupRouter);
|
||||
|
||||
api.route("/clients", clientsRouter);
|
||||
api.route("/pets", petsRouter);
|
||||
api.route("/services", servicesRouter);
|
||||
api.route("/appointments", appointmentsRouter);
|
||||
api.route("/waitlist", waitlistRouter);
|
||||
api.route("/staff", staffRouter);
|
||||
api.route("/invoices", invoicesRouter);
|
||||
api.route("/reports", reportsRouter);
|
||||
api.route("/appointment-groups", appointmentGroupsRouter);
|
||||
api.route("/grooming-logs", groomingLogsRouter);
|
||||
api.route("/impersonation", impersonationRouter);
|
||||
api.route("/admin/settings", settingsRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/search", searchRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await initAuth();
|
||||
console.log(`API server listening on port ${port}`);
|
||||
const server = serve({ fetch: app.fetch, port });
|
||||
|
||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||
startReminderScheduler();
|
||||
|
||||
function shutdown() {
|
||||
console.log("Shutting down gracefully...");
|
||||
server.close(() => {
|
||||
console.log("HTTP server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.error("Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
export default app;
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
||||
import { decryptSecret } from "@groombook/db";
|
||||
import { sendEmail } from "../services/email.js";
|
||||
|
||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
// Auth instance — initialized lazily via initAuth()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let authInstance: any = null;
|
||||
let authInitPromise: Promise<void> | null = null;
|
||||
|
||||
/** Returns the current auth instance. Throws if not yet initialized. */
|
||||
export function getAuth() {
|
||||
if (!authInstance) {
|
||||
throw new Error(
|
||||
"Auth not initialized. Call initAuth() at startup before handling requests."
|
||||
);
|
||||
}
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
/** Returns a promise that resolves when auth is initialized. */
|
||||
export function getAuthPromise() {
|
||||
return authInitPromise;
|
||||
}
|
||||
|
||||
/** Returns which OAuth/social providers are configured via env vars. */
|
||||
export function getActiveProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
providers.push("google");
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
providers.push("github");
|
||||
}
|
||||
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
|
||||
providers.push("authentik");
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-initializes the Better-Auth instance after auth config changes.
|
||||
*
|
||||
* Clears both authInstance and authInitPromise, then calls initAuth() to
|
||||
* re-read config from DB and build a fresh Better-Auth instance.
|
||||
* Sessions are DB-backed and survive the re-init.
|
||||
*/
|
||||
export async function reinitAuth(): Promise<void> {
|
||||
authInstance = null;
|
||||
authInitPromise = null;
|
||||
await initAuth();
|
||||
console.log("[auth] Re-initialized auth instance after config change");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Better-Auth instance.
|
||||
*
|
||||
* Config resolution chain:
|
||||
* 1. Query auth_provider_config table for an enabled provider
|
||||
* 2. If DB config exists → use it (decrypt clientSecret)
|
||||
* 3. If no DB config → fall back to OIDC_* env vars
|
||||
* 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied)
|
||||
*
|
||||
* Idempotent — subsequent calls return immediately after initialization completes.
|
||||
*/
|
||||
export async function initAuth(): Promise<void> {
|
||||
if (authInstance) return; // Already initialized
|
||||
if (authInitPromise) {
|
||||
await authInitPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
authInitPromise = (async () => {
|
||||
// Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode
|
||||
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
|
||||
throw new Error(
|
||||
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||
);
|
||||
}
|
||||
|
||||
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
||||
// config so auth.handler exists (middleware bypasses it anyway)
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET!,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 100,
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: "placeholder",
|
||||
clientSecret: "placeholder",
|
||||
discoveryUrl: undefined,
|
||||
scopes: ["openid", "profile", "email"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
updateAge: 60 * 60 * 24,
|
||||
cookieCache: { enabled: false },
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Try to load config from DB
|
||||
const db = getDb();
|
||||
const [dbConfig] = await db
|
||||
.select()
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
let providerConfig: {
|
||||
providerId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl?: string;
|
||||
scopes: string;
|
||||
};
|
||||
|
||||
if (dbConfig) {
|
||||
// Step 2: Use DB config (decrypt clientSecret)
|
||||
const decryptedSecret = decryptSecret(dbConfig.clientSecret);
|
||||
providerConfig = {
|
||||
providerId: dbConfig.providerId,
|
||||
clientId: dbConfig.clientId,
|
||||
clientSecret: decryptedSecret,
|
||||
issuerUrl: dbConfig.issuerUrl,
|
||||
internalBaseUrl: dbConfig.internalBaseUrl ?? undefined,
|
||||
scopes: dbConfig.scopes,
|
||||
};
|
||||
console.log("[auth] Using DB config for provider:", dbConfig.providerId);
|
||||
} else {
|
||||
// Step 3: Fall back to env vars
|
||||
const oidcIssuer = process.env.OIDC_ISSUER;
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID;
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
|
||||
if (!oidcIssuer || !oidcClientId || !oidcClientSecret) {
|
||||
// Step 4: Neither DB config nor env vars — auth is unconfigured
|
||||
console.warn(
|
||||
"[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars."
|
||||
);
|
||||
return; // authInstance stays null — AUTH_DISABLED mode
|
||||
}
|
||||
|
||||
providerConfig = {
|
||||
providerId: "authentik",
|
||||
clientId: oidcClientId,
|
||||
clientSecret: oidcClientSecret,
|
||||
issuerUrl: oidcIssuer,
|
||||
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
|
||||
scopes: "openid profile email",
|
||||
};
|
||||
console.log("[auth] Using env var config (no DB config found)");
|
||||
}
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
||||
const issuerHostname = issuerUrlObj.hostname;
|
||||
|
||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||
let oidcConfig: Record<string, string> = {};
|
||||
try {
|
||||
const discoveryRes = await fetch(discoveryUrlStr);
|
||||
if (discoveryRes.ok) {
|
||||
const discovery = await discoveryRes.json() as {
|
||||
authorization_endpoint?: string;
|
||||
token_endpoint?: string;
|
||||
userinfo_endpoint?: string;
|
||||
};
|
||||
const replaceHost = (url: string, newHost: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const newParsed = new URL(newHost);
|
||||
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const authzUrl = discovery.authorization_endpoint;
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const authzUrlObj = new URL(authzUrl);
|
||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
||||
if (authzUrlObj.hostname !== issuerHostname) {
|
||||
throw new Error(
|
||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||
);
|
||||
}
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
} else {
|
||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||
}
|
||||
} else {
|
||||
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
|
||||
}
|
||||
|
||||
// Build Better-Auth instance using resolved config
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
}),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 100,
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
account: {
|
||||
storeStateStrategy: "cookie" as const,
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your GroomBook email",
|
||||
text: `Click the link to verify your email: ${url}`,
|
||||
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: providerConfig.providerId,
|
||||
clientId: providerConfig.clientId,
|
||||
clientSecret: providerConfig.clientSecret,
|
||||
discoveryUrl: discoveryUrlStr,
|
||||
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
||||
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
...(hasGoogle ? {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
} : {}),
|
||||
...(hasGitHub ? {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
})();
|
||||
|
||||
await authInitPromise;
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
let s3Instance: S3Client | null = null;
|
||||
|
||||
function getS3Client(): S3Client {
|
||||
if (!s3Instance) {
|
||||
s3Instance = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
forcePathStyle: true, // required for Ceph RGW
|
||||
});
|
||||
}
|
||||
return s3Instance;
|
||||
}
|
||||
|
||||
function getBucket(): string {
|
||||
return process.env.S3_BUCKET ?? "groombook-pet-photos";
|
||||
}
|
||||
|
||||
/** Generate a presigned PUT URL for uploading a pet photo. Expires in 15 min. */
|
||||
export async function getPresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
sizeBytes: number,
|
||||
expiresIn = 900
|
||||
): Promise<string> {
|
||||
const client = getS3Client();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: sizeBytes,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/** Generate a presigned GET URL for viewing a pet photo. Expires in 1 hour. */
|
||||
export async function getPresignedGetUrl(
|
||||
key: string,
|
||||
expiresIn = 3600
|
||||
): Promise<string> {
|
||||
const client = getS3Client();
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/** Delete a pet photo object from storage. */
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Read an object from S3 and return its body buffer and content type. */
|
||||
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
|
||||
const client = getS3Client();
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
const chunks: Uint8Array[] = [];
|
||||
// response.Body is a Readable stream; collect chunks into a buffer
|
||||
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
const contentType = response.ContentType ?? "application/octet-stream";
|
||||
return { body, contentType };
|
||||
}
|
||||
|
||||
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
|
||||
export async function putObject(
|
||||
key: string,
|
||||
body: Buffer | Uint8Array | string,
|
||||
contentType: string,
|
||||
contentLength: number
|
||||
): Promise<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Business hours slot generation — pure utility, no DB dependencies.
|
||||
* Extracted so it can be unit tested independently of the route layer.
|
||||
*/
|
||||
|
||||
export const BUSINESS_START_HOUR = 9; // UTC
|
||||
export const BUSINESS_END_HOUR = 17; // UTC
|
||||
|
||||
export interface BookedSlot {
|
||||
staffId: string | null;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all available appointment start times for a given date,
|
||||
* returning only slots where at least one groomer is free.
|
||||
*/
|
||||
export function generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes,
|
||||
groomerIds,
|
||||
booked,
|
||||
}: {
|
||||
dateStr: string;
|
||||
durationMinutes: number;
|
||||
groomerIds: string[];
|
||||
booked: BookedSlot[];
|
||||
}): string[] {
|
||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
const durationMs = durationMinutes * 60_000;
|
||||
const slots: string[] = [];
|
||||
let slotStart = dayStart.getTime();
|
||||
|
||||
while (slotStart + durationMs <= dayEnd.getTime()) {
|
||||
const slotEnd = slotStart + durationMs;
|
||||
const hasGroomer = groomerIds.some(
|
||||
(groomerId) =>
|
||||
!booked.some(
|
||||
(a) =>
|
||||
a.staffId === groomerId &&
|
||||
a.startTime.getTime() < slotEnd &&
|
||||
a.endTime.getTime() > slotStart
|
||||
)
|
||||
);
|
||||
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
||||
slotStart += durationMs;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getAuth } from "../lib/auth.js";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Guard: refuse to start with AUTH_DISABLED in production.
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error(
|
||||
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
|
||||
"Remove AUTH_DISABLED from your environment and configure Better-Auth."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn(
|
||||
"[WARNING] AUTH_DISABLED=true — authentication is bypassed. " +
|
||||
"Do NOT use this in production."
|
||||
);
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as { sub: string });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = getAuth();
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware
|
||||
c.set("jwtPayload", {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
});
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
* Server-side audit logging middleware for portal routes.
|
||||
* Applied after validatePortalSession in the middleware chain.
|
||||
*
|
||||
* After the route handler completes (await next()), inserts an audit log entry
|
||||
* into impersonationAuditLogs:
|
||||
* - sessionId: from c.get("portalSessionId")
|
||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||
* - pageVisited: c.req.path
|
||||
* - metadata: { method, statusCode: c.res.status }
|
||||
*
|
||||
* Log entries are written for both success and error responses.
|
||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||
* request is not affected.
|
||||
*/
|
||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
await next();
|
||||
|
||||
const sessionId = c.get("portalSessionId");
|
||||
if (!sessionId) return;
|
||||
|
||||
const method = c.req.method;
|
||||
const routePath = c.req.path;
|
||||
const pageVisited = c.req.path;
|
||||
const statusCode = c.res.status;
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId,
|
||||
action: `${method} ${routePath}`,
|
||||
pageVisited,
|
||||
metadata: { method, statusCode },
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
console.error("[portalAudit] Failed to write audit log:", err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
portalClientId: string;
|
||||
portalSessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||
* Must be applied to all portal routes.
|
||||
*
|
||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||
* Returns 401 if session is invalid/missing/expired.
|
||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||
*/
|
||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("portalClientId", session.clientId);
|
||||
c.set("portalSessionId", session.id);
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
jwtPayload: { sub: string; email?: string; name?: string };
|
||||
staff: StaffRow;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the authenticated staff record from the DB and stores it in context.
|
||||
* Must be applied after authMiddleware on all protected routes.
|
||||
*
|
||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth
|
||||
* user ID), or falls back to the first manager in the DB.
|
||||
*/
|
||||
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next
|
||||
) => {
|
||||
// Better-Auth's own routes handle their own auth — skip staff resolution
|
||||
// OOBE setup routes also handle their own auth — staff record is created during setup
|
||||
if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
if (!devUserId) {
|
||||
// No header — fall back to first manager
|
||||
const [manager] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.role, "manager"))
|
||||
.limit(1);
|
||||
if (!manager) {
|
||||
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
||||
}
|
||||
c.set("staff", { ...manager, isSuperUser: manager.isSuperUser ?? false });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Treat X-Dev-User-Id as the Better-Auth user ID first
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, devUserId));
|
||||
if (row) {
|
||||
c.set("staff", { ...row, isSuperUser: row.isSuperUser ?? false });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login
|
||||
// may send the primary key for staff records that predate the userId field)
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, devUserId));
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
|
||||
403
|
||||
);
|
||||
}
|
||||
c.set("staff", { ...fallbackRow, isSuperUser: fallbackRow.isSuperUser ?? false });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = c.get("jwtPayload");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, jwt.sub));
|
||||
if (row) {
|
||||
c.set("staff", row);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: staff records that predate the userId field may still have oidcSub
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (fallbackRow) {
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Auto-link by email: staff record exists with matching email but no userId
|
||||
if (jwt.email) {
|
||||
const [byEmail] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||
if (byEmail) {
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub, updatedAt: new Date() })
|
||||
.where(eq(staff.id, byEmail.id));
|
||||
c.set("staff", { ...byEmail, userId: jwt.sub });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware factory that enforces one of the allowed roles.
|
||||
* Must be applied after resolveStaffMiddleware.
|
||||
*
|
||||
* @example
|
||||
* api.use("/staff/*", requireRole("manager"));
|
||||
* api.use("/reports/*", requireRole("manager"));
|
||||
*/
|
||||
export function requireRole(
|
||||
...allowedRoles: StaffRole[]
|
||||
): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
if (!(allowedRoles as string[]).includes(staffRow.role)) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
|
||||
* Use for routes where managers OR super-users should have access.
|
||||
*
|
||||
* @example
|
||||
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
*/
|
||||
export function requireRoleOrSuperUser(
|
||||
...allowedRoles: StaffRole[]
|
||||
): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
|
||||
if (hasAllowedRole || staffRow.isSuperUser) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
},
|
||||
403
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that enforces the staff member is a super user.
|
||||
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
|
||||
*
|
||||
* @example
|
||||
* api.use("/staff/*", requireRole("manager"));
|
||||
* api.use("/staff/*", requireSuperUser());
|
||||
*/
|
||||
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
if (!staffRow.isSuperUser) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: super user privileges required" },
|
||||
403
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Admin seed endpoint — populates minimal known-user seed data via the API.
|
||||
*
|
||||
* This is the canonical way to seed prod/demo data. The old approach (seed.ts
|
||||
* writing directly to the DB) bypasses API validation and audit trails.
|
||||
*
|
||||
* Security: This endpoint is manager-only (enforced via requireRole in index.ts).
|
||||
* It is disabled when AUTH_DISABLED=true — dev/test seeding should use the
|
||||
* direct-DB seed.ts in that mode.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { eq, getDb, staff, clients, pets, services } from "@groombook/db";
|
||||
|
||||
export const adminSeedRouter = new Hono();
|
||||
|
||||
const KNOWN_STAFF = {
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager" as const,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const KNOWN_CLIENT = {
|
||||
name: "Demo Client",
|
||||
email: "demo-client@example.com",
|
||||
phone: "555-0001",
|
||||
address: "1 Demo Street, Demo City, CA 90210",
|
||||
};
|
||||
|
||||
const DEMO_PET = {
|
||||
name: "Demo Dog",
|
||||
species: "Dog",
|
||||
breed: "Golden Retriever",
|
||||
weightKg: "30.00",
|
||||
};
|
||||
|
||||
const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||
];
|
||||
|
||||
adminSeedRouter.post("/seed", async (c) => {
|
||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Seed endpoint is not available when AUTH_DISABLED=true. Use direct DB seeding for dev/test environments.",
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const results: string[] = [];
|
||||
|
||||
// ── Staff: Demo Manager ─────────────────────────────────────────────────────
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.email, KNOWN_STAFF.email));
|
||||
|
||||
if (existingStaff) {
|
||||
results.push(`Staff '${KNOWN_STAFF.name}' already exists (id: ${existingStaff.id})`);
|
||||
} else {
|
||||
const [created] = await db.insert(staff).values(KNOWN_STAFF).returning();
|
||||
results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`);
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ────────────────────
|
||||
// NOTE: UNIQUE constraint on services.name must exist (via migration 0020).
|
||||
// Both this admin seed and the main DB seed use the same deterministic IDs
|
||||
// and ON CONFLICT (name), ensuring consistency across both seed paths.
|
||||
for (const svc of DEMO_SERVICES) {
|
||||
await db.insert(services)
|
||||
.values({ ...svc, active: true })
|
||||
.onConflictDoUpdate({
|
||||
target: services.name,
|
||||
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
||||
});
|
||||
}
|
||||
results.push(`Upserted ${DEMO_SERVICES.length} services`);
|
||||
|
||||
// ── Client: Demo Client ───────────────────────────────────────────────────
|
||||
const [existingClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, KNOWN_CLIENT.email));
|
||||
|
||||
let clientId: string;
|
||||
if (existingClient) {
|
||||
clientId = existingClient.id;
|
||||
results.push(`Client '${KNOWN_CLIENT.name}' already exists (id: ${clientId})`);
|
||||
} else {
|
||||
const [created] = await db.insert(clients).values(KNOWN_CLIENT).returning();
|
||||
clientId = created!.id;
|
||||
results.push(`Created client '${KNOWN_CLIENT.name}' (id: ${clientId})`);
|
||||
}
|
||||
|
||||
// ── Pet: Demo Dog ──────────────────────────────────────────────────────────
|
||||
const existingPets = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, clientId));
|
||||
|
||||
const demoDog = existingPets.find(
|
||||
(p) => p.name === DEMO_PET.name && p.species === DEMO_PET.species
|
||||
);
|
||||
|
||||
if (demoDog) {
|
||||
results.push(`Pet '${DEMO_PET.name}' already exists for Demo Client (id: ${demoDog.id})`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId,
|
||||
name: DEMO_PET.name,
|
||||
species: DEMO_PET.species,
|
||||
breed: DEMO_PET.breed,
|
||||
weightKg: DEMO_PET.weightKg,
|
||||
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
|
||||
})
|
||||
.returning();
|
||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Seed complete",
|
||||
details: results,
|
||||
credentials: {
|
||||
note: "For dev-mode access, use X-Dev-User-Id: demo-manager-001 header",
|
||||
staffOidcSub: KNOWN_STAFF.oidcSub,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
appointmentGroups,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const petAppointmentSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
// Each pet may have a different end time (e.g. small dog done faster)
|
||||
endTime: z.string().datetime(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const createGroupSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
startTime: z.string().datetime(),
|
||||
// One entry per pet
|
||||
pets: z.array(petAppointmentSchema).min(2, "A group booking requires at least 2 pets"),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const updateGroupSchema = z.object({
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
// ─── List groups (compact, with appointment count and start time) ─────────────
|
||||
|
||||
appointmentGroupsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const groupConditions = clientId
|
||||
? [eq(appointmentGroups.clientId, clientId)]
|
||||
: [];
|
||||
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(appointmentGroups)
|
||||
.where(groupConditions.length > 0 ? and(...groupConditions) : undefined)
|
||||
.orderBy(appointmentGroups.createdAt);
|
||||
|
||||
if (groups.length === 0) return c.json([]);
|
||||
|
||||
// Fetch appointments for all groups (filter by time range if provided)
|
||||
const apptConditions = [];
|
||||
if (from) apptConditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) apptConditions.push(lte(appointments.startTime, new Date(to)));
|
||||
|
||||
const allAppts = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(apptConditions.length > 0 ? and(...apptConditions) : undefined);
|
||||
|
||||
const groupApptMap = new Map<string, typeof appointments.$inferSelect[]>();
|
||||
for (const appt of allAppts) {
|
||||
if (!appt.groupId) continue;
|
||||
if (!groupApptMap.has(appt.groupId)) groupApptMap.set(appt.groupId, []);
|
||||
groupApptMap.get(appt.groupId)!.push(appt);
|
||||
}
|
||||
|
||||
const result = groups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
appointments: (groupApptMap.get(g.id) ?? []).sort(
|
||||
(a, b) => a.startTime.getTime() - b.startTime.getTime()
|
||||
),
|
||||
}))
|
||||
.filter((g) => !from || g.appointments.length > 0);
|
||||
|
||||
if (isGroomer) {
|
||||
return c.json(
|
||||
result.filter((g) =>
|
||||
g.appointments.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ─── Get single group with its appointments ───────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select()
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const groupAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
petId: appointments.petId,
|
||||
petName: pets.name,
|
||||
serviceId: appointments.serviceId,
|
||||
serviceName: services.name,
|
||||
staffId: appointments.staffId,
|
||||
batherStaffId: appointments.batherStaffId,
|
||||
staffName: staff.name,
|
||||
status: appointments.status,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
priceCents: appointments.priceCents,
|
||||
notes: appointments.notes,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(pets, eq(appointments.petId, pets.id))
|
||||
.leftJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(eq(appointments.groupId, id))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
if (
|
||||
isGroomer &&
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, group.clientId));
|
||||
|
||||
return c.json({ ...group, client, appointments: groupAppts });
|
||||
});
|
||||
|
||||
// ─── Create group booking ─────────────────────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
if (staffRow?.role === "groomer") {
|
||||
return c.json(
|
||||
{ error: "Forbidden: groomers cannot create group bookings" },
|
||||
403
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
const startTime = new Date(body.startTime);
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId));
|
||||
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||
|
||||
// Verify all pets belong to this client
|
||||
const petIds = body.pets.map((p) => p.petId);
|
||||
const petRows = await db
|
||||
.select({ id: pets.id, clientId: pets.clientId })
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, body.clientId));
|
||||
const ownedPetIds = new Set(petRows.map((p) => p.id));
|
||||
const unauthorized = petIds.filter((id) => !ownedPetIds.has(id));
|
||||
if (unauthorized.length > 0) {
|
||||
return c.json({ error: `Pet(s) not found for this client: ${unauthorized.join(", ")}` }, 422);
|
||||
}
|
||||
|
||||
// Deduplicate pets in a single booking
|
||||
if (new Set(petIds).size !== petIds.length) {
|
||||
return c.json({ error: "Each pet can only appear once per group booking" }, 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Check conflicts for each staff member
|
||||
for (const pet of body.pets) {
|
||||
if (!pet.staffId) continue;
|
||||
const endTime = new Date(pet.endTime);
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, pet.staffId),
|
||||
lt(appointments.startTime, endTime),
|
||||
gte(appointments.endTime, startTime),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(`Staff conflict for pet ${pet.petId}`),
|
||||
{ statusCode: 409, petId: pet.petId, staffId: pet.staffId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the group record
|
||||
const [group] = await tx
|
||||
.insert(appointmentGroups)
|
||||
.values({ clientId: body.clientId, notes: body.notes ?? null })
|
||||
.returning();
|
||||
if (!group) throw new Error("Failed to create appointment group");
|
||||
|
||||
// Create one appointment per pet
|
||||
const createdAppts = [];
|
||||
for (const pet of body.pets) {
|
||||
const endTime = new Date(pet.endTime);
|
||||
const [appt] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
clientId: body.clientId,
|
||||
petId: pet.petId,
|
||||
serviceId: pet.serviceId,
|
||||
staffId: pet.staffId ?? null,
|
||||
startTime,
|
||||
endTime,
|
||||
priceCents: pet.priceCents ?? null,
|
||||
groupId: group.id,
|
||||
})
|
||||
.returning();
|
||||
if (appt) createdAppts.push(appt);
|
||||
}
|
||||
|
||||
return { group, appointments: createdAppts };
|
||||
});
|
||||
|
||||
return c.json(result, 201);
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { statusCode?: number };
|
||||
if (e.statusCode === 409) {
|
||||
return c.json({ error: "A staff member has a conflicting appointment at this time", detail: e.message }, 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Update group notes ───────────────────────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointmentGroups)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(appointmentGroups.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Cancel all appointments in a group ──────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.groupId, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,845 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
or,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
recurringSeries,
|
||||
reminderLogs,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
delayMs: number,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[appointments] ${context}: ${lastError}`);
|
||||
}
|
||||
|
||||
export const appointmentsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createAppointmentSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
batherStaffId: z.string().uuid().optional(),
|
||||
startTime: z.string().datetime(),
|
||||
endTime: z.string().datetime(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
||||
recurrence: z
|
||||
.object({
|
||||
frequencyWeeks: z.number().int().min(1).max(52),
|
||||
count: z.number().int().min(2).max(52),
|
||||
})
|
||||
.refine(
|
||||
(r) => r.frequencyWeeks * r.count <= 52,
|
||||
{ message: "Recurrence series must not exceed 1 year" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const updateAppointmentSchema = z.object({
|
||||
staffId: z.string().uuid().nullable().optional(),
|
||||
batherStaffId: z.string().uuid().nullable().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
"scheduled",
|
||||
"confirmed",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"no_show",
|
||||
])
|
||||
.optional(),
|
||||
startTime: z.string().datetime().optional(),
|
||||
endTime: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
priceCents: z.number().int().positive().nullable().optional(),
|
||||
// When updating a series member, optionally propagate the change
|
||||
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
|
||||
});
|
||||
|
||||
// List appointments, optionally filtered by date range or staffId.
|
||||
// Groomers see only their own appointments (staffId or batherStaffId).
|
||||
appointmentsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffId = c.req.query("staffId");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const conditions = [];
|
||||
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||
|
||||
// Groomer: restrict to their own appointments (as groomer or bather)
|
||||
if (isGroomer) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(and(...conditions))
|
||||
.orderBy(appointments.startTime)
|
||||
: await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
appointmentsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if not assigned as groomer or bather
|
||||
if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
const end = new Date(body.endTime);
|
||||
|
||||
if (end <= start) {
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
}
|
||||
|
||||
const { recurrence, ...apptFields } = body;
|
||||
|
||||
// Wrap conflict check + insert in a transaction to prevent double-booking
|
||||
// race conditions under concurrent load (fixes #18).
|
||||
let firstRow: typeof appointments.$inferSelect;
|
||||
try {
|
||||
firstRow = await db.transaction(async (tx) => {
|
||||
// Conflict check applies to the first occurrence only; subsequent
|
||||
// occurrences are spread weeks apart so conflicts are unlikely and can
|
||||
// be resolved individually if needed.
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrence) {
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({ ...apptFields, startTime: start, endTime: end })
|
||||
.returning();
|
||||
if (!inserted) throw new Error("Insert failed");
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// Create recurring series
|
||||
const seriesRows = await tx
|
||||
.insert(recurringSeries)
|
||||
.values({ frequencyWeeks: recurrence.frequencyWeeks })
|
||||
.returning();
|
||||
const series = seriesRows[0];
|
||||
if (!series) throw new Error("Failed to create recurring series");
|
||||
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const intervalMs =
|
||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let first: typeof appointments.$inferSelect | undefined;
|
||||
const conflictingInstances: number[] = [];
|
||||
for (let i = 0; i < recurrence.count; i++) {
|
||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||
const instanceEnd = new Date(
|
||||
instanceStart.getTime() + durationMs
|
||||
);
|
||||
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
...apptFields,
|
||||
startTime: instanceStart,
|
||||
endTime: instanceEnd,
|
||||
seriesId: series.id,
|
||||
seriesIndex: i,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||
if (i === 0) first = inserted;
|
||||
}
|
||||
|
||||
if (conflictingInstances.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
||||
),
|
||||
{ statusCode: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!first) throw new Error("No appointments created");
|
||||
return first;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err as Error & { statusCode?: number }).statusCode === 409
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "Staff member has a conflicting appointment at this time" },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send confirmation email (fire-and-forget — never fails the request)
|
||||
withRetry(
|
||||
() => sendConfirmationEmail(db, firstRow),
|
||||
2,
|
||||
1000,
|
||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||
);
|
||||
|
||||
return c.json(firstRow, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Confirmation email helper ─────────────────────────────────────────────
|
||||
|
||||
async function sendConfirmationEmail(
|
||||
db: ReturnType<typeof getDb>,
|
||||
appt: typeof appointments.$inferSelect
|
||||
): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(eq(appointments.id, appt.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildConfirmationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
startTime: appt.startTime,
|
||||
})
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: "confirmation" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
appointmentsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const { cascadeMode = "this_only", ...updateFields } = body;
|
||||
|
||||
// ── Cascade update (this_and_future / all) ────────────────────────────────
|
||||
if (cascadeMode !== "this_only") {
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) {
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
// Compute time deltas and apply them uniformly across the series so
|
||||
// all instances shift by the same amount (e.g. rescheduled 1 hr later).
|
||||
const startDeltaMs = updateFields.startTime
|
||||
? new Date(updateFields.startTime).getTime() -
|
||||
current.startTime.getTime()
|
||||
: 0;
|
||||
const endDeltaMs = updateFields.endTime
|
||||
? new Date(updateFields.endTime).getTime() -
|
||||
current.endTime.getTime()
|
||||
: 0;
|
||||
|
||||
// Validate resulting times on the anchor appointment
|
||||
const newStart = new Date(
|
||||
current.startTime.getTime() + startDeltaMs
|
||||
);
|
||||
const newEnd = new Date(current.endTime.getTime() + endDeltaMs);
|
||||
if (newEnd <= newStart) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which appointments to update
|
||||
let whereClause;
|
||||
if (current.seriesId && current.seriesIndex !== null) {
|
||||
whereClause =
|
||||
cascadeMode === "this_and_future"
|
||||
? and(
|
||||
eq(appointments.seriesId, current.seriesId),
|
||||
gte(appointments.seriesIndex, current.seriesIndex),
|
||||
)
|
||||
: eq(appointments.seriesId, current.seriesId);
|
||||
} else {
|
||||
// Not part of a series — fall back to single update
|
||||
whereClause = eq(appointments.id, id);
|
||||
}
|
||||
|
||||
const affected = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(whereClause);
|
||||
|
||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||
for (const appt of affected) {
|
||||
const newStart =
|
||||
startDeltaMs !== 0
|
||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
||||
: appt.startTime;
|
||||
const newEnd =
|
||||
endDeltaMs !== 0
|
||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
||||
: appt.endTime;
|
||||
const newStaffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: appt.staffId;
|
||||
const newBatherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: appt.batherStaffId;
|
||||
|
||||
if (
|
||||
newStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.staffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, newStaffId),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newBatherStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.batherStaffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, newBatherStaffId),
|
||||
eq(appointments.batherStaffId, newBatherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const apptUpdate: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (updateFields.staffId !== undefined)
|
||||
apptUpdate.staffId = updateFields.staffId;
|
||||
if (updateFields.notes !== undefined)
|
||||
apptUpdate.notes = updateFields.notes;
|
||||
if (updateFields.status !== undefined)
|
||||
apptUpdate.status = updateFields.status;
|
||||
if (updateFields.priceCents !== undefined)
|
||||
apptUpdate.priceCents = updateFields.priceCents;
|
||||
if (startDeltaMs !== 0)
|
||||
apptUpdate.startTime = new Date(
|
||||
appt.startTime.getTime() + startDeltaMs
|
||||
);
|
||||
if (endDeltaMs !== 0)
|
||||
apptUpdate.endTime = new Date(
|
||||
appt.endTime.getTime() + endDeltaMs
|
||||
);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(apptUpdate)
|
||||
.where(eq(appointments.id, appt.id))
|
||||
.returning();
|
||||
if (appt.id === id) firstUpdated = updated;
|
||||
}
|
||||
|
||||
return firstUpdated;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
// ── this_only (original logic) ────────────────────────────────────────────
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (updateFields.startTime) update.startTime = new Date(updateFields.startTime);
|
||||
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
||||
|
||||
if (needsConflictCheck) {
|
||||
// Wrap conflict check + update in a transaction to prevent race conditions
|
||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) {
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
const start = updateFields.startTime
|
||||
? new Date(updateFields.startTime)
|
||||
: current.startTime;
|
||||
const end = updateFields.endTime
|
||||
? new Date(updateFields.endTime)
|
||||
: current.endTime;
|
||||
// Use provided staffId (may be null to unassign); fall back to existing
|
||||
const staffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
if (staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, batherStaffId),
|
||||
eq(appointments.batherStaffId, batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// Soft-delete: cancel the appointment instead of removing the row,
|
||||
// preserving audit trail and financial records (fixes #20).
|
||||
// Optional ?cascade=this_only|this_and_future|all for series appointments.
|
||||
appointmentsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const cascade = c.req.query("cascade") ?? "this_only";
|
||||
|
||||
if (cascade === "this_and_future" || cascade === "all") {
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (current.seriesId && current.seriesIndex !== null) {
|
||||
const whereClause =
|
||||
cascade === "this_and_future"
|
||||
? and(
|
||||
eq(appointments.seriesId, current.seriesId),
|
||||
gte(appointments.seriesIndex, current.seriesIndex),
|
||||
)
|
||||
: eq(appointments.seriesId, current.seriesId);
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(whereClause);
|
||||
} else {
|
||||
// Not in a series — cancel only this one
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id));
|
||||
}
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
// Single cancel (default)
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/confirm ───────────────────────────────────────
|
||||
// Staff/portal: confirm a specific appointment by ID. Idempotent.
|
||||
|
||||
appointmentsRouter.post("/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Cannot confirm a cancelled appointment" }, 409);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.json(appt); // idempotent
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/cancel ───────────────────────────────────────
|
||||
// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified.
|
||||
|
||||
appointmentsRouter.post("/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Appointment is already cancelled" }, 409);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── Token generation helper ──────────────────────────────────────────────────
|
||||
|
||||
export function generateConfirmationToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
import { reinitAuth } from "../lib/auth.js";
|
||||
|
||||
export const authProviderRouter = new Hono();
|
||||
|
||||
const REDACTED = "••••••••";
|
||||
|
||||
const putAuthProviderSchema = z.object({
|
||||
providerId: z.string().min(1).max(100),
|
||||
displayName: z.string().min(1).max(200),
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.string().default("openid profile email"),
|
||||
});
|
||||
|
||||
/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */
|
||||
const authProviderTestSchema = z.object({
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/auth-provider
|
||||
* Returns the current provider config with clientSecret redacted.
|
||||
* Returns 404 if no provider is configured.
|
||||
*/
|
||||
authProviderRouter.get(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "No auth provider configured" }, 404);
|
||||
}
|
||||
|
||||
// Return with secret redacted
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
clientSecret: REDACTED,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/admin/auth-provider
|
||||
* Creates or replaces the auth provider config.
|
||||
* The clientSecret is encrypted before storage.
|
||||
*/
|
||||
authProviderRouter.put(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
zValidator("json", putAuthProviderSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
let encryptedSecret: string;
|
||||
try {
|
||||
encryptedSecret = encryptSecret(body.clientSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to encrypt client secret: ${message}` }, 500);
|
||||
}
|
||||
|
||||
// Upsert: delete existing rows then insert atomically
|
||||
let row: typeof authProviderConfig.$inferSelect | undefined;
|
||||
try {
|
||||
[row] = await db.transaction(async (tx) => {
|
||||
await tx.delete(authProviderConfig);
|
||||
return tx.insert(authProviderConfig).values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
}).returning();
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to persist auth provider config: ${message}` }, 500);
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Failed to create auth provider config" }, 500);
|
||||
|
||||
try {
|
||||
await reinitAuth();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
clientSecret: REDACTED,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/auth-provider/test
|
||||
* Validates the provider config by hitting the OIDC discovery endpoint.
|
||||
* Returns {ok: true, metadata} on success or {ok: false, error: string} on failure.
|
||||
*/
|
||||
authProviderRouter.post(
|
||||
"/test",
|
||||
requireSuperUser(),
|
||||
zValidator("json", authProviderTestSchema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const discoveryUrl = `${body.issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) {
|
||||
return c.json({ ok: false, error: `Discovery endpoint returned ${res.status}` });
|
||||
}
|
||||
const metadata = await res.json() as Record<string, unknown>;
|
||||
return c.json({ ok: true, metadata });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ ok: false, error: message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/auth-provider
|
||||
* Removes the auth provider config from the DB.
|
||||
* After this, auth falls back to OIDC_* env vars.
|
||||
*/
|
||||
authProviderRouter.delete(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
await db.delete(authProviderConfig);
|
||||
try {
|
||||
await reinitAuth();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
lt,
|
||||
ne,
|
||||
getDb,
|
||||
services,
|
||||
staff,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
} from "@groombook/db";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
|
||||
export const bookRouter = new Hono();
|
||||
|
||||
// ─── GET /api/book/services ─────────────────────────────────────────────────
|
||||
// Public: list active services for the booking flow
|
||||
|
||||
bookRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.active, true))
|
||||
.orderBy(services.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return c.json({ error: "date must be YYYY-MM-DD" }, 400);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
|
||||
|
||||
if (groomers.length === 0) return c.json([]);
|
||||
|
||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
// Fetch all active appointments for the day (any groomer)
|
||||
const booked = await db
|
||||
.select({
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, dayStart),
|
||||
lt(appointments.startTime, dayEnd),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes: service.durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
});
|
||||
|
||||
return c.json(slots);
|
||||
});
|
||||
|
||||
// ─── POST /api/book/appointments ─────────────────────────────────────────────
|
||||
// Public: create a booking. Finds or creates client by email, always creates pet.
|
||||
|
||||
const bookingSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
startTime: z.string().datetime().refine(
|
||||
(dt) => new Date(dt) > new Date(),
|
||||
{ message: "Appointment must be in the future" }
|
||||
),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
petName: z.string().min(1).max(200),
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
bookRouter.post(
|
||||
"/appointments",
|
||||
zValidator("json", bookingSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
|
||||
// Find all active groomers
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
|
||||
|
||||
if (groomers.length === 0) {
|
||||
return c.json({ error: "No groomers available" }, 409);
|
||||
}
|
||||
|
||||
// Find conflicting appointments for this time window
|
||||
const booked = await db
|
||||
.select({ staffId: appointments.staffId })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
const busyIds = new Set(booked.map((a) => a.staffId));
|
||||
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
|
||||
if (!freeGroomer) {
|
||||
return c.json(
|
||||
{ error: "No groomers available at this time. Please choose another slot." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// Find or create client by email (skip disabled clients)
|
||||
let [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active")));
|
||||
|
||||
if (!client) {
|
||||
const inserted = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
name: body.clientName,
|
||||
email: body.clientEmail,
|
||||
phone: body.clientPhone ?? null,
|
||||
})
|
||||
.returning();
|
||||
client = inserted[0];
|
||||
}
|
||||
|
||||
if (!client) return c.json({ error: "Failed to create client" }, 500);
|
||||
|
||||
// Create pet
|
||||
const petInserted = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId: client.id,
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||
|
||||
// Insert appointment in a transaction to guard against race conditions
|
||||
let appointment;
|
||||
try {
|
||||
appointment = await db.transaction(async (tx) => {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, freeGroomer.id),
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
|
||||
const apptInserted = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: body.serviceId,
|
||||
staffId: freeGroomer.id,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
notes: body.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
return apptInserted[0];
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const code = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (code === 409) {
|
||||
return c.json(
|
||||
{ error: "This slot was just taken. Please choose another time." },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!appointment) return c.json({ error: "Failed to create appointment" }, 500);
|
||||
|
||||
return c.json({ appointment, client, pet }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/book/confirm/:token ──────────────────────────────────────────
|
||||
// Public: confirm appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173";
|
||||
|
||||
bookRouter.get("/confirm/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/cancel/:token ───────────────────────────────────────────
|
||||
// Public: cancel appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
bookRouter.get("/cancel/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
getDb,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
|
||||
export const calendarRouter = new Hono();
|
||||
|
||||
function formatIcalDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
function escapeIcalText(text: string | null): string {
|
||||
if (!text) return "";
|
||||
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
function buildIcalFeed(
|
||||
appointments: Array<{
|
||||
id: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: string;
|
||||
clientName: string | null;
|
||||
petName: string | null;
|
||||
serviceName: string | null;
|
||||
}>,
|
||||
staffName: string,
|
||||
dtstamp: string
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//GroomBook//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
`X-WR-CALNAME:${escapeIcalText(staffName)} - GroomBook`,
|
||||
];
|
||||
|
||||
for (const appt of appointments) {
|
||||
const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED";
|
||||
const sequence = appt.status === "cancelled" ? "1" : "0";
|
||||
const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`;
|
||||
const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`;
|
||||
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${appt.id}@groombook`,
|
||||
`DTSTAMP:${dtstamp}`,
|
||||
`DTSTART:${formatIcalDate(new Date(appt.startTime))}`,
|
||||
`DTEND:${formatIcalDate(new Date(appt.endTime))}`,
|
||||
`SUMMARY:${escapeIcalText(summary)}`,
|
||||
`DESCRIPTION:${escapeIcalText(description)}`,
|
||||
`STATUS:${status}`,
|
||||
`SEQUENCE:${sequence}`,
|
||||
"END:VEVENT"
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
const db = getDb();
|
||||
const staffId = c.req.param("staffId") as string;
|
||||
const token = c.req.query("token") as string;
|
||||
|
||||
if (!token) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const [staffMember] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (!staffMember || !staffMember.icalToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const storedToken = staffMember.icalToken;
|
||||
const incomingToken = token;
|
||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
||||
if (
|
||||
storedBuf.length !== incomingBuf.length ||
|
||||
!timingSafeEqual(storedBuf, incomingBuf)
|
||||
) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
clientName: clients.name,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
gte(appointments.startTime, now)
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const ical = buildIcalFeed(rows, staffMember.name, formatIcalDate(new Date()));
|
||||
return c.text(ical, 200, {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`,
|
||||
});
|
||||
});
|
||||
|
||||
export function generateIcalToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
smsOptIn: z.boolean().optional(),
|
||||
smsConsentText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
// List clients — defaults to active only, ?includeDisabled=true shows all.
|
||||
// Groomers see only clients with ≥1 appointment assigned to them.
|
||||
clientsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeDisabled = c.req.query("includeDisabled") === "true";
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Groomer: subquery for clients with an appointment for this groomer
|
||||
const groomerApptFilter = isGroomer
|
||||
? exists(
|
||||
db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clients.id),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const conditions = [];
|
||||
if (!includeDisabled) conditions.push(eq(clients.status, "active"));
|
||||
if (groomerApptFilter) conditions.push(groomerApptFilter);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(clients.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// Get a single client
|
||||
clientsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if no appointment linkage to this client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
// Create a client
|
||||
clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(clients).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
smsOptOut: z.boolean().optional(),
|
||||
});
|
||||
|
||||
clientsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", patchClientSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const now = new Date();
|
||||
|
||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||
|
||||
if (body.status === "disabled") {
|
||||
setValues.disabledAt = now;
|
||||
} else if (body.status === "active") {
|
||||
setValues.disabledAt = null;
|
||||
}
|
||||
|
||||
if (body.smsOptOut === true) {
|
||||
setValues.smsOptIn = false;
|
||||
setValues.smsOptOutDate = now;
|
||||
delete setValues.smsOptOut;
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
.where(eq(clients.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a client — requires ?confirm=true query param
|
||||
clientsRouter.delete("/:id", async (c) => {
|
||||
const confirm = c.req.query("confirm");
|
||||
if (confirm !== "true") {
|
||||
return c.json(
|
||||
{ error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
|
||||
const [existingAppt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existingAppt) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, staff, clients, eq, sql } from "@groombook/db";
|
||||
|
||||
const devRouter = new Hono();
|
||||
|
||||
// GET /api/dev/config — tells the frontend whether auth is disabled
|
||||
devRouter.get("/config", (c) => {
|
||||
return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" });
|
||||
});
|
||||
|
||||
// GET /api/dev/users — list staff and clients for the login selector
|
||||
// Only available when AUTH_DISABLED=true
|
||||
devRouter.get("/users", async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const staffList = await db
|
||||
.select({
|
||||
id: staff.id,
|
||||
userId: staff.userId,
|
||||
name: staff.name,
|
||||
email: staff.email,
|
||||
role: staff.role,
|
||||
})
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.orderBy(staff.name);
|
||||
|
||||
const clientList = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
petCount: sql<number>`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"),
|
||||
})
|
||||
.from(clients)
|
||||
.orderBy(clients.name)
|
||||
.limit(20);
|
||||
|
||||
return c.json({ staff: staffList, clients: clientList });
|
||||
});
|
||||
|
||||
export { devRouter };
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createLogSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
cutStyle: z.string().max(500).optional(),
|
||||
productsUsed: z.string().max(1000).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
groomedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// GET /api/grooming-logs?petId=<uuid>
|
||||
groomingLogsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.query("petId");
|
||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.petId, petId))
|
||||
.orderBy(desc(groomingVisitLogs.groomedAt));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
groomingLogsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createLogSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
if (appointmentId) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.id, appointmentId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
} else {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
groomingLogsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [log] = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.limit(1);
|
||||
if (!log) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, log.petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.returning();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
clients,
|
||||
desc,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const impersonationRouter = new Hono<AppEnv>();
|
||||
|
||||
const SESSION_TIMEOUT_MINUTES = 30;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
/** Expire any timed-out active sessions for a given staff member. */
|
||||
async function expireTimedOutSessions(staffId: string) {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const active = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.staffId, staffId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
);
|
||||
for (const s of active) {
|
||||
if (s.expiresAt <= now) {
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "expired", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, s.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an active session has expired by time. If so, mark it expired in DB
|
||||
* and return true. Returns false if the session is still valid.
|
||||
*/
|
||||
async function checkAndExpireSession(
|
||||
session: typeof impersonationSessions.$inferSelect
|
||||
): Promise<boolean> {
|
||||
if (session.status !== "active") return false;
|
||||
if (session.expiresAt > new Date()) return false;
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "expired", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, session.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── POST /sessions — Start a new impersonation session ─────────────────────
|
||||
// requireRole("manager") is enforced by index.ts middleware on /impersonation/*
|
||||
|
||||
const startSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
impersonationRouter.post(
|
||||
"/sessions",
|
||||
zValidator("json", startSessionSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId));
|
||||
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||
|
||||
// Expire timed-out sessions first
|
||||
await expireTimedOutSessions(staffRow.id);
|
||||
|
||||
// Enforce one active session per staff member
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.staffId, staffRow.id),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
);
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "You already have an active impersonation session", sessionId: existing.id },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId: staffRow.id,
|
||||
clientId: body.clientId,
|
||||
reason: body.reason ?? null,
|
||||
expiresAt: expiresAt(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Log session start
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session!.id,
|
||||
action: "session_started",
|
||||
metadata: { reason: body.reason ?? null },
|
||||
});
|
||||
|
||||
return c.json(session!, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /sessions/:id — Get session details ────────────────────────────────
|
||||
|
||||
impersonationRouter.get("/sessions/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
|
||||
// Auto-expire if timed out
|
||||
if (await checkAndExpireSession(session)) {
|
||||
session.status = "expired";
|
||||
session.endedAt = new Date();
|
||||
}
|
||||
|
||||
return c.json(session);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/extend — Extend session timeout ─────────────────────
|
||||
|
||||
impersonationRouter.post("/sessions/:id/extend", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const newExpiry = expiresAt();
|
||||
const [updated] = await db
|
||||
.update(impersonationSessions)
|
||||
.set({ expiresAt: newExpiry })
|
||||
.where(eq(impersonationSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session.id,
|
||||
action: "session_extended",
|
||||
metadata: { newExpiresAt: newExpiry.toISOString() },
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/end — End session ────────────────────────────────────
|
||||
|
||||
impersonationRouter.post("/sessions/:id/end", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "ended", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session.id,
|
||||
action: "session_ended",
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/log — Log an audit entry ────────────────────────────
|
||||
|
||||
const logEntrySchema = z.object({
|
||||
action: z.string().min(1).max(200),
|
||||
pageVisited: z.string().max(500).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
impersonationRouter.post(
|
||||
"/sessions/:id/log",
|
||||
zValidator("json", logEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId: session.id,
|
||||
action: body.action,
|
||||
pageVisited: body.pageVisited ?? null,
|
||||
metadata: body.metadata ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /sessions/:id/audit-log — Get audit trail ──────────────────────────
|
||||
|
||||
impersonationRouter.get("/sessions/:id/audit-log", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(impersonationAuditLogs)
|
||||
.where(eq(impersonationAuditLogs.sessionId, session.id))
|
||||
.orderBy(desc(impersonationAuditLogs.createdAt));
|
||||
|
||||
return c.json(logs);
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
invoices,
|
||||
invoiceLineItems,
|
||||
invoiceTipSplits,
|
||||
refunds,
|
||||
appointments,
|
||||
services,
|
||||
clients,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
|
||||
// Convert Zod validation errors from 422 to 400
|
||||
invoicesRouter.onError((err, c) => {
|
||||
if (err instanceof z.ZodError) {
|
||||
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
clientId: z.string().uuid(),
|
||||
lineItems: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().min(1).max(500),
|
||||
quantity: z.number().int().positive().default(1),
|
||||
unitPriceCents: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
taxCents: z.number().int().nonnegative().default(0),
|
||||
tipCents: z.number().int().nonnegative().default(0),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z.object({
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
paymentMethod: z.enum(["cash", "card", "check", "other"]).nullable().optional(),
|
||||
paidAt: z.string().datetime().nullable().optional(),
|
||||
taxCents: z.number().int().nonnegative().optional(),
|
||||
tipCents: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
tipSplits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).optional(),
|
||||
});
|
||||
|
||||
// List invoices
|
||||
const listInvoicesQuerySchema = z.object({
|
||||
clientId: z.string().uuid().optional(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
invoicesRouter.get(
|
||||
"/",
|
||||
zValidator("query", listInvoicesQuerySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||
stripeRefundId: invoices.stripeRefundId,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
}
|
||||
);
|
||||
|
||||
// Get single invoice with line items and tip splits
|
||||
invoicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
let cardLast4: string | null = null;
|
||||
let paymentStatus: string | null = null;
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||
if (details) {
|
||||
cardLast4 = details.cardLast4;
|
||||
paymentStatus = details.paymentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||
});
|
||||
|
||||
// Save tip splits for an invoice (replaces existing splits)
|
||||
const tipSplitSchema = z.object({
|
||||
splits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).min(1).refine(
|
||||
(splits) => {
|
||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
return totalBps === 10000;
|
||||
},
|
||||
{ message: "Split percentages must sum to 100" }
|
||||
),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/tip-splits",
|
||||
zValidator("json", tipSplitSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status === "void") return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||
|
||||
const tipCents = invoice.tipCents;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Remove existing splits
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
|
||||
// Insert new splits, distributing tipCents proportionally
|
||||
let remaining = tipCents;
|
||||
const rows = body.splits.map((s, i) => {
|
||||
const isLast = i === body.splits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
|
||||
if (rows.length > 0) {
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
});
|
||||
|
||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Create invoice (optionally pre-populated from an appointment)
|
||||
invoicesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// If appointmentId provided, verify it exists
|
||||
if (body.appointmentId) {
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, body.appointmentId));
|
||||
if (!appt) return c.json({ error: "Appointment not found" }, 404);
|
||||
}
|
||||
|
||||
const subtotalCents = body.lineItems.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitPriceCents,
|
||||
0
|
||||
);
|
||||
const totalCents = subtotalCents + body.taxCents + body.tipCents;
|
||||
|
||||
const [invoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
appointmentId: body.appointmentId ?? null,
|
||||
clientId: body.clientId,
|
||||
subtotalCents,
|
||||
taxCents: body.taxCents,
|
||||
tipCents: body.tipCents,
|
||||
totalCents,
|
||||
notes: body.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) return c.json({ error: "Failed to create invoice" }, 500);
|
||||
|
||||
const items = await db
|
||||
.insert(invoiceLineItems)
|
||||
.values(
|
||||
body.lineItems.map((item) => ({
|
||||
invoiceId: invoice.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
totalCents: item.quantity * item.unitPriceCents,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return c.json({ ...invoice, lineItems: items }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Create invoice from appointment (convenience endpoint)
|
||||
invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
||||
const db = getDb();
|
||||
const appointmentId = c.req.param("appointmentId");
|
||||
|
||||
const [appt] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
clientId: appointments.clientId,
|
||||
serviceId: appointments.serviceId,
|
||||
priceCents: appointments.priceCents,
|
||||
serviceName: services.name,
|
||||
serviceBasePriceCents: services.basePriceCents,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(eq(appointments.id, appointmentId));
|
||||
|
||||
if (!appt) return c.json({ error: "Appointment not found" }, 404);
|
||||
|
||||
// Check if invoice already exists for this appointment
|
||||
const [existing] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.appointmentId, appointmentId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "Invoice already exists for this appointment", invoiceId: existing.id },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const unitPriceCents = appt.priceCents ?? appt.serviceBasePriceCents;
|
||||
const subtotalCents = unitPriceCents;
|
||||
const totalCents = subtotalCents;
|
||||
|
||||
const [invoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
appointmentId,
|
||||
clientId: appt.clientId,
|
||||
subtotalCents,
|
||||
taxCents: 0,
|
||||
tipCents: 0,
|
||||
totalCents,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) return c.json({ error: "Failed to create invoice" }, 500);
|
||||
|
||||
const [lineItem] = await db
|
||||
.insert(invoiceLineItems)
|
||||
.values({
|
||||
invoiceId: invoice.id,
|
||||
description: appt.serviceName,
|
||||
quantity: 1,
|
||||
unitPriceCents,
|
||||
totalCents: unitPriceCents,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
||||
});
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["pending", "void"],
|
||||
pending: ["draft", "paid", "void"],
|
||||
paid: ["void"],
|
||||
void: [],
|
||||
};
|
||||
|
||||
// Update invoice
|
||||
invoicesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, id));
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (body.status !== undefined) {
|
||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||
if (!allowed.includes(body.status)) {
|
||||
return c.json(
|
||||
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tipCents = body.tipCents ?? current.tipCents;
|
||||
|
||||
// Validate tip splits when marking invoice as paid
|
||||
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||
if (body.tipSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||
}
|
||||
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||
if (Math.abs(totalPct - 100) > 0.01) {
|
||||
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||
|
||||
// Auto-set paidAt when marking as paid
|
||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||
update.paidAt = new Date();
|
||||
}
|
||||
|
||||
// Recalculate total if tax or tip changed
|
||||
const newTaxCents = body.taxCents ?? current.taxCents;
|
||||
const newTipCents = body.tipCents ?? current.tipCents;
|
||||
if (body.taxCents !== undefined || body.tipCents !== undefined) {
|
||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||
}
|
||||
|
||||
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
const splits = body.tipSplits;
|
||||
if (splits.length > 0) {
|
||||
let remaining = tipCents;
|
||||
const rows = splits.map((s, i) => {
|
||||
const isLast = i === splits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedInvoice] = await tx
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
const lineItems = await tx
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
.where(eq(invoiceLineItems.invoiceId, id));
|
||||
|
||||
return [updatedInvoice, lineItems];
|
||||
});
|
||||
|
||||
return c.json({ ...updated, lineItems });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||
|
||||
const refundSchema = z.object({
|
||||
amountCents: z.number().int().nonnegative().optional(),
|
||||
idempotencyKey: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/refund",
|
||||
zValidator("json", refundSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staff = c.get("staff");
|
||||
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
||||
if (staff.role !== "manager" && !staff.isSuperUser) {
|
||||
return c.json({ error: "Manager role required" }, 403);
|
||||
}
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
if (body.idempotencyKey) {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
||||
if (existing) {
|
||||
return c.json({ refundId: existing.stripeRefundId });
|
||||
}
|
||||
}
|
||||
|
||||
let refundId: string;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
refundId = result.refundId;
|
||||
} else {
|
||||
// Manual refund — no Stripe call needed
|
||||
refundId = `manual_${id}_${Date.now()}`;
|
||||
}
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Payment stats for admin dashboard
|
||||
invoicesRouter.get("/stats/summary", async (c) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const [revenueResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||
|
||||
const [outstandingResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.status, "pending"));
|
||||
|
||||
const [refundsResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||
.from(refunds)
|
||||
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||
|
||||
const methodBreakdown = await db
|
||||
.select({
|
||||
method: invoices.paymentMethod,
|
||||
total: sql<number>`count(*)`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||
.groupBy(invoices.paymentMethod);
|
||||
|
||||
return c.json({
|
||||
revenueThisMonth: revenueResult?.total ?? 0,
|
||||
outstanding: outstandingResult?.total ?? 0,
|
||||
refundsThisMonth: refundsResult?.total ?? 0,
|
||||
methodBreakdown,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("stats/summary error:", err);
|
||||
return c.json({
|
||||
revenueThisMonth: 0,
|
||||
outstanding: 0,
|
||||
refundsThisMonth: 0,
|
||||
methodBreakdown: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||
invoicesRouter.get("/:id/stripe-details", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
let cardLast4: string | null = null;
|
||||
let paymentStatus: string | null = null;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||
if (details) {
|
||||
cardLast4 = details.cardLast4;
|
||||
paymentStatus = details.paymentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
stripePaymentIntentId: invoice.stripePaymentIntentId,
|
||||
stripeRefundId: invoice.stripeRefundId,
|
||||
cardLast4,
|
||||
paymentStatus,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
getPresignedGetUrl,
|
||||
deleteObject,
|
||||
} from "../lib/s3.js";
|
||||
|
||||
export const petsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createPetSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
name: z.string().min(1).max(200),
|
||||
species: z.string().min(1).max(100),
|
||||
breed: z.string().max(200).optional(),
|
||||
weightKg: z.number().positive().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
healthAlerts: z.string().max(2000).optional(),
|
||||
groomingNotes: z.string().max(2000).optional(),
|
||||
cutStyle: z.string().max(500).optional(),
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).optional(),
|
||||
customFields: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||
|
||||
// List pets, optionally filtered by clientId.
|
||||
// Groomers see only pets owned by clients with ≥1 appointment for this groomer.
|
||||
petsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Groomer: filter to pets whose client has an appointment for this groomer
|
||||
const groomerClientFilter = isGroomer
|
||||
? exists(
|
||||
db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, pets.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(pets.clientId, clientId));
|
||||
if (groomerClientFilter) conditions.push(groomerClientFilter);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
petsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.id, petId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if no appointment linkage to this pet's client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, row.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
customFields: customFields ?? {},
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
petsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updatePetSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
...(customFields !== undefined ? { customFields } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
petsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(pets)
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Photo routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
const uploadUrlSchema = z.object({
|
||||
contentType: z.string().refine((v) => ALLOWED_CONTENT_TYPES.has(v), {
|
||||
message: "contentType must be one of: image/jpeg, image/png, image/webp, image/gif",
|
||||
}),
|
||||
fileSizeBytes: z.number().int().positive().max(MAX_PHOTO_SIZE, {
|
||||
message: "File must not exceed 5 MB",
|
||||
}),
|
||||
});
|
||||
|
||||
const confirmSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:petId/photo/upload-url
|
||||
* Returns a presigned S3 PUT URL and the object key for the upload.
|
||||
* All staff roles (manager, receptionist, groomer) may call this.
|
||||
*/
|
||||
petsRouter.post(
|
||||
"/:petId/photo/upload-url",
|
||||
zValidator("json", uploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
const { contentType, fileSizeBytes } = c.req.valid("json");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
const ext = contentType.split("/")[1] ?? "jpg";
|
||||
const key = `pets/${petId}/${Date.now()}.${ext}`;
|
||||
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
|
||||
|
||||
return c.json({ uploadUrl, key });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /:petId/photo/confirm
|
||||
* Called after the client has successfully uploaded to the presigned URL.
|
||||
* Records the object key in the DB.
|
||||
*/
|
||||
petsRouter.post(
|
||||
"/:petId/photo/confirm",
|
||||
zValidator("json", confirmSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
const { key } = c.req.valid("json");
|
||||
|
||||
// Validate that the key belongs to this pet to prevent key hijacking
|
||||
if (!key.startsWith(`pets/${petId}/`)) {
|
||||
return c.json({ error: "Invalid key" }, 400);
|
||||
}
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
// Delete the previous photo from storage to avoid orphaned objects
|
||||
if (pet.photoKey) {
|
||||
try {
|
||||
await deleteObject(pet.photoKey);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(pets.id, petId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
return c.json({ ok: true, photoKey: row.photoKey });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /:petId/photo
|
||||
* Removes the photo from object storage and clears the DB record.
|
||||
* All staff roles (manager, receptionist, groomer) may call this.
|
||||
*/
|
||||
petsRouter.delete("/:petId/photo", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||
|
||||
try {
|
||||
await deleteObject(pet.photoKey);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||
}
|
||||
await db
|
||||
.update(pets)
|
||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||
.where(eq(pets.id, petId));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:petId/photo
|
||||
* Returns a presigned GET URL for the pet's photo.
|
||||
* All authenticated staff may access (read).
|
||||
*/
|
||||
petsRouter.get("/:petId/photo", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||
|
||||
const url = await getPresignedGetUrl(pet.photoKey);
|
||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
|
||||
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||
const devSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/dev-session",
|
||||
zValidator("json", devSessionSchema),
|
||||
async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId))
|
||||
.limit(1);
|
||||
if (!client) {
|
||||
return c.json({ error: "Client not found" }, 404);
|
||||
}
|
||||
|
||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let staffId = DEMO_STAFF_ID;
|
||||
const [demoStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||
.limit(1);
|
||||
|
||||
if (!demoStaff) {
|
||||
const [firstStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.limit(1);
|
||||
if (!firstStaff) {
|
||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||
}
|
||||
staffId = firstStaff.id;
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId,
|
||||
clientId: body.clientId,
|
||||
reason: "dev-mode-client-portal",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(session, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/config", async (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const allAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
confirmationStatus: appointments.confirmationStatus,
|
||||
customerNotes: appointments.customerNotes,
|
||||
notes: appointments.notes,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||
|
||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||
|
||||
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||
|
||||
const appts = allAppts.map(a => ({
|
||||
id: a.id,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
status: a.status,
|
||||
confirmationStatus: a.confirmationStatus,
|
||||
customerNotes: a.customerNotes,
|
||||
notes: a.notes,
|
||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||
service: a.serviceId ? { id: a.serviceId } : null,
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
return c.json({ appointments: appts });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
|
||||
|
||||
const itemsByInvoice: Record<string, typeof lineItems> = {};
|
||||
for (const li of lineItems) {
|
||||
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
|
||||
itemsByInvoice[li.invoiceId]!.push(li);
|
||||
}
|
||||
|
||||
return c.json(clientInvoices.map(inv => ({
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
date: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
|
||||
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||
|
||||
const customerNotesSchema = z.object({
|
||||
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
||||
customerNotes: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
portalRouter.patch(
|
||||
"/appointments/:id/notes",
|
||||
zValidator("json", customerNotesSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ customerNotes: body.customerNotes, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
customerNotes: updated.customerNotes,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Appointment confirm/cancel ──────────────────────────────────────────────
|
||||
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus !== "pending") {
|
||||
return c.json({ error: "Appointment is not pending confirmation" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated!.id,
|
||||
confirmationStatus: updated!.confirmationStatus,
|
||||
confirmedAt: updated!.confirmedAt,
|
||||
updatedAt: updated!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Appointment is already cancelled or completed" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated!.id,
|
||||
status: updated!.status,
|
||||
confirmationStatus: updated!.confirmationStatus,
|
||||
cancelledAt: updated!.cancelledAt,
|
||||
updatedAt: updated!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
preferredDate: z.string(),
|
||||
preferredTime: z.string(),
|
||||
});
|
||||
|
||||
const updateWaitlistEntrySchema = z.object({
|
||||
status: z.literal("cancelled").optional(),
|
||||
preferredDate: z.string().optional(),
|
||||
preferredTime: z.string().optional(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/waitlist",
|
||||
zValidator("json", createWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
.values({
|
||||
clientId,
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: body.preferredTime,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.patch(
|
||||
"/waitlist/:id",
|
||||
zValidator("json", updateWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
||||
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
|
||||
|
||||
const [updated] = await db
|
||||
.update(waitlistEntries)
|
||||
.set(updateData)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
getStripeClient,
|
||||
} from "../services/payment.js";
|
||||
|
||||
const payMultipleSchema = z.object({
|
||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/pay-multiple",
|
||||
zValidator("json", payMultipleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, body.invoiceIds));
|
||||
|
||||
if (invoiceRows.length !== body.invoiceIds.length) {
|
||||
return c.json({ error: "One or more invoices not found" }, 404);
|
||||
}
|
||||
|
||||
for (const inv of invoiceRows) {
|
||||
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (inv.status === "draft" || inv.status === "void") {
|
||||
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
||||
}
|
||||
if (inv.status === "paid") {
|
||||
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
||||
}
|
||||
}
|
||||
|
||||
const firstInvoice = invoiceRows[0];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||
if (!allSameClient) {
|
||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
return c.json(methods);
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
||||
|
||||
const result = await createSetupIntent(customerId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
||||
return c.json({ error: "Payment method not found" }, 404);
|
||||
}
|
||||
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
lt,
|
||||
sql,
|
||||
getDb,
|
||||
appointments,
|
||||
clients,
|
||||
invoices,
|
||||
invoiceTipSplits,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
|
||||
export const reportsRouter = new Hono();
|
||||
|
||||
reportsRouter.onError((err, c) => {
|
||||
console.error("[reports] unhandled error:", err);
|
||||
return c.json({ error: "Internal server error", message: err.message }, 500);
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseDate(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? fallback : d;
|
||||
}
|
||||
|
||||
function defaultFrom(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - 30);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function defaultTo(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
// GET /api/reports/summary?from=&to=
|
||||
// High-level KPIs for a date range
|
||||
|
||||
reportsRouter.get("/summary", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
const [revenueRow] = await db
|
||||
.select({
|
||||
totalRevenueCents: sql<number>`COALESCE(SUM(${invoices.totalCents}), 0)::int`,
|
||||
paidCount: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
);
|
||||
|
||||
const [apptRow] = await db
|
||||
.select({
|
||||
total: sql<number>`COUNT(*)::int`,
|
||||
completed: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
cancelled: sql<number>`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`,
|
||||
noShow: sql<number>`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
);
|
||||
|
||||
const [clientRow] = await db
|
||||
.select({
|
||||
totalClients: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(clients);
|
||||
|
||||
// New clients in the period
|
||||
const [newClientRow] = await db
|
||||
.select({
|
||||
newClients: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
gte(clients.createdAt, from),
|
||||
lt(clients.createdAt, to)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
revenue: {
|
||||
totalCents: revenueRow?.totalRevenueCents ?? 0,
|
||||
paidInvoices: revenueRow?.paidCount ?? 0,
|
||||
},
|
||||
appointments: {
|
||||
total: apptRow?.total ?? 0,
|
||||
completed: apptRow?.completed ?? 0,
|
||||
cancelled: apptRow?.cancelled ?? 0,
|
||||
noShow: apptRow?.noShow ?? 0,
|
||||
},
|
||||
clients: {
|
||||
total: clientRow?.totalClients ?? 0,
|
||||
new: newClientRow?.newClients ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Revenue by period ────────────────────────────────────────────────────────
|
||||
// GET /api/reports/revenue?from=&to=&groupBy=day|week|month
|
||||
|
||||
reportsRouter.get("/revenue", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
const groupBy = c.req.query("groupBy") ?? "day";
|
||||
|
||||
const truncUnit =
|
||||
groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day";
|
||||
|
||||
const byPeriod = await db
|
||||
.select({
|
||||
period: sql<string>`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})::text`,
|
||||
totalCents: sql<number>`SUM(${invoices.totalCents})::int`,
|
||||
invoiceCount: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})`
|
||||
)
|
||||
.orderBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})`
|
||||
);
|
||||
|
||||
// Revenue by groomer (via appointment -> staff join)
|
||||
const byGroomer = await db
|
||||
.select({
|
||||
staffId: staff.id,
|
||||
staffName: staff.name,
|
||||
totalCents: sql<number>`SUM(${invoices.totalCents})::int`,
|
||||
invoiceCount: sql<number>`COUNT(${invoices.id})::int`,
|
||||
})
|
||||
.from(invoices)
|
||||
.innerJoin(appointments, eq(invoices.appointmentId, appointments.id))
|
||||
.innerJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.groupBy(staff.id, staff.name)
|
||||
.orderBy(sql`SUM(${invoices.totalCents}) DESC`);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod, byGroomer });
|
||||
});
|
||||
|
||||
// ─── Appointment analytics ────────────────────────────────────────────────────
|
||||
// GET /api/reports/appointments?from=&to=&groupBy=day|week|month
|
||||
|
||||
reportsRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
const groupBy = c.req.query("groupBy") ?? "day";
|
||||
|
||||
const truncUnit =
|
||||
groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day";
|
||||
|
||||
const byPeriod = await db
|
||||
.select({
|
||||
period: sql<string>`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})::text`,
|
||||
total: sql<number>`COUNT(*)::int`,
|
||||
completed: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
cancelled: sql<number>`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`,
|
||||
noShow: sql<number>`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})`
|
||||
)
|
||||
.orderBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})`
|
||||
);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod });
|
||||
});
|
||||
|
||||
// ─── Service popularity ───────────────────────────────────────────────────────
|
||||
// GET /api/reports/services?from=&to=
|
||||
|
||||
reportsRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
serviceId: services.id,
|
||||
serviceName: services.name,
|
||||
appointmentCount: sql<number>`COUNT(${appointments.id})::int`,
|
||||
completedCount: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
revenueCents: sql<number>`COALESCE(SUM(CASE WHEN ${invoices.status} = 'paid' THEN ${invoices.totalCents} ELSE 0 END), 0)::int`,
|
||||
})
|
||||
.from(services)
|
||||
.leftJoin(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.serviceId, services.id),
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.leftJoin(invoices, eq(invoices.appointmentId, appointments.id))
|
||||
.groupBy(services.id, services.name)
|
||||
.orderBy(sql`COUNT(${appointments.id}) DESC`);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), rows });
|
||||
});
|
||||
|
||||
// ─── Client retention ─────────────────────────────────────────────────────────
|
||||
// GET /api/reports/clients?from=&to=
|
||||
// Returns: new clients, returning clients, clients with no recent activity (churn risk)
|
||||
|
||||
reportsRouter.get("/clients", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
// New clients in period
|
||||
const newClients = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
clientName: clients.name,
|
||||
createdAt: clients.createdAt,
|
||||
})
|
||||
.from(clients)
|
||||
.where(and(gte(clients.createdAt, from), lt(clients.createdAt, to)))
|
||||
.orderBy(clients.createdAt);
|
||||
|
||||
// Active clients in period (had at least 1 appointment)
|
||||
const activeInPeriod = await db
|
||||
.select({
|
||||
clientId: appointments.clientId,
|
||||
appointmentCount: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to),
|
||||
eq(appointments.status, "completed")
|
||||
)
|
||||
)
|
||||
.groupBy(appointments.clientId);
|
||||
|
||||
// Clients with no appointment in last 90 days (churn risk)
|
||||
const ninetyDaysAgo = new Date();
|
||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||
|
||||
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const churnRisk = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
clientName: clients.name,
|
||||
lastAppointmentAt: sql<string | null>`MAX(${appointments.startTime})::text`,
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id, clients.name)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [churnCountRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(
|
||||
db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.as("churn_count")
|
||||
);
|
||||
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
newClients,
|
||||
activeInPeriodCount: activeInPeriod.length,
|
||||
churnRisk,
|
||||
churnRiskTotal,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tip splits payroll report ────────────────────────────────────────────────
|
||||
// GET /api/reports/tip-splits?from=&to=
|
||||
// Aggregates tip earnings per staff member for the period
|
||||
|
||||
reportsRouter.get("/tip-splits", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
staffId: invoiceTipSplits.staffId,
|
||||
staffName: invoiceTipSplits.staffName,
|
||||
totalTipCents: sql<number>`SUM(${invoiceTipSplits.shareCents})::int`,
|
||||
invoiceCount: sql<number>`COUNT(DISTINCT ${invoiceTipSplits.invoiceId})::int`,
|
||||
})
|
||||
.from(invoiceTipSplits)
|
||||
.innerJoin(invoices, eq(invoiceTipSplits.invoiceId, invoices.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.groupBy(invoiceTipSplits.staffId, invoiceTipSplits.staffName)
|
||||
.orderBy(sql`SUM(${invoiceTipSplits.shareCents}) DESC`);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), rows });
|
||||
});
|
||||
|
||||
// ─── CSV export ───────────────────────────────────────────────────────────────
|
||||
// GET /api/reports/export.csv?type=revenue|appointments|services&from=&to=
|
||||
|
||||
reportsRouter.get("/export.csv", async (c) => {
|
||||
const db = getDb();
|
||||
const type = c.req.query("type") ?? "revenue";
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
let csv = "";
|
||||
|
||||
if (type === "revenue") {
|
||||
const rows = await db
|
||||
.select({
|
||||
paidAt: invoices.paidAt,
|
||||
clientId: invoices.clientId,
|
||||
totalCents: invoices.totalCents,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(appointments, eq(invoices.appointmentId, appointments.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.orderBy(invoices.paidAt);
|
||||
|
||||
csv = "Date,Groomer,Total,Subtotal,Tax,Tip,Payment Method\n";
|
||||
csv += rows
|
||||
.map((r) =>
|
||||
[
|
||||
r.paidAt ? new Date(r.paidAt).toLocaleDateString() : "",
|
||||
r.staffName ?? "",
|
||||
(r.totalCents / 100).toFixed(2),
|
||||
(r.subtotalCents / 100).toFixed(2),
|
||||
(r.taxCents / 100).toFixed(2),
|
||||
(r.tipCents / 100).toFixed(2),
|
||||
r.paymentMethod ?? "",
|
||||
].join(",")
|
||||
)
|
||||
.join("\n");
|
||||
} else if (type === "appointments") {
|
||||
const rows = await db
|
||||
.select({
|
||||
startTime: appointments.startTime,
|
||||
status: appointments.status,
|
||||
clientId: appointments.clientId,
|
||||
clientName: clients.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.leftJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
csv = "Date,Client,Service,Groomer,Status\n";
|
||||
csv += rows
|
||||
.map((r) =>
|
||||
[
|
||||
new Date(r.startTime).toLocaleDateString(),
|
||||
`"${(r.clientName ?? "").replace(/"/g, '""')}"`,
|
||||
`"${(r.serviceName ?? "").replace(/"/g, '""')}"`,
|
||||
r.staffName ?? "",
|
||||
r.status,
|
||||
].join(",")
|
||||
)
|
||||
.join("\n");
|
||||
} else if (type === "services") {
|
||||
const rows = await db
|
||||
.select({
|
||||
serviceName: services.name,
|
||||
appointmentCount: sql<number>`COUNT(${appointments.id})::int`,
|
||||
completedCount: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
})
|
||||
.from(services)
|
||||
.leftJoin(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.serviceId, services.id),
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.groupBy(services.id, services.name)
|
||||
.orderBy(sql`COUNT(${appointments.id}) DESC`);
|
||||
|
||||
csv = "Service,Total Appointments,Completed\n";
|
||||
csv += rows
|
||||
.map((r) =>
|
||||
[
|
||||
`"${r.serviceName.replace(/"/g, '""')}"`,
|
||||
r.appointmentCount,
|
||||
r.completedCount,
|
||||
].join(",")
|
||||
)
|
||||
.join("\n");
|
||||
} else {
|
||||
return c.json({ error: "Invalid type. Use revenue, appointments, or services." }, 400);
|
||||
}
|
||||
|
||||
const filename = `groombook-${type}-report.csv`;
|
||||
c.header("Content-Type", "text/csv");
|
||||
c.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
return c.text(csv);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Hono } from "hono";
|
||||
import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db";
|
||||
|
||||
export const searchRouter = new Hono();
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */
|
||||
function escapeLike(s: string): string {
|
||||
return `%${s.replace(/[%_\\]/g, "\\$&")}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search?q={query}
|
||||
*
|
||||
* Returns up to 10 matching active clients and up to 10 matching pets.
|
||||
* Clients are matched on name, email, or phone.
|
||||
* Pets are matched on name or breed; includes owner name.
|
||||
*/
|
||||
searchRouter.get("/", async (c) => {
|
||||
const q = c.req.query("q");
|
||||
if (!q || q.trim().length === 0) {
|
||||
return c.json({ error: "Query parameter q is required" }, 400);
|
||||
}
|
||||
|
||||
const pattern = escapeLike(q.trim());
|
||||
const db = getDb();
|
||||
|
||||
const [matchingClients, matchingPets] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
phone: clients.phone,
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.status, "active"),
|
||||
or(
|
||||
ilike(clients.name, pattern),
|
||||
ilike(clients.email, pattern),
|
||||
ilike(clients.phone, pattern)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(LIMIT),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: pets.id,
|
||||
name: pets.name,
|
||||
breed: pets.breed,
|
||||
clientId: pets.clientId,
|
||||
ownerName: clients.name,
|
||||
})
|
||||
.from(pets)
|
||||
.innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active")))
|
||||
.where(
|
||||
or(
|
||||
ilike(pets.name, pattern),
|
||||
ilike(pets.breed, pattern)
|
||||
)
|
||||
)
|
||||
.limit(LIMIT),
|
||||
]);
|
||||
|
||||
return c.json({ clients: matchingClients, pets: matchingPets });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, services } from "@groombook/db";
|
||||
|
||||
export const servicesRouter = new Hono();
|
||||
|
||||
const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
basePriceCents: z.number().int().positive(),
|
||||
durationMinutes: z.number().int().positive().max(480),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const updateServiceSchema = createServiceSchema.partial();
|
||||
|
||||
servicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
const query = db.select().from(services).orderBy(services.name);
|
||||
const rows = includeInactive
|
||||
? await query
|
||||
: await query.where(eq(services.active, true));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
servicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
servicesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createServiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(services).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
servicesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateServiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(services)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(services.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
servicesRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(services)
|
||||
.where(eq(services.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
|
||||
// GET /api/admin/settings — return current business settings
|
||||
settingsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) {
|
||||
// Auto-create default settings if none exist
|
||||
const [created] = await db.insert(businessSettings).values({}).returning();
|
||||
return c.json(created);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const updateSettingsSchema = z.object({
|
||||
businessName: z.string().min(1).max(200).optional(),
|
||||
primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(),
|
||||
accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(),
|
||||
});
|
||||
|
||||
// PATCH /api/admin/settings — update business settings
|
||||
settingsRouter.patch(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
zValidator("json", updateSettingsSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Get or create the settings row
|
||||
const rows = await db.select().from(businessSettings).limit(1);
|
||||
let settingsId: string;
|
||||
if (rows[0]) {
|
||||
settingsId = rows[0].id;
|
||||
} else {
|
||||
const [inserted] = await db.insert(businessSettings).values({}).returning();
|
||||
if (!inserted) throw new Error("Failed to create default settings");
|
||||
settingsId = inserted.id;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(businessSettings)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, settingsId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Logo routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]);
|
||||
const MAX_LOGO_SIZE = 512 * 1024; // 512 KB
|
||||
|
||||
const logoUploadUrlSchema = z.object({
|
||||
contentType: z.string().refine((v) => ALLOWED_LOGO_TYPES.has(v), {
|
||||
message: "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||
}),
|
||||
fileSizeBytes: z.number().int().positive().max(MAX_LOGO_SIZE, {
|
||||
message: "File must not exceed 512 KB",
|
||||
}),
|
||||
});
|
||||
|
||||
const logoConfirmSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/logo/upload-url
|
||||
* Returns a presigned S3 PUT URL and the object key for logo upload.
|
||||
*/
|
||||
settingsRouter.post(
|
||||
"/logo/upload-url",
|
||||
zValidator("json", logoUploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { contentType, fileSizeBytes } = c.req.valid("json");
|
||||
|
||||
const rows = await db.select().from(businessSettings).limit(1);
|
||||
if (!rows[0]) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
const settingsId = rows[0].id;
|
||||
|
||||
const ext = contentType.split("/")[1] ?? "png";
|
||||
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
|
||||
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
|
||||
|
||||
return c.json({ uploadUrl, key });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/logo/upload
|
||||
* Proxy upload through the API server to avoid mixed-content issues with
|
||||
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
|
||||
* directly to S3 from the server using the internal endpoint.
|
||||
*/
|
||||
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Parse multipart form data (file field)
|
||||
const body = await c.req.parseBody({ all: true });
|
||||
const file = body["file"];
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return c.json({ error: "No file provided" }, 400);
|
||||
}
|
||||
|
||||
const contentType = file.type;
|
||||
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const fileSizeBytes = file.size;
|
||||
if (fileSizeBytes > MAX_LOGO_SIZE) {
|
||||
return c.json({ error: "File must not exceed 512 KB" }, 400);
|
||||
}
|
||||
|
||||
const rows = await db.select().from(businessSettings).limit(1);
|
||||
if (!rows[0]) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
const settingsId = rows[0].id;
|
||||
|
||||
const ext = contentType.split("/")[1] ?? "png";
|
||||
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
|
||||
|
||||
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await putObject(key, buffer, contentType, fileSizeBytes);
|
||||
|
||||
// Delete previous S3 object if any
|
||||
if (rows[0].logoKey) {
|
||||
await deleteObject(rows[0].logoKey);
|
||||
}
|
||||
|
||||
// Update database with new logo key
|
||||
const [updated] = await db
|
||||
.update(businessSettings)
|
||||
.set({
|
||||
logoKey: key,
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(businessSettings.id, settingsId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, logoKey: updated.logoKey });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/logo/confirm
|
||||
* Called after the client has successfully uploaded to the presigned URL.
|
||||
* Records the object key in the DB and clears legacy base64 fields.
|
||||
*/
|
||||
settingsRouter.post(
|
||||
"/logo/confirm",
|
||||
zValidator("json", logoConfirmSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { key } = c.req.valid("json");
|
||||
|
||||
const rows = await db.select().from(businessSettings).limit(1);
|
||||
if (!rows[0]) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
const settingsId = rows[0].id;
|
||||
|
||||
// Validate key prefix
|
||||
if (!key.startsWith(`logos/${settingsId}/`)) {
|
||||
return c.json({ error: "Invalid key" }, 400);
|
||||
}
|
||||
|
||||
// Delete previous S3 object if any
|
||||
if (rows[0].logoKey) {
|
||||
await deleteObject(rows[0].logoKey);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(businessSettings)
|
||||
.set({ logoKey: key, logoBase64: null, logoMimeType: null, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, settingsId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, logoKey: updated.logoKey });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/logo
|
||||
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
||||
* Returns the image bytes with proper Content-Type.
|
||||
*/
|
||||
settingsRouter.get("/logo", async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||
|
||||
const { body, contentType } = await getObject(row.logoKey);
|
||||
return new Response(Buffer.from(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/settings/logo
|
||||
* Removes the logo from S3 and clears the DB record.
|
||||
*/
|
||||
settingsRouter.delete("/logo", async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||
|
||||
await deleteObject(row.logoKey);
|
||||
await db
|
||||
.update(businessSettings)
|
||||
.set({ logoKey: null, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, row.id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
const RATE_LIMIT_MAX = 10;
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
||||
const entry = rateLimitMap.get(ip);
|
||||
const now = Date.now();
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
||||
}
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count };
|
||||
}
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
// and whether the auth provider bootstrap step should be shown
|
||||
setupRouter.get("/status", async (c) => {
|
||||
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
|
||||
if (skipOobe) {
|
||||
return c.json({
|
||||
needsSetup: false,
|
||||
showAuthProviderStep: false,
|
||||
authConfigExists: false,
|
||||
authEnvVarsSet: false,
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if any super user exists
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
// Check if DB already has an auth provider config
|
||||
const [dbAuthConfig] = await db
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
// Check if OIDC env vars are set (bootstrap mode)
|
||||
const oidcIssuer = process.env.OIDC_ISSUER;
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID;
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const authEnvVarsSet = !!(oidcIssuer && oidcClientId && oidcClientSecret);
|
||||
|
||||
return c.json({
|
||||
needsSetup: !superUser,
|
||||
// Show auth provider bootstrap step when: fresh install (no super user) AND no DB config AND no env vars
|
||||
showAuthProviderStep: !superUser && !dbAuthConfig && !authEnvVarsSet,
|
||||
authConfigExists: !!dbAuthConfig,
|
||||
authEnvVarsSet,
|
||||
});
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
businessName: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
// POST /api/setup — authenticated (Better-Auth JWT), creates staff record if needed and sets business name
|
||||
// This endpoint is exempt from resolveStaffMiddleware so that OOBE users (with no staff record yet) can complete setup
|
||||
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const jwt = c.get("jwtPayload");
|
||||
const currentStaff = c.get("staff"); // may be undefined during OOBE
|
||||
|
||||
// Use a transaction with row-level locking to prevent race conditions
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Lock super user rows to prevent concurrent claims
|
||||
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
|
||||
const [existingSuperUser] = await tx
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
|
||||
if (existingSuperUser) {
|
||||
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
|
||||
}
|
||||
|
||||
// Lock the business_settings row for update to prevent concurrent setup
|
||||
const [existingSettings] = await tx
|
||||
.select({ id: businessSettings.id })
|
||||
.from(businessSettings)
|
||||
.limit(1);
|
||||
|
||||
// Update or create business settings with the business name
|
||||
if (existingSettings) {
|
||||
await tx
|
||||
.update(businessSettings)
|
||||
.set({ businessName: body.businessName, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, existingSettings.id));
|
||||
} else {
|
||||
await tx.insert(businessSettings).values({ businessName: body.businessName });
|
||||
}
|
||||
|
||||
// Find or create staff record for the authenticated user
|
||||
let resolvedStaff = currentStaff;
|
||||
|
||||
if (!resolvedStaff) {
|
||||
// Try to find by userId
|
||||
const [byUserId] = await tx
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, jwt.sub));
|
||||
if (byUserId) {
|
||||
resolvedStaff = byUserId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedStaff && jwt.email) {
|
||||
// Try auto-link by email: staff record exists with matching email but no userId
|
||||
const [byEmail] = await tx
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||
if (byEmail) {
|
||||
await tx
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub })
|
||||
.where(eq(staff.id, byEmail.id));
|
||||
resolvedStaff = { ...byEmail, userId: jwt.sub };
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedStaff) {
|
||||
// Brand new user during OOBE — create staff record
|
||||
if (!jwt.email) {
|
||||
return { error: "Cannot complete setup: authenticated user has no email claim", code: 400 };
|
||||
}
|
||||
const [newStaff] = await tx
|
||||
.insert(staff)
|
||||
.values({
|
||||
name: jwt.name || jwt.email,
|
||||
email: jwt.email,
|
||||
userId: jwt.sub,
|
||||
role: "manager",
|
||||
isSuperUser: false, // will be set below
|
||||
})
|
||||
.returning();
|
||||
resolvedStaff = newStaff!;
|
||||
}
|
||||
|
||||
// Mark as super user
|
||||
const [updatedStaff] = await tx
|
||||
.update(staff)
|
||||
.set({ isSuperUser: true, updatedAt: new Date() })
|
||||
.where(eq(staff.id, resolvedStaff.id))
|
||||
.returning();
|
||||
|
||||
return { staff: updatedStaff };
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
const status = (result as { code?: number }).code || 409;
|
||||
return c.json({ error: result.error }, status as any);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, staff: result.staff }, 201);
|
||||
});
|
||||
|
||||
// ─── Auth Provider Bootstrap ──────────────────────────────────────────────────
|
||||
|
||||
const authProviderBootstrapSchema = z.object({
|
||||
providerId: z.string().min(1).max(100),
|
||||
displayName: z.string().min(1).max(200),
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.string().default("openid profile email"),
|
||||
});
|
||||
|
||||
// Minimal schema for test endpoint — OIDC discovery only needs issuer/internal URLs
|
||||
const authProviderTestSchema = z.object({
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/auth-provider
|
||||
* Unauthenticated endpoint for first-time auth provider setup during OOBE.
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
* Rate-limited by the API gateway; additionally restricted to first-time setup only.
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
let row: typeof authProviderConfig.$inferSelect;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [superUser] = await tx
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
||||
}
|
||||
|
||||
const [existingConfig] = await tx
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig) {
|
||||
throw Object.assign(new Error("config-exists"), { code: 409 });
|
||||
}
|
||||
|
||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
|
||||
const [configRow] = await tx
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!configRow) {
|
||||
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
||||
}
|
||||
|
||||
return configRow;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { code?: number };
|
||||
if (e.message === "setup-complete") {
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
||||
}
|
||||
if (e.message === "config-exists") {
|
||||
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
||||
}
|
||||
if (e.message === "insert-failed") {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/auth-provider/test
|
||||
* Unauthenticated endpoint to validate an OIDC provider configuration during OOBE.
|
||||
* Fetches the OIDC discovery document to confirm the issuer is reachable.
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
*/
|
||||
setupRouter.post("/auth-provider/test", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
return c.json({ ok: false, error: "Setup has already been completed." }, 403);
|
||||
}
|
||||
|
||||
const body = authProviderTestSchema.parse(await c.req.json());
|
||||
|
||||
// Determine the discovery URL
|
||||
const discoveryUrl = body.internalBaseUrl
|
||||
? `${body.internalBaseUrl.replace(/\/$/, "")}/application/o/.well-known/openid-configuration`
|
||||
: `${body.issuerUrl}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { method: "GET", signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: `OIDC discovery failed (HTTP ${res.status}). Check your Issuer URL and Internal Base URL.`,
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
} catch {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: "Could not reach the OIDC provider. Check your Issuer URL and network connectivity.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const staffRouter = new Hono<AppEnv>();
|
||||
|
||||
const createStaffSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["groomer", "receptionist", "manager"]).default("groomer"),
|
||||
oidcSub: z.string().optional(),
|
||||
active: z.boolean().default(true),
|
||||
isSuperUser: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||
|
||||
const linkUserSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
staffRouter.get("/me", async (c) => {
|
||||
const staffRow = c.get("staff");
|
||||
return c.json(staffRow);
|
||||
});
|
||||
|
||||
staffRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
const rows = includeInactive
|
||||
? await db.select().from(staff).orderBy(staff.name)
|
||||
: await db.select().from(staff).where(eq(staff.active, true)).orderBy(staff.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
staffRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.post("/", zValidator("json", createStaffSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(staff).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const currentStaff = c.get("staff");
|
||||
const targetId = c.req.param("id");
|
||||
|
||||
// Super user check: only super users can change isSuperUser
|
||||
if (body.isSuperUser !== undefined && !currentStaff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: only super users can grant or revoke super user status" }, 403);
|
||||
}
|
||||
|
||||
// If revoking super user status, check last-super-user guardrail
|
||||
if (body.isSuperUser === false) {
|
||||
const superUserCount = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.isSuperUser, true), eq(staff.active, true)))
|
||||
.limit(2); // just need count; fetch 2 to know if > 1
|
||||
if (superUserCount.length <= 1) {
|
||||
return c.json(
|
||||
{ error: "Cannot revoke the last super user. Assign another super user first." },
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If deactivating a super user, check last-super-user guardrail
|
||||
if (body.active === false) {
|
||||
const [target] = await db
|
||||
.select({ isSuperUser: staff.isSuperUser })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, targetId))
|
||||
.limit(1);
|
||||
if (target?.isSuperUser) {
|
||||
const superUserCount = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.isSuperUser, true), eq(staff.active, true)))
|
||||
.limit(2);
|
||||
if (superUserCount.length <= 1) {
|
||||
return c.json(
|
||||
{ error: "Cannot deactivate the last super user. Assign another super user first." },
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(staff)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(staff.id, targetId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const targetId = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const currentStaff = c.get("staff");
|
||||
|
||||
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, targetId))
|
||||
.limit(1);
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const [updated] = await db
|
||||
.update(staff)
|
||||
.set({ userId: body.userId, updatedAt: new Date() })
|
||||
.where(eq(staff.id, targetId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
staffRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
// Prevent deleting staff who have existing non-cancelled appointments (fixes #21).
|
||||
const activeAppointments = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, id),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (activeAppointments.length > 0) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Cannot delete staff member with existing appointments. Reassign or cancel their appointments first.",
|
||||
},
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent deleting the last super user
|
||||
const [target] = await db
|
||||
.select({ isSuperUser: staff.isSuperUser })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.limit(1);
|
||||
if (target?.isSuperUser) {
|
||||
const superUserCount = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.isSuperUser, true), eq(staff.active, true)))
|
||||
.limit(2);
|
||||
if (superUserCount.length <= 1) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete the last super user. Assign another super user first." },
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
staffRouter.post("/:id/ical-token", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
if (staffRow.role !== "manager" && staffRow.id !== id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!member) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const [updated] = await db
|
||||
.update(staff)
|
||||
.set({ icalToken: token, updatedAt: new Date() })
|
||||
.where(eq(staff.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ icalToken: updated.icalToken });
|
||||
});
|
||||
|
||||
staffRouter.delete("/:id/ical-token", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
if (staffRow.role !== "manager" && staffRow.id !== id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!member) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ icalToken: null, updatedAt: new Date() })
|
||||
.where(eq(staff.id, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
webhooksRouter.post("/stripe", async (c) => {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||
}
|
||||
|
||||
const signature = c.req.header("stripe-signature");
|
||||
if (!signature) {
|
||||
return c.json({ error: "Missing signature" }, 401);
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await c.req.text();
|
||||
} catch {
|
||||
return c.json({ error: "Could not read body" }, 400);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) {
|
||||
return c.json({ error: "Stripe not configured" }, 503);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||
return c.json({ error: message }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (event.type === "payment_intent.succeeded") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "paid",
|
||||
paymentMethod: "card",
|
||||
paidAt: new Date(),
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
const charge = event.data.object as Stripe.Charge;
|
||||
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
||||
const [inv] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
||||
.limit(1);
|
||||
if (inv) {
|
||||
const refundId =
|
||||
typeof charge.refunded === "boolean" && charge.refunded
|
||||
? `ch_${charge.id}_refund`
|
||||
: null;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "void",
|
||||
stripeRefundId: refundId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, inv.id));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.dispute.created") {
|
||||
const dispute = event.data.object as Stripe.Dispute;
|
||||
console.error(
|
||||
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
lt,
|
||||
getDb,
|
||||
waitlistEntries,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const waitlistRouter = new Hono<AppEnv>();
|
||||
|
||||
async function markExpiredEntries(db: ReturnType<typeof getDb>, rows: { status: string; preferredDate: string }[]) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const hasExpired = rows.some((r) => r.status === "active" && r.preferredDate < today);
|
||||
if (hasExpired) {
|
||||
await db
|
||||
.update(waitlistEntries)
|
||||
.set({ status: "expired", updatedAt: new Date() })
|
||||
.where(and(eq(waitlistEntries.status, "active"), lt(waitlistEntries.preferredDate, today)));
|
||||
}
|
||||
}
|
||||
|
||||
waitlistRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const date = c.req.query("date");
|
||||
|
||||
const conditions = [];
|
||||
if (date) {
|
||||
conditions.push(eq(waitlistEntries.preferredDate, date));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: waitlistEntries.id,
|
||||
clientId: waitlistEntries.clientId,
|
||||
petId: waitlistEntries.petId,
|
||||
serviceId: waitlistEntries.serviceId,
|
||||
preferredDate: waitlistEntries.preferredDate,
|
||||
preferredTime: waitlistEntries.preferredTime,
|
||||
status: waitlistEntries.status,
|
||||
notifiedAt: waitlistEntries.notifiedAt,
|
||||
expiresAt: waitlistEntries.expiresAt,
|
||||
createdAt: waitlistEntries.createdAt,
|
||||
updatedAt: waitlistEntries.updatedAt,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(waitlistEntries)
|
||||
.leftJoin(clients, eq(waitlistEntries.clientId, clients.id))
|
||||
.leftJoin(pets, eq(waitlistEntries.petId, pets.id))
|
||||
.leftJoin(services, eq(waitlistEntries.serviceId, services.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(waitlistEntries.createdAt);
|
||||
|
||||
await markExpiredEntries(db, rows);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const enriched = rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status === "active" && row.preferredDate < today ? "expired" : row.status,
|
||||
}));
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
waitlistRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, c.req.param("id")))
|
||||
.limit(1);
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
await markExpiredEntries(db, [row]);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const isExpired = row.status === "active" && row.preferredDate < today;
|
||||
return c.json({
|
||||
...row,
|
||||
status: isExpired ? "expired" : row.status,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import type Mail from "nodemailer/lib/mailer/index.js";
|
||||
|
||||
// Returns null when SMTP is not configured — callers skip sending silently.
|
||||
function createTransport(): nodemailer.Transporter | null {
|
||||
const host = process.env.SMTP_HOST;
|
||||
if (!host) return null;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host,
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
let _transport: nodemailer.Transporter | null | undefined;
|
||||
|
||||
function getTransport(): nodemailer.Transporter | null {
|
||||
if (_transport === undefined) _transport = createTransport();
|
||||
return _transport;
|
||||
}
|
||||
|
||||
const FROM = process.env.SMTP_FROM ?? "Groom Book <noreply@groombook.local>";
|
||||
|
||||
export async function sendEmail(opts: Mail.Options): Promise<boolean> {
|
||||
const transport = getTransport();
|
||||
if (!transport) return false; // SMTP not configured — skip silently
|
||||
|
||||
await transport.sendMail({ from: FROM, ...opts });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Email templates ──────────────────────────────────────────────────────────
|
||||
|
||||
interface AppointmentEmailData {
|
||||
clientName: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
groomerName: string | null;
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
function formatDateTime(d: Date): string {
|
||||
return d.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function buildConfirmationEmail(
|
||||
to: string,
|
||||
data: AppointmentEmailData
|
||||
): Mail.Options {
|
||||
const time = formatDateTime(data.startTime);
|
||||
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
||||
return {
|
||||
to,
|
||||
subject: `Appointment Confirmed — ${data.petName} on ${data.startTime.toLocaleDateString()}`,
|
||||
text: [
|
||||
`Hi ${data.clientName},`,
|
||||
``,
|
||||
`Your appointment has been confirmed!`,
|
||||
``,
|
||||
` Pet: ${data.petName}`,
|
||||
` Service: ${data.serviceName}`,
|
||||
` When: ${time}${groomer}`,
|
||||
``,
|
||||
`We look forward to seeing you. If you need to reschedule, please contact us.`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<p>Hi ${data.clientName},</p>
|
||||
<p>Your appointment has been confirmed!</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
|
||||
</table>
|
||||
<p>We look forward to seeing you. If you need to reschedule, please contact us.</p>
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReminderEmail(
|
||||
to: string,
|
||||
data: AppointmentEmailData,
|
||||
hoursAhead: number,
|
||||
confirmationToken?: string | null
|
||||
): Mail.Options {
|
||||
const time = formatDateTime(data.startTime);
|
||||
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
||||
const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`;
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
|
||||
const confirmUrl = confirmationToken ? `${apiUrl}/api/book/confirm/${confirmationToken}` : null;
|
||||
const cancelUrl = confirmationToken ? `${apiUrl}/api/book/cancel/${confirmationToken}` : null;
|
||||
|
||||
const actionText = confirmationToken
|
||||
? [
|
||||
``,
|
||||
`Confirm your appointment: ${confirmUrl}`,
|
||||
`Cancel your appointment: ${cancelUrl}`,
|
||||
].join("\n")
|
||||
: "";
|
||||
|
||||
const actionHtml = confirmationToken
|
||||
? `
|
||||
<div style="margin:1.5em 0">
|
||||
<a href="${confirmUrl}" style="display:inline-block;padding:10px 20px;background:#10b981;color:#fff;text-decoration:none;border-radius:4px;font-weight:600;margin-right:12px">Confirm Appointment</a>
|
||||
<a href="${cancelUrl}" style="display:inline-block;padding:10px 20px;background:#fff;color:#ef4444;text-decoration:none;border-radius:4px;font-weight:600;border:1px solid #ef4444">Cancel Appointment</a>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return {
|
||||
to,
|
||||
subject: `Reminder: ${data.petName}'s appointment is ${when}`,
|
||||
text: [
|
||||
`Hi ${data.clientName},`,
|
||||
``,
|
||||
`Just a reminder that ${data.petName}'s grooming appointment is ${when}.`,
|
||||
``,
|
||||
` Pet: ${data.petName}`,
|
||||
` Service: ${data.serviceName}`,
|
||||
` When: ${time}${groomer}`,
|
||||
actionText,
|
||||
`See you soon!`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<p>Hi ${data.clientName},</p>
|
||||
<p>Just a reminder that <strong>${data.petName}</strong>'s grooming appointment is <strong>${when}</strong>.</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
|
||||
</table>
|
||||
${actionHtml}
|
||||
<p>See you soon!</p>
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
interface WaitlistNotificationData {
|
||||
clientName: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
preferredDate: string;
|
||||
preferredTime: string;
|
||||
}
|
||||
|
||||
export function buildWaitlistNotificationEmail(
|
||||
to: string,
|
||||
data: WaitlistNotificationData
|
||||
): Mail.Options {
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
const bookUrl = `${apiUrl}/book`;
|
||||
return {
|
||||
to,
|
||||
subject: `Appointment Cancelled — A slot has opened up for ${data.petName}`,
|
||||
text: [
|
||||
`Hi ${data.clientName},`,
|
||||
``,
|
||||
`Great news! An appointment slot has become available.`,
|
||||
``,
|
||||
`We had a cancellation for:`,
|
||||
` Pet: ${data.petName}`,
|
||||
` Service: ${data.serviceName}`,
|
||||
` Date: ${data.preferredDate}`,
|
||||
` Time: ${data.preferredTime}`,
|
||||
``,
|
||||
`If you're still interested, book now before this slot is taken!`,
|
||||
``,
|
||||
`Book your appointment: ${bookUrl}`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<p>Hi ${data.clientName},</p>
|
||||
<p>Great news! <strong>An appointment slot has become available</strong>.</p>
|
||||
<p>We had a cancellation for:</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Date</td><td>${data.preferredDate}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Time</td><td>${data.preferredTime}</td></tr>
|
||||
</table>
|
||||
<div style="margin:1.5em 0">
|
||||
<a href="${bookUrl}" style="display:inline-block;padding:12px 24px;background:#10b981;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;font-size:16px">Book This Slot</a>
|
||||
</div>
|
||||
<p>If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.</p>
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import Stripe from "stripe";
|
||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
||||
|
||||
let _stripe: Stripe | null | undefined;
|
||||
|
||||
export function getStripeClient(): Stripe | null {
|
||||
if (_stripe === undefined) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) return null;
|
||||
_stripe = new Stripe(secretKey);
|
||||
}
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return null;
|
||||
|
||||
if (client.stripeCustomerId) return client.stripeCustomerId;
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
metadata: { groombook_client_id: clientId },
|
||||
});
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
export async function createPaymentIntent(
|
||||
invoiceIdOrIds: string | string[],
|
||||
clientId: string
|
||||
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||
const firstInvoiceId = invoiceIds[0];
|
||||
if (!firstInvoiceId) return null;
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, firstInvoiceId));
|
||||
|
||||
const [invoice] = invoiceRows;
|
||||
if (!invoice) return null;
|
||||
|
||||
let totalCents = invoice.totalCents;
|
||||
if (invoiceIds.length > 1) {
|
||||
const allInvoices = await db
|
||||
.select({ totalCents: invoices.totalCents })
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, invoiceIds));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||
}
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: totalCents,
|
||||
currency: "usd",
|
||||
customer: stripeCustomerId,
|
||||
metadata: {
|
||||
groombook_invoice_ids: invoiceIds.join(","),
|
||||
groombook_client_id: clientId,
|
||||
},
|
||||
automatic_payment_methods: { enabled: true },
|
||||
});
|
||||
|
||||
for (const invId of invoiceIds) {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invId));
|
||||
}
|
||||
|
||||
const clientSecret = paymentIntent.client_secret;
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return { clientSecret, paymentIntentId: paymentIntent.id };
|
||||
}
|
||||
|
||||
export async function processRefund(
|
||||
invoiceId: string,
|
||||
amountCents?: number
|
||||
): Promise<{ refundId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
||||
if (!invoice?.stripePaymentIntentId) return null;
|
||||
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: invoice.stripePaymentIntentId,
|
||||
amount: amountCents,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
return { refundId: refund.id };
|
||||
}
|
||||
|
||||
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const methods = await stripe.paymentMethods.list({
|
||||
customer: stripeCustomerId,
|
||||
type: "card",
|
||||
});
|
||||
|
||||
return methods.data;
|
||||
}
|
||||
|
||||
export async function attachPaymentMethod(
|
||||
clientId: string,
|
||||
paymentMethodId: string
|
||||
): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return false;
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
await stripe.paymentMethods.detach(paymentMethodId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
});
|
||||
|
||||
return { clientSecret: setupIntent.client_secret! };
|
||||
}
|
||||
|
||||
export async function getPaymentIntentDetails(
|
||||
paymentIntentId: string
|
||||
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
|
||||
const cardLast4 = pi.payment_method
|
||||
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
|
||||
: null;
|
||||
return {
|
||||
cardLast4,
|
||||
paymentStatus: pi.status ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import cron from "node-cron";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
inArray,
|
||||
lt,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
reminderLogs,
|
||||
session,
|
||||
} from "@groombook/db";
|
||||
import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
} from "./email.js";
|
||||
import { smsSend } from "./sms.js";
|
||||
|
||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||
|
||||
function getReminderWindows(): { label: string; hours: number }[] {
|
||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||
return [
|
||||
{ label: `${early}h`, hours: early },
|
||||
{ label: `${late}h`, hours: late },
|
||||
];
|
||||
}
|
||||
|
||||
export async function runReminderCheck(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
for (const window of getReminderWindows()) {
|
||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||
|
||||
const upcoming = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
status: appointments.status,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
);
|
||||
|
||||
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||
if (appointmentIds.length === 0) continue;
|
||||
|
||||
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||
const sentRows = await db
|
||||
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
appointmentIds.length === 1
|
||||
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||
)
|
||||
);
|
||||
|
||||
const sentEmail = new Set(
|
||||
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||
);
|
||||
const sentSms = new Set(
|
||||
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||
);
|
||||
|
||||
// Bulk JOIN: fetch all client/pet/service/staff data in one query
|
||||
const joinedRows = await db
|
||||
.select({
|
||||
appointmentId: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
clientSmsOptIn: clients.smsOptIn,
|
||||
clientPhone: clients.phone,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
);
|
||||
|
||||
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||
for (const row of joinedRows) {
|
||||
appointmentMap.set(row.appointmentId, row);
|
||||
}
|
||||
|
||||
for (const appt of upcoming) {
|
||||
const joined = appointmentMap.get(appt.id as string);
|
||||
if (!joined) continue;
|
||||
|
||||
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) continue;
|
||||
if (!petName || !serviceName) continue;
|
||||
|
||||
const emailSent = sentEmail.has(appt.id as string);
|
||||
const smsSent = sentSms.has(appt.id as string);
|
||||
|
||||
let confirmationToken = appt.confirmationToken;
|
||||
if (!confirmationToken) {
|
||||
confirmationToken = randomBytes(32).toString("hex");
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ confirmationToken, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
if (!emailSent) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
clientEmail,
|
||||
{
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: staffName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||
const smsBody = [
|
||||
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||
`Confirm: ${confirmUrl}`,
|
||||
`Cancel: ${cancelUrl}`,
|
||||
TCPA_OPT_OUT,
|
||||
].join(". ");
|
||||
try {
|
||||
const smsOk = await smsSend(clientPhone, smsBody);
|
||||
if (smsOk) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[reminders] SMS send failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startReminderScheduler(): void {
|
||||
cron.schedule("* * * * *", () => {
|
||||
runReminderCheck().catch((err) => {
|
||||
console.error("[reminders] Error during reminder check:", err);
|
||||
});
|
||||
runSessionCleanup().catch((err) => {
|
||||
console.error("[reminders] Error during session cleanup:", err);
|
||||
});
|
||||
});
|
||||
console.log("[reminders] Reminder scheduler started");
|
||||
}
|
||||
|
||||
export async function runSessionCleanup(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.delete(session)
|
||||
.where(lt(session.expiresAt, now));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||
validateWebhookSignature(req: Request): boolean;
|
||||
}
|
||||
|
||||
interface TelnyxSmsResult {
|
||||
message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function createTelnyxClient(): Telnyx | null {
|
||||
const apiKey = process.env.TELNYX_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return new Telnyx(apiKey);
|
||||
}
|
||||
|
||||
let _client: Telnyx | null | undefined;
|
||||
|
||||
function getClient(): Telnyx | null {
|
||||
if (_client === undefined) _client = createTelnyxClient();
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getFromNumber(): string | null {
|
||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
}
|
||||
|
||||
function isE164(phone: string): boolean {
|
||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||
|
||||
const from = getFromNumber();
|
||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||
|
||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
};
|
||||
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
payload.media_urls = mediaUrls;
|
||||
}
|
||||
|
||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||
return {
|
||||
messageId: smsResult.message_id,
|
||||
status: smsResult.status,
|
||||
};
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements SmsProvider {
|
||||
async sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return sendSms(to, body, mediaUrls);
|
||||
}
|
||||
|
||||
validateWebhookSignature(req: Request): boolean {
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const signature = req.headers.get("telnyx-signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
const sigByte = sigBuf[i] ?? 0;
|
||||
const expByte = expBuf[i] ?? 0;
|
||||
diff |= sigByte ^ expByte;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: SmsProvider | null | undefined;
|
||||
|
||||
export function createSmsProvider(): SmsProvider | null {
|
||||
if (_provider === undefined) {
|
||||
if (process.env.SMS_ENABLED !== "true") {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
switch (process.env.SMS_PROVIDER) {
|
||||
case "telnyx": {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
_provider = new TelnyxProvider();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_provider = null;
|
||||
}
|
||||
}
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export async function smsSend(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<boolean> {
|
||||
const provider = createSmsProvider();
|
||||
if (!provider) return false;
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "@groombook/db";
|
||||
import { buildWaitlistNotificationEmail, sendEmail } from "./email.js";
|
||||
|
||||
export async function notifyWaitlistForAppointment(
|
||||
appointmentId: string,
|
||||
appointmentDate: string,
|
||||
appointmentTime: string,
|
||||
serviceId: string
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const matchingEntries = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(waitlistEntries.preferredDate, appointmentDate),
|
||||
eq(waitlistEntries.preferredTime, appointmentTime),
|
||||
eq(waitlistEntries.serviceId, serviceId),
|
||||
eq(waitlistEntries.status, "active")
|
||||
)
|
||||
);
|
||||
|
||||
for (const entry of matchingEntries) {
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, entry.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client?.email || client.emailOptOut) continue;
|
||||
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, entry.petId))
|
||||
.limit(1);
|
||||
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, entry.serviceId))
|
||||
.limit(1);
|
||||
|
||||
if (!pet || !service) continue;
|
||||
|
||||
const email = buildWaitlistNotificationEmail(client.email, {
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
preferredDate: appointmentDate,
|
||||
preferredTime: appointmentTime,
|
||||
});
|
||||
|
||||
const sent = await sendEmail(email);
|
||||
if (sent) {
|
||||
await db
|
||||
.update(waitlistEntries)
|
||||
.set({ status: "notified", notifiedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(waitlistEntries.id, entry.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
declare module "telnyx" {
|
||||
export interface MessageResult {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface MessagesCreateParams {
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
media_urls?: string[];
|
||||
}
|
||||
|
||||
export class Telnyx {
|
||||
constructor(apiKey: string);
|
||||
messages: {
|
||||
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user