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:
@@ -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")
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user