feat: Staff Impersonation backend + frontend wiring (#75)

* feat: implement Staff Impersonation backend and wire frontend

Add server-side impersonation session management with full audit
logging, replacing the frontend-only mock. Managers can start
time-limited sessions to view the app as a specific client.

Backend:
- Add impersonation_sessions and impersonation_audit_logs tables
  (Drizzle schema) with proper FK constraints and status enum
- Add Hono API routes: start/get/extend/end session + audit logging
- Server-side session expiration, one-active-per-staff enforcement
- Staff role validation (manager-only)

Frontend:
- Add CustomerPortal wrapper with URL-param session init
- Add ImpersonationBanner with live countdown timer
- Add AuditLogViewer modal for session audit trail
- Add "View as Customer" button on Clients page
- Auto-log page visits during impersonation

Closes #74

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* chore: remove unused useNavigate import from Clients.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add authorization + expiry checks to impersonation endpoints, add tests

Security: Add ownership verification (resolveStaff + staffId check) to
GET /sessions/:id, POST /sessions/:id/log, and GET /sessions/:id/audit-log
endpoints that were previously unprotected.

Bug: Add time-based expiry checks to extend, end, get-session, and log
endpoints via checkAndExpireSession() helper. Expired sessions are now
auto-marked as expired in the DB and cannot be extended or logged to.

Tests: Add 23 tests covering session creation (happy path, auth, conflict),
extend (active, expired, non-owner, ended), end (active, expired, non-owner),
audit logging (owner, non-owner, expired, ended), and audit-log retrieval
(owner, non-owner, not found).

Addresses QA review on PR #75 (GRO-66).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve @groombook/db source in vitest config

Add resolve alias so vitest can resolve @groombook/db from source
TypeScript files without requiring a prior build step. Fixes CI
test failures when dist/ has not been compiled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Groom Book CEO <ceo@groombook.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Groom Book CTO <cto@groombook.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Scrubs McBarkley <scrubs@groombook.app>
This commit was merged in pull request #75.
This commit is contained in:
groombook-paperclip[bot]
2026-03-20 08:16:09 +00:00
committed by GitHub
parent ea5450651d
commit 70958542f8
6 changed files with 974 additions and 0 deletions
@@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { JwtPayload } from "../middleware/auth.js";
// ─── Mock data ───────────────────────────────────────────────────────────────
const MANAGER_STAFF = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
role: "manager",
name: "Manager",
};
const GROOMER_STAFF = {
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");
function createApp(sub: string) {
const app = new Hono<{ Variables: { jwtPayload: JwtPayload } }>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub } as JwtPayload);
await next();
});
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("oidc-manager-sub");
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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", async () => {
const app = createApp("oidc-groomer-sub");
selectQueue.push([GROOMER_STAFF]);
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(/only managers/i);
});
it("returns 403 when staff record not found", async () => {
const app = createApp("unknown-sub");
selectQueue.push([]);
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("oidc-manager-sub");
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[] // 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("oidc-manager-sub");
const existing = makeSession();
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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("oidc-manager-sub");
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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("oidc-groomer-sub");
const session = makeSession(); // owned by manager
selectQueue.push(
[GROOMER_STAFF], // resolveStaff
[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("oidc-manager-sub");
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[] // 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("oidc-manager-sub");
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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("oidc-manager-sub");
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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("oidc-manager-sub");
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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("oidc-groomer-sub");
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF], // resolveStaff
[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("oidc-manager-sub");
const session = makeSession({ status: "ended" });
selectQueue.push(
[MANAGER_STAFF],
[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("oidc-manager-sub");
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF],
[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("oidc-manager-sub");
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF],
[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("oidc-groomer-sub");
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF],
[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("oidc-manager-sub");
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF],
[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("oidc-groomer-sub");
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF],
[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("oidc-manager-sub");
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF],
[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("oidc-manager-sub");
const session = makeSession({ status: "ended" });
selectQueue.push(
[MANAGER_STAFF],
[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("oidc-manager-sub");
const session = makeSession();
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[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("oidc-groomer-sub");
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF],
[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("oidc-manager-sub");
selectQueue.push(
[MANAGER_STAFF],
[]
);
const res = await app.request(
"/impersonation/sessions/nonexistent/audit-log"
);
expect(res.status).toBe(404);
});
});
+2
View File
@@ -12,6 +12,7 @@ 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 { getDb, businessSettings } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
@@ -66,6 +67,7 @@ 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);
const port = Number(process.env.PORT ?? 3000);
+330
View File
@@ -0,0 +1,330 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import {
and,
eq,
getDb,
impersonationSessions,
impersonationAuditLogs,
staff,
clients,
desc,
} from "@groombook/db";
import type { JwtPayload } from "../middleware/auth.js";
type Env = { Variables: { jwtPayload: JwtPayload } };
export const impersonationRouter = new Hono<Env>();
const SESSION_TIMEOUT_MINUTES = 30;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
return new Date(Date.now() + minutes * 60_000);
}
/** Resolve the staff row for the authenticated OIDC subject. */
async function resolveStaff(sub: string) {
const db = getDb();
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, sub));
return row ?? null;
}
/** 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 / — Start a new impersonation session ─────────────────────────────
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 jwt = c.get("jwtPayload") as JwtPayload;
const body = c.req.valid("json");
// Resolve authenticated staff
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
if (staffRow.role !== "manager") {
return c.json({ error: "Only managers can impersonate clients" }, 403);
}
// 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 jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
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 jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
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 jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
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 jwt = c.get("jwtPayload") as JwtPayload;
const body = c.req.valid("json");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
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 jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
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);
});
+6
View File
@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@groombook/db": path.resolve(__dirname, "../../packages/db/src/index.ts"),
},
},
test: {
coverage: {
provider: "v8",
+34
View File
@@ -225,6 +225,40 @@ export const reminderLogs = pgTable(
(t) => [unique().on(t.appointmentId, t.reminderType)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
export const impersonationSessionStatusEnum = pgEnum(
"impersonation_session_status",
["active", "ended", "expired"]
);
export const impersonationSessions = pgTable("impersonation_sessions", {
id: uuid("id").primaryKey().defaultRandom(),
staffId: uuid("staff_id")
.notNull()
.references(() => staff.id, { onDelete: "restrict" }),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
reason: text("reason"),
status: impersonationSessionStatusEnum("status").notNull().default("active"),
startedAt: timestamp("started_at").notNull().defaultNow(),
endedAt: timestamp("ended_at"),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const impersonationAuditLogs = pgTable("impersonation_audit_logs", {
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id")
.notNull()
.references(() => impersonationSessions.id, { onDelete: "cascade" }),
action: text("action").notNull(),
pageVisited: text("page_visited"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const businessSettings = pgTable("business_settings", {
id: uuid("id").primaryKey().defaultRandom(),
businessName: text("business_name").notNull().default("GroomBook"),
+25
View File
@@ -149,6 +149,31 @@ export interface Invoice {
tipSplits?: InvoiceTipSplit[];
}
// ─── Impersonation ──────────────────────────────────────────────────────────
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
export interface ImpersonationSession {
id: string;
staffId: string;
clientId: string;
reason: string | null;
status: ImpersonationSessionStatus;
startedAt: string;
endedAt: string | null;
expiresAt: string;
createdAt: string;
}
export interface ImpersonationAuditLog {
id: string;
sessionId: string;
action: string;
pageVisited: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
}
export interface BusinessSettings {
id: string;
businessName: string;