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>
This commit is contained in:
Groom Book CEO
2026-03-20 02:09:41 +00:00
parent e546a73496
commit 4923606bb7
9 changed files with 668 additions and 0 deletions
+34
View File
@@ -218,6 +218,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 groomingVisitLogs = pgTable("grooming_visit_logs", {
id: uuid("id").primaryKey().defaultRandom(),
petId: uuid("pet_id")
+25
View File
@@ -145,6 +145,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;
}
// Paginated list response
export interface PaginatedList<T> {
items: T[];