Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 58232381c7 feat(GRO-609): admin refund handling and payment stats dashboard
- Add refund button to invoice detail modal (paid Stripe invoices only)
- Full refund (default) and partial refund via confirmation dialog
- Call POST /api/invoices/:id/refund on confirmation
- Display Stripe payment info (card last4, brand, payment status)
- Show refund status when invoice has been refunded
- Add payment stats section to InvoicesPage (revenue, outstanding, refunds, method breakdown)
- Add GET /api/invoices/:id/stripe-payment endpoint for card details
- Add GET /api/invoices/stats endpoint for monthly payment stats
- Extend Invoice type with Stripe fields (stripePaymentIntentId, stripeRefundId)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 01:55:00 +00:00
31 changed files with 751 additions and 1135 deletions
-1
View File
@@ -23,7 +23,6 @@
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"stripe": "^22.0.0", "stripe": "^22.0.0",
"telnyx": "^1.23.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
+3 -17
View File
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
// ─── Queue-based mock DB ────────────────────────────────────────────────────── // ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = []; let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = []; let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = []; let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null; let deletedId: string | null = null;
function resetMock() { function resetMock() {
selectRows = []; selectRows = [];
appointmentRows = [];
insertedValues = []; insertedValues = [];
updatedValues = []; updatedValues = [];
deletedId = null; deletedId = null;
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } { 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 { return {
getDb: () => ({ getDb: () => ({
select: () => ({ select: () => ({
from: (table: unknown) => { from: () => makeChainable(selectRows),
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
}), }),
insert: () => ({ insert: () => ({
values: (vals: Record<string, unknown>) => { values: (vals: Record<string, unknown>) => {
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
}), }),
}), }),
clients, clients,
appointments,
eq: vi.fn(), eq: vi.fn(),
and: vi.fn(), and: vi.fn(),
or: vi.fn(),
}; };
}); });
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie"); expect(insertedValues[0]!.name).toBe("Charlie");
}); });
it("creates a client with name and email", async () => { it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" }); const res = await jsonRequest("POST", "/clients", { name: "Dana" });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana"); expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
}); });
it("rejects empty name", async () => { it("rejects empty name", async () => {
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
}), }),
appointments, appointments,
eq: () => ({}), eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
}; };
}); });
+2 -3
View File
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
}), }),
staff, staff,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
}; };
}); });
@@ -363,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test"); const res = await app.request("/test");
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i); expect(body.error).toMatch(/super user privileges required/i);
}); });
it("blocks a non-super-user groomer from manager-only routes", async () => { it("blocks a non-super-user groomer from manager-only routes", async () => {
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test"); const res = await app.request("/test");
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i); expect(body.error).toMatch(/super user privileges required/i);
}); });
it("allows a manager with multiple allowed roles", async () => { it("allows a manager with multiple allowed roles", async () => {
+2 -32
View File
@@ -33,26 +33,11 @@ import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono(); const app = new Hono();
// Global middleware // 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("*", logger());
app.use( app.use(
"/api/*", "/api/*",
cors({ cors({
origin: (origin, ctx) => { origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
if (!origin) {
return ALLOWED_ORIGIN;
}
if (TRUSTED_ORIGINS.includes(origin)) {
return origin;
}
ctx.status(403);
return null;
},
credentials: true, credentials: true,
}) })
); );
@@ -202,24 +187,9 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
await initAuth(); await initAuth();
console.log(`API server listening on port ${port}`); console.log(`API server listening on port ${port}`);
const server = serve({ fetch: app.fetch, port }); serve({ fetch: app.fetch, port });
// Start background reminder scheduler (runs every minute to check for upcoming appointments) // Start background reminder scheduler (runs every minute to check for upcoming appointments)
startReminderScheduler(); 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; export default app;
+4 -12
View File
@@ -89,7 +89,7 @@ export async function initAuth(): Promise<void> {
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance"); console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
authInstance = betterAuth({ authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }), database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET!, secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: { rateLimit: {
enabled: true, enabled: true,
@@ -177,9 +177,9 @@ export async function initAuth(): Promise<void> {
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); 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 hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const issuerUrlObj = new URL(providerConfig.issuerUrl); // Fetch OIDC discovery document to derive canonical provider URLs.
const issuerHostname = issuerUrlObj.hostname; // Replace the host of token/userinfo endpoints with internalBaseUrl when set,
// while keeping authorizationUrl public for browser redirects.
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`; const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {}; let oidcConfig: Record<string, string> = {};
try { try {
@@ -203,14 +203,6 @@ export async function initAuth(): Promise<void> {
const tokenUrl = discovery.token_endpoint; const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint; const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) { 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 = { oidcConfig = {
authorizationUrl: authzUrl, authorizationUrl: authzUrl,
tokenUrl: providerConfig.internalBaseUrl tokenUrl: providerConfig.internalBaseUrl
-45
View File
@@ -1,45 +0,0 @@
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);
}
};
-40
View File
@@ -1,40 +0,0 @@
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();
};
+8 -25
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db"; import { eq, getDb, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect; export type StaffRow = typeof staff.$inferSelect;
@@ -89,31 +89,14 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.select() .select()
.from(staff) .from(staff)
.where(eq(staff.oidcSub, jwt.sub)); .where(eq(staff.oidcSub, jwt.sub));
if (fallbackRow) { 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( return c.json(
{ error: "Forbidden: no staff record found for authenticated user" }, { error: "Forbidden: no staff record found for authenticated user" },
403 403
); );
}
c.set("staff", fallbackRow);
await next();
}; };
/** /**
@@ -166,9 +149,9 @@ export function requireRoleOrSuperUser(
} }
return c.json( return c.json(
{ {
error: hasAllowedRole error: staffRow.isSuperUser
? "Forbidden: super user privileges required" ? `Forbidden: role '${staffRow.role}' is not permitted`
: `Forbidden: role '${staffRow.role}' is not permitted`, : "Forbidden: super user privileges required",
}, },
403 403
); );
+42 -197
View File
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.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>(); export const appointmentsRouter = new Hono<AppEnv>();
const createAppointmentSchema = z.object({ const createAppointmentSchema = z.object({
@@ -62,10 +41,6 @@ const createAppointmentSchema = z.object({
frequencyWeeks: z.number().int().min(1).max(52), frequencyWeeks: z.number().int().min(1).max(52),
count: z.number().int().min(2).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(), .optional(),
}); });
@@ -233,54 +208,11 @@ appointmentsRouter.post(
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000; recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
let first: typeof appointments.$inferSelect | undefined; let first: typeof appointments.$inferSelect | undefined;
const conflictingInstances: number[] = [];
for (let i = 0; i < recurrence.count; i++) { for (let i = 0; i < recurrence.count; i++) {
const instanceStart = new Date(start.getTime() + i * intervalMs); const instanceStart = new Date(start.getTime() + i * intervalMs);
const instanceEnd = new Date( const instanceEnd = new Date(
instanceStart.getTime() + durationMs 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 const [inserted] = await tx
.insert(appointments) .insert(appointments)
.values({ .values({
@@ -291,19 +223,9 @@ appointmentsRouter.post(
seriesIndex: i, seriesIndex: i,
}) })
.returning(); .returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
if (i === 0) first = inserted; 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"); if (!first) throw new Error("No appointments created");
return first; return first;
}); });
@@ -321,12 +243,9 @@ appointmentsRouter.post(
} }
// Send confirmation email (fire-and-forget — never fails the request) // Send confirmation email (fire-and-forget — never fails the request)
withRetry( sendConfirmationEmail(db, firstRow).catch((err) => {
() => sendConfirmationEmail(db, firstRow), console.error("[appointments] Failed to send confirmation email:", err);
2, });
1000,
`Failed to send confirmation email for appointment ${firstRow.id}`
);
return c.json(firstRow, 201); return c.json(firstRow, 201);
} }
@@ -338,35 +257,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect appt: typeof appointments.$inferSelect
): Promise<void> { ): Promise<void> {
const [row] = await db const [client] = await db
.select({ .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
clientName: clients.name, .from(clients)
clientEmail: clients.email, .where(eq(clients.id, appt.clientId))
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); .limit(1);
if (!row) return; if (!client || !client.email || client.emailOptOut) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return; const [pet] = await db
if (!petName || !serviceName) return; .select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) return;
const sent = await sendEmail( const sent = await sendEmail(
buildConfirmationEmail(clientEmail, { buildConfirmationEmail(client.email, {
clientName, clientName: client.name,
petName, petName: pet.name,
serviceName, serviceName: service.name,
groomerName: groomerName ?? null, groomerName,
startTime: appt.startTime, startTime: appt.startTime,
}) })
); );
@@ -446,76 +374,6 @@ appointmentsRouter.patch(
let firstUpdated: typeof appointments.$inferSelect | undefined; let firstUpdated: typeof appointments.$inferSelect | undefined;
for (const appt of affected) { 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> = { const apptUpdate: Record<string, unknown> = {
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -551,13 +409,6 @@ appointmentsRouter.patch(
if (statusCode === 404) return c.json({ error: "Not found" }, 404); if (statusCode === 404) return c.json({ error: "Not found" }, 404);
if (statusCode === 422) if (statusCode === 422)
return c.json({ error: "endTime must be after startTime" }, 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; throw err;
} }
@@ -735,12 +586,9 @@ appointmentsRouter.delete("/:id", async (c) => {
const apptDate = current.startTime.toISOString().slice(0, 10); const apptDate = current.startTime.toISOString().slice(0, 10);
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
withRetry( notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), console.error("[appointments] Failed to notify waitlist:", err);
2, });
1000,
`Failed to notify waitlist for appointment ${id}`
);
return c.json({ ok: true }); return c.json({ ok: true });
} }
@@ -763,12 +611,9 @@ appointmentsRouter.delete("/:id", async (c) => {
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404); if (!row) return c.json({ error: "Not found" }, 404);
withRetry( notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), console.error("[appointments] Failed to notify waitlist:", err);
2, });
1000,
`Failed to notify waitlist for appointment ${id}`
);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
+12 -28
View File
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({ const bookingSchema = z.object({
serviceId: z.string().uuid(), serviceId: z.string().uuid(),
startTime: z.string().datetime().refine( startTime: z.string().datetime(),
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
clientName: z.string().min(1).max(200), clientName: z.string().min(1).max(200),
clientEmail: z.string().email(), clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(), clientPhone: z.string().max(50).optional(),
@@ -268,36 +265,29 @@ bookRouter.get("/confirm/:token", async (c) => {
return c.redirect(`${BASE_URL()}/booking/error`); return c.redirect(`${BASE_URL()}/booking/error`);
} }
// Reject if appointment is in the past
if (appt.startTime < new Date()) { if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`); return c.redirect(`${BASE_URL()}/booking/error`);
} }
// Idempotent confirm: if already confirmed, redirect to success
if (appt.confirmationStatus === "confirmed") { if (appt.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`); return c.redirect(`${BASE_URL()}/booking/confirmed`);
} }
// Reject if already cancelled
if (appt.confirmationStatus === "cancelled") { if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`); return c.redirect(`${BASE_URL()}/booking/error`);
} }
const updated = await db await db
.update(appointments) .update(appointments)
.set({ .set({
confirmationStatus: "confirmed", confirmationStatus: "confirmed",
confirmedAt: new Date(), confirmedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where( .where(eq(appointments.id, appt.id));
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`); return c.redirect(`${BASE_URL()}/booking/confirmed`);
}); });
@@ -319,15 +309,19 @@ bookRouter.get("/cancel/:token", async (c) => {
return c.redirect(`${BASE_URL()}/booking/error`); return c.redirect(`${BASE_URL()}/booking/error`);
} }
// Reject if appointment is in the past
if (appt.startTime < new Date()) { if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`); return c.redirect(`${BASE_URL()}/booking/error`);
} }
// Reject if already cancelled (token was nullified — this path won't normally hit,
// but guard against edge cases where token lookup still works)
if (appt.confirmationStatus === "cancelled") { if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`); return c.redirect(`${BASE_URL()}/booking/error`);
} }
const updated = await db // Single-use cancellation: nullify token after use
await db
.update(appointments) .update(appointments)
.set({ .set({
confirmationStatus: "cancelled", confirmationStatus: "cancelled",
@@ -335,17 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null, confirmationToken: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where( .where(eq(appointments.id, appt.id));
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`); return c.redirect(`${BASE_URL()}/booking/cancelled`);
}); });
+2 -13
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { randomBytes, timingSafeEqual } from "node:crypto"; import { randomBytes } from "node:crypto";
import { import {
and, and,
eq, eq,
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId)) .where(eq(staff.id, staffId))
.limit(1); .limit(1);
if (!staffMember || !staffMember.icalToken) { if (!staffMember || staffMember.icalToken !== token) {
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); return c.text("Unauthorized", 401);
} }
+3 -27
View File
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({ const createClientSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
email: z.string().email(), email: z.string().email().optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
smsOptIn: z.boolean().optional(),
smsConsentText: z.string().max(1000).optional(),
}); });
@@ -97,7 +95,6 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
// Update a client (including status changes) // Update a client (including status changes)
const patchClientSchema = createClientSchema.partial().extend({ const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(), status: z.enum(["active", "disabled"]).optional(),
smsOptOut: z.boolean().optional(),
}); });
clientsRouter.patch( clientsRouter.patch(
@@ -110,19 +107,13 @@ clientsRouter.patch(
const setValues: Record<string, unknown> = { ...body, updatedAt: now }; const setValues: Record<string, unknown> = { ...body, updatedAt: now };
// When disabling, set disabledAt; when re-enabling, clear it
if (body.status === "disabled") { if (body.status === "disabled") {
setValues.disabledAt = now; setValues.disabledAt = now;
} else if (body.status === "active") { } else if (body.status === "active") {
setValues.disabledAt = null; setValues.disabledAt = null;
} }
if (body.smsOptOut === true) {
setValues.smsOptIn = false;
setValues.smsOptOutDate = now;
delete setValues.smsOptOut;
}
delete setValues.smsOptOut;
const [row] = await db const [row] = await db
.update(clients) .update(clients)
.set(setValues) .set(setValues)
@@ -144,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
} }
const db = getDb(); 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 const [row] = await db
.delete(clients) .delete(clients)
.where(eq(clients.id, clientId)) .where(eq(clients.id, c.req.param("id")))
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404); if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true }); return c.json({ ok: true });
+120 -59
View File
@@ -4,11 +4,11 @@ import { z } from "zod/v3";
import { import {
and, and,
eq, eq,
gte,
getDb, getDb,
invoices, invoices,
invoiceLineItems, invoiceLineItems,
invoiceTipSplits, invoiceTipSplits,
refunds,
appointments, appointments,
services, services,
clients, clients,
@@ -45,20 +45,13 @@ const updateInvoiceSchema = z.object({
}); });
// List invoices // List invoices
const listInvoicesQuerySchema = z.object({ invoicesRouter.get("/", async (c) => {
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 db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query"); const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId");
const status = c.req.query("status");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = []; const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId)); if (clientId) conditions.push(eq(invoices.clientId, clientId));
@@ -97,8 +90,7 @@ invoicesRouter.get(
.offset(offset); .offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 }); return c.json({ data: rows, total: totalResult?.count ?? 0 });
} });
);
// Get single invoice with line items and tip splits // Get single invoice with line items and tip splits
invoicesRouter.get("/:id", async (c) => { invoicesRouter.get("/:id", async (c) => {
@@ -126,8 +118,8 @@ const tipSplitSchema = z.object({
}) })
).min(1).refine( ).min(1).refine(
(splits) => { (splits) => {
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
return totalBps === 10000; return Math.abs(total - 100) < 0.01;
}, },
{ message: "Split percentages must sum to 100" } { message: "Split percentages must sum to 100" }
), ),
@@ -171,13 +163,12 @@ invoicesRouter.post(
} }
}); });
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id)); const splits = await db
const [lineItems, tipSplits] = await Promise.all([ .select()
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), .from(invoiceTipSplits)
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), .where(eq(invoiceTipSplits.invoiceId, id));
]);
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201); return c.json(splits, 201);
} }
); );
@@ -302,13 +293,6 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
return c.json({ ...invoice, lineItems: [lineItem] }, 201); 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 // Update invoice
invoicesRouter.patch( invoicesRouter.patch(
"/:id", "/:id",
@@ -324,14 +308,8 @@ invoicesRouter.patch(
.where(eq(invoices.id, id)); .where(eq(invoices.id, id));
if (!current) return c.json({ error: "Not found" }, 404); if (!current) return c.json({ error: "Not found" }, 404);
if (body.status !== undefined) { if (current.status === "void") {
const allowed = ALLOWED_TRANSITIONS[current.status] ?? []; return c.json({ error: "Cannot modify a voided invoice" }, 422);
if (!allowed.includes(body.status)) {
return c.json(
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
422
);
}
} }
const update: Record<string, unknown> = { ...body, updatedAt: new Date() }; const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
@@ -369,7 +347,6 @@ import { processRefund } from "../services/payment.js";
const refundSchema = z.object({ const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(), amountCents: z.number().int().nonnegative().optional(),
idempotencyKey: z.string().max(255).optional(),
}); });
invoicesRouter.post( invoicesRouter.post(
@@ -395,28 +372,112 @@ invoicesRouter.post(
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); return c.json({ error: "No Stripe payment intent found for this invoice" }, 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 });
}
}
const result = await processRefund(id, body.amountCents); const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500); if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId: result.refundId }); return c.json({ refundId: result.refundId });
});
} }
); );
// ─── Stripe Payment Info ───────────────────────────────────────────────────────
import { getStripeClient } from "../services/payment.js";
invoicesRouter.get("/:id/stripe-payment", 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);
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment found for this invoice" }, 404);
}
const stripe = getStripeClient();
if (!stripe) return c.json({ error: "Stripe not configured" }, 503);
try {
const paymentIntent = await stripe.paymentIntents.retrieve(invoice.stripePaymentIntentId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cardDetails = (paymentIntent as any).payment_details?.card;
const refundStatus = invoice.stripeRefundId
? await stripe.refunds.retrieve(invoice.stripeRefundId).then((r) => r.status).catch(() => null)
: null;
return c.json({
paymentIntentId: invoice.stripePaymentIntentId,
amountPaidCents: paymentIntent.amount_received,
status: paymentIntent.status,
cardLast4: cardDetails?.last4 ?? null,
cardBrand: cardDetails?.brand ?? null,
refundId: invoice.stripeRefundId,
refundStatus,
});
} catch {
return c.json({ error: "Failed to retrieve Stripe payment info" }, 500);
}
});
// ─── Payment Stats ─────────────────────────────────────────────────────────────
invoicesRouter.get("/stats", async (c) => {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonthInvoices = await db
.select()
.from(invoices)
.where(
and(
gte(invoices.createdAt, startOfMonth),
eq(invoices.status, "paid")
)
);
const revenueCents = thisMonthInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
const pendingInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(eq(invoices.status, "pending"));
const outstandingCents = pendingInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
const refundedInvoices = await db
.select()
.from(invoices)
.where(
and(
gte(invoices.createdAt, startOfMonth),
sql`${invoices.stripeRefundId} IS NOT NULL`
)
);
const refundsCents = refundedInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
const paymentMethodBreakdown = await db
.select({
paymentMethod: invoices.paymentMethod,
count: sql<number>`count(*)`,
totalCents: sql<number>`sum(${invoices.totalCents})`,
})
.from(invoices)
.where(
and(
gte(invoices.createdAt, startOfMonth),
sql`${invoices.paymentMethod} IS NOT NULL`
)
)
.groupBy(invoices.paymentMethod);
return c.json({
revenueCents,
outstandingCents,
refundsCents,
revenueCount: thisMonthInvoices.length,
refundCount: refundedInvoices.length,
paymentMethodBreakdown,
});
});
+122 -23
View File
@@ -1,22 +1,33 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db"; import { and, eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js"; import type { AppEnv } from "../middleware/rbac.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
export const portalRouter = new Hono<PortalEnv>(); export const portalRouter = new Hono<AppEnv>();
// Apply middleware to all portal routes // ─── Session helper ───────────────────────────────────────────────────────────
portalRouter.use("/*", validatePortalSession, portalAudit);
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
if (!sessionId) return null;
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 null;
return session.clientId;
}
// ─── GET routes ────────────────────────────────────────────────────────────── // ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => { portalRouter.get("/me", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404); if (!client) return c.json({ error: "Not found" }, 404);
@@ -38,7 +49,9 @@ portalRouter.get("/services", async (c) => {
portalRouter.get("/appointments", async (c) => { portalRouter.get("/appointments", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date(); const now = new Date();
const allAppts = await db const allAppts = await db
@@ -88,7 +101,9 @@ portalRouter.get("/appointments", async (c) => {
portalRouter.get("/pets", async (c) => { portalRouter.get("/pets", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); 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, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
@@ -96,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
portalRouter.get("/invoices", async (c) => { portalRouter.get("/invoices", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id); const invoiceIds = clientInvoices.map(i => i.id);
@@ -131,7 +148,12 @@ portalRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -174,7 +196,12 @@ portalRouter.patch(
portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/confirm", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -223,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
portalRouter.post("/appointments/:id/cancel", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -287,7 +319,28 @@ portalRouter.post(
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
let clientId: string | null = null;
if (sessionId) {
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()) {
clientId = session.clientId;
}
}
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [entry] = await db const [entry] = await db
.insert(waitlistEntries) .insert(waitlistEntries)
@@ -311,7 +364,26 @@ portalRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
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);
}
const [existing] = await db const [existing] = await db
.select() .select()
@@ -320,7 +392,7 @@ portalRouter.patch(
.limit(1); .limit(1);
if (!existing) return c.json({ error: "Not found" }, 404); if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) { if (existing.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
@@ -342,7 +414,26 @@ portalRouter.patch(
portalRouter.delete("/waitlist/:id", async (c) => { portalRouter.delete("/waitlist/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
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);
}
const [entry] = await db const [entry] = await db
.select() .select()
@@ -351,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.limit(1); .limit(1);
if (!entry) return c.json({ error: "Not found" }, 404); if (!entry) return c.json({ error: "Not found" }, 404);
if (entry.clientId !== clientId) { if (entry.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
@@ -384,7 +475,9 @@ portalRouter.post(
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db const invoiceRows = await db
.select() .select()
@@ -421,7 +514,9 @@ portalRouter.post(
); );
portalRouter.get("/payment-methods", async (c) => { portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId); const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
@@ -429,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
}); });
portalRouter.post("/payment-methods", async (c) => { portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId); const customerId = await getOrCreateStripeCustomer(clientId);
@@ -442,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
}); });
portalRouter.delete("/payment-methods/:id", async (c) => { portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id"); const paymentMethodId = c.req.param("id");
+1 -1
View File
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(), basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480), durationMinutes: z.number().int().positive(),
active: z.boolean().default(true), active: z.boolean().default(true),
}); });
+11 -58
View File
@@ -4,24 +4,6 @@ import { z } from "zod/v3";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db"; import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js"; 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 now = Date.now();
const entry = rateLimitMap.get(ip);
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>(); export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed // GET /api/setup/status — public (no auth), returns whether setup is needed
@@ -203,43 +185,37 @@ const authProviderTestSchema = z.object({
* After setup completes, this endpoint permanently returns 403. * After setup completes, this endpoint permanently returns 403.
*/ */
setupRouter.post("/auth-provider", async (c) => { 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(); const db = getDb();
let row: typeof authProviderConfig.$inferSelect; // Guard: only allow during fresh install (no super user yet)
try { const [superUser] = await db
row = await db.transaction(async (tx) => {
const [superUser] = await tx
.select({ id: staff.id }) .select({ id: staff.id })
.from(staff) .from(staff)
.where(eq(staff.isSuperUser, true)) .where(eq(staff.isSuperUser, true))
.limit(1); .limit(1);
if (superUser) { if (superUser) {
throw Object.assign(new Error("setup-complete"), { code: 403 }); // Setup already completed — lock this endpoint permanently
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
} }
const [existingConfig] = await tx // Guard: ensure no DB config already exists (should be redundant with status check but defensive)
const [existingConfig] = await db
.select({ id: authProviderConfig.id }) .select({ id: authProviderConfig.id })
.from(authProviderConfig) .from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true)) .where(eq(authProviderConfig.enabled, true))
.limit(1); .limit(1);
if (existingConfig) { if (existingConfig) {
throw Object.assign(new Error("config-exists"), { code: 409 }); return c.json({ error: "Auth provider is already configured." }, 409);
} }
const body = authProviderBootstrapSchema.parse(await c.req.json()); const body = authProviderBootstrapSchema.parse(await c.req.json());
// Encrypt clientSecret before storing
const encryptedSecret = encryptSecret(body.clientSecret); const encryptedSecret = encryptSecret(body.clientSecret);
const [configRow] = await tx const [row] = await db
.insert(authProviderConfig) .insert(authProviderConfig)
.values({ .values({
providerId: body.providerId, providerId: body.providerId,
@@ -253,24 +229,8 @@ setupRouter.post("/auth-provider", async (c) => {
}) })
.returning(); .returning();
if (!configRow) { if (!row) {
throw Object.assign(new Error("insert-failed"), { code: 500 }); return c.json({ error: "Failed to save auth provider configuration." }, 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({ return c.json({
@@ -294,13 +254,6 @@ setupRouter.post("/auth-provider", async (c) => {
* Only available when needsSetup is true (no super user = fresh install). * Only available when needsSetup is true (no super user = fresh install).
*/ */
setupRouter.post("/auth-provider/test", async (c) => { 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(); const db = getDb();
// Guard: only allow during fresh install (no super user yet) // Guard: only allow during fresh install (no super user yet)
+3 -10
View File
@@ -1,6 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import Stripe from "stripe"; import Stripe from "stripe";
import { z } from "zod/v3";
import { eq, getDb, invoices } from "@groombook/db"; import { eq, getDb, invoices } from "@groombook/db";
import { getStripeClient } from "../services/payment.js"; import { getStripeClient } from "../services/payment.js";
@@ -45,13 +44,10 @@ webhooksRouter.post("/stripe", async (c) => {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) { for (const invoiceId of invoiceIds) {
if (!invoiceId) continue; if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
const [inv] = await db const [inv] = await db
.select() .select()
.from(invoices) .from(invoices)
.where(eq(invoices.id, invoiceIdTrimmed)) .where(eq(invoices.id, invoiceId))
.limit(1); .limit(1);
if (!inv) continue; if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue; if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
stripePaymentIntentId: pi.id, stripePaymentIntentId: pi.id,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(invoices.id, invoiceIdTrimmed)); .where(eq(invoices.id, invoiceId));
} }
} }
} else if (event.type === "payment_intent.payment_failed") { } else if (event.type === "payment_intent.payment_failed") {
@@ -73,16 +69,13 @@ webhooksRouter.post("/stripe", async (c) => {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) { for (const invoiceId of invoiceIds) {
if (!invoiceId) continue; if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
await db await db
.update(invoices) .update(invoices)
.set({ .set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed", paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(invoices.id, invoiceIdTrimmed)); .where(eq(invoices.id, invoiceId));
} }
} }
} else if (event.type === "charge.refunded") { } else if (event.type === "charge.refunded") {
+22 -52
View File
@@ -18,10 +18,9 @@ import {
buildReminderEmail, buildReminderEmail,
sendEmail, sendEmail,
} from "./email.js"; } from "./email.js";
import { smsSend } from "./sms.js";
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
// How many hours before the appointment to send each reminder.
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
function getReminderWindows(): { label: string; hours: number }[] { function getReminderWindows(): { label: string; hours: number }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -31,14 +30,20 @@ function getReminderWindows(): { label: string; hours: number }[] {
]; ];
} }
// Checks for upcoming appointments that need reminders and sends them.
// Runs every minute — idempotent via reminder_logs unique constraint.
export async function runReminderCheck(): Promise<void> { export async function runReminderCheck(): Promise<void> {
const db = getDb(); const db = getDb();
const now = new Date(); const now = new Date();
for (const window of getReminderWindows()) { for (const window of getReminderWindows()) {
// Target window: appointments starting between (hours - 1) and hours from now.
// Running every minute means we check a 1-minute slice; the 1-hour window
// ensures we catch appointments that started between heartbeats.
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000); const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
const windowEnd = new Date(now.getTime() + window.hours * 3600_000); const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
// Find upcoming appointments in this time window that haven't been cancelled/completed
const upcoming = await db const upcoming = await db
.select({ .select({
id: appointments.id, id: appointments.id,
@@ -60,38 +65,23 @@ export async function runReminderCheck(): Promise<void> {
); );
for (const appt of upcoming) { for (const appt of upcoming) {
const [emailLog] = await db // Check if reminder already sent (unique constraint prevents double-send)
const existing = await db
.select({ id: reminderLogs.id }) .select({ id: reminderLogs.id })
.from(reminderLogs) .from(reminderLogs)
.where( .where(
and( and(
eq(reminderLogs.appointmentId, appt.id), eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label), eq(reminderLogs.reminderType, window.label)
eq(reminderLogs.channel, "email")
) )
) )
.limit(1); .limit(1);
const [smsLog] = await db if (existing.length > 0) continue; // already sent
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
// Fetch related records for the email
const [client] = await db const [client] = await db
.select({ .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.from(clients) .from(clients)
.where(eq(clients.id, appt.clientId)) .where(eq(clients.id, appt.clientId))
.limit(1); .limit(1);
@@ -122,6 +112,8 @@ export async function runReminderCheck(): Promise<void> {
if (!pet || !service) continue; if (!pet || !service) continue;
// Ensure the appointment has a confirmation token before sending the reminder.
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
let confirmationToken = appt.confirmationToken; let confirmationToken = appt.confirmationToken;
if (!confirmationToken) { if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex"); confirmationToken = randomBytes(32).toString("hex");
@@ -131,7 +123,6 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
if (!emailLog) {
const sent = await sendEmail( const sent = await sendEmail(
buildReminderEmail( buildReminderEmail(
client.email, client.email,
@@ -148,42 +139,19 @@ export async function runReminderCheck(): Promise<void> {
); );
if (sent) { if (sent) {
// Record send — ignore conflicts (race condition between instances)
await db await db
.insert(reminderLogs) .insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" }) .values({ appointmentId: appt.id, reminderType: window.label })
.onConflictDoNothing(); .onConflictDoNothing();
} }
} }
if (!smsLog && client.smsOptIn && client.phone) {
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 ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`,
TCPA_OPT_OUT,
].join(". ");
try {
const smsOk = await smsSend(client.phone, 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);
}
}
}
} }
} }
// Starts the cron scheduler. Call once at server startup.
export function startReminderScheduler(): void { export function startReminderScheduler(): void {
// Run every minute
cron.schedule("* * * * *", () => { cron.schedule("* * * * *", () => {
runReminderCheck().catch((err) => { runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", err); console.error("[reminders] Error during reminder check:", err);
@@ -195,6 +163,8 @@ export function startReminderScheduler(): void {
console.log("[reminders] Reminder scheduler started"); console.log("[reminders] Reminder scheduler started");
} }
// Deletes expired sessions from the database.
// Runs every minute alongside reminder checks.
export async function runSessionCleanup(): Promise<void> { export async function runSessionCleanup(): Promise<void> {
const db = getDb(); const db = getDb();
const now = new Date(); const now = new Date();
-142
View File
@@ -1,142 +0,0 @@
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;
}
-19
View File
@@ -1,19 +0,0 @@
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>;
};
}
}
+188 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -173,6 +173,23 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash"); const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [stripeInfo, setStripeInfo] = useState<StripePaymentInfo | null>(null);
const [stripeLoading, setStripeLoading] = useState(false);
const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmountStr, setRefundAmountStr] = useState("");
const [refunding, setRefunding] = useState(false);
useEffect(() => {
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
setStripeLoading(true);
fetch(`/api/invoices/${invoice.id}/stripe-payment`)
.then((r) => r.json())
.then((data: StripePaymentInfo) => setStripeInfo(data))
.catch(() => { /* non-blocking */ })
.finally(() => setStripeLoading(false));
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct} // Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId const linkedAppt = invoice.appointmentId
@@ -271,6 +288,31 @@ function InvoiceDetailModal({
} }
} }
async function submitRefund() {
setRefunding(true);
setError(null);
const amountCents = refundType === "partial"
? Math.round(parseFloat(refundAmountStr) * 100)
: undefined;
try {
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amountCents }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Refund failed");
} finally {
setRefunding(false);
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>; if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
@@ -330,6 +372,18 @@ function InvoiceDetailModal({
/> />
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />} {invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />} {invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{stripeLoading && <SummaryRow label="Stripe" value="Loading…" />}
{stripeInfo && (
<>
{stripeInfo.cardLast4 && (
<SummaryRow label="Card" value={`${stripeInfo.cardBrand ?? "Card"} •••• ${stripeInfo.cardLast4}`} />
)}
<SummaryRow label="Stripe status" value={stripeInfo.status} />
{invoice.stripeRefundId && stripeInfo.refundStatus && (
<SummaryRow label="Refund status" value={stripeInfo.refundStatus === "succeeded" ? "Refunded" : stripeInfo.refundStatus} />
)}
</>
)}
</div> </div>
{/* ── Tip Distribution ── */} {/* ── Tip Distribution ── */}
@@ -447,10 +501,101 @@ function InvoiceDetailModal({
</div> </div>
)} )}
{(invoice.status === "paid" || invoice.status === "void") && ( {(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && (
<button
onClick={() => {
setRefundType("full");
setRefundAmountStr("");
setShowRefundDialog(true);
}}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}
>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button> <button onClick={onClose} style={btnStyle}>Close</button>
</div> </div>
)} )}
{showRefundDialog && (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 110,
}}
onClick={(e) => { if (e.target === e.currentTarget) setShowRefundDialog(false); }}
>
<div style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 400, width: "calc(100% - 2rem)",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}>
<h3 style={{ margin: "0 0 1rem" }}>Process Refund</h3>
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
Invoice total: {fmtMoney(invoice.totalCents)}
</p>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
Refund type
</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => setRefundType("full")}
style={{
...btnStyle,
backgroundColor: refundType === "full" ? "var(--color-primary)" : "#fff",
color: refundType === "full" ? "#fff" : "#374151",
borderColor: refundType === "full" ? "var(--color-primary)" : "#d1d5db",
}}
>
Full refund
</button>
<button
onClick={() => { setRefundType("partial"); setRefundAmountStr((invoice.totalCents / 100).toFixed(2)); }}
style={{
...btnStyle,
backgroundColor: refundType === "partial" ? "var(--color-primary)" : "#fff",
color: refundType === "partial" ? "#fff" : "#374151",
borderColor: refundType === "partial" ? "var(--color-primary)" : "#d1d5db",
}}
>
Partial refund
</button>
</div>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
Refund amount
</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ color: "#6b7280" }}>$</span>
<input
type="number"
min="0.01"
max={(invoice.totalCents / 100).toFixed(2)}
step="0.01"
value={refundAmountStr}
onChange={(e) => setRefundAmountStr(e.target.value)}
style={{ ...inputStyle, width: 100 }}
/>
</div>
</div>
)}
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>Cancel</button>
<button
onClick={submitRefund}
disabled={refunding || (refundType === "partial" && (!refundAmountStr || parseFloat(refundAmountStr) <= 0))}
style={{ ...btnStyle, backgroundColor: "#dc2626", color: "#fff", borderColor: "#dc2626" }}
>
{refunding ? "Refunding…" : "Refund"}
</button>
</div>
</div>
</div>
)}
</Modal> </Modal>
); );
} }
@@ -492,6 +637,8 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null); const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [stats, setStats] = useState<PaymentStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const LIMIT = 50; const LIMIT = 50;
@@ -513,6 +660,15 @@ export function InvoicesPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [statusFilter]); }, [statusFilter]);
useEffect(() => {
setStatsLoading(true);
fetch("/api/invoices/stats")
.then((r) => r.json())
.then((data: PaymentStats) => setStats(data))
.catch(() => { /* non-blocking */ })
.finally(() => setStatsLoading(false));
}, []);
function loadCreateData() { function loadCreateData() {
if (createData) return Promise.resolve(); if (createData) return Promise.resolve();
setCreateLoading(true); setCreateLoading(true);
@@ -573,6 +729,36 @@ export function InvoicesPage() {
</button> </button>
</div> </div>
{!statsLoading && stats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1rem" }}>
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Revenue this month</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#065f46" }}>{fmtMoney(stats.revenueCents)}</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.revenueCount} paid</div>
</div>
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#92400e" }}>{fmtMoney(stats.outstandingCents)}</div>
</div>
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Refunds this month</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#991b1b" }}>{fmtMoney(stats.refundsCents)}</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.refundCount} refunds</div>
</div>
{stats.paymentMethodBreakdown.length > 0 && (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>By payment method</div>
{stats.paymentMethodBreakdown.map((b) => (
<div key={b.paymentMethod} style={{ fontSize: 13, display: "flex", justifyContent: "space-between", marginTop: "0.2rem" }}>
<span style={{ textTransform: "capitalize" }}>{b.paymentMethod}</span>
<span style={{ fontWeight: 600 }}>{fmtMoney(b.totalCents)}</span>
</div>
))}
</div>
)}
</div>
)}
{invoiceList.length === 0 ? ( {invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}> <p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment. No invoices yet. Create one from a completed appointment.
-11
View File
@@ -1,11 +0,0 @@
CREATE TABLE "refunds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
"stripe_refund_id" text NOT NULL,
"idempotency_key" text UNIQUE,
"amount_cents" integer,
"created_at" timestamp NOT NULL DEFAULT NOW()
);
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
@@ -1,15 +0,0 @@
-- SMS opt-in fields for clients (idempotent)
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
-- Add channel column to reminder_logs with default 'email' (idempotent)
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
-- Drop old unique constraints if they exist (idempotent)
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
-- Add new unique constraint with channel
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
@@ -1,20 +0,0 @@
-- Migration: 0029_db_indexes_constraints.sql
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
-- Backfill NULL emails before setting NOT NULL
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
-- Add indexes on appointments table
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
-- Add index on pets table
CREATE INDEX idx_pets_client_id ON pets(client_id);
-- Add index on clients table
CREATE INDEX idx_clients_email ON clients(email);
-- Set NOT NULL on clients.email (after backfill)
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
-14
View File
@@ -190,20 +190,6 @@
"when": 1775568867192, "when": 1775568867192,
"tag": "0026_stripe_payment", "tag": "0026_stripe_payment",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1775655267192,
"tag": "0027_refunds",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
} }
] ]
} }
-4
View File
@@ -71,10 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
address: "1 Main St, Springfield, CA 90000", address: "1 Main St, Springfield, CA 90000",
notes: null, notes: null,
emailOptOut: false, emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
stripeCustomerId: null, stripeCustomerId: null,
status: "active", status: "active",
disabledAt: null, disabledAt: null,
+6 -40
View File
@@ -102,32 +102,22 @@ export const verification = pgTable("verification", {
// ─── Tables ─────────────────────────────────────────────────────────────────── // ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable( export const clients = pgTable("clients", {
"clients",
{
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(), name: text("name").notNull(),
email: text("email").notNull(), email: text("email"),
phone: text("phone"), phone: text("phone"),
address: text("address"), address: text("address"),
notes: text("notes"), notes: text("notes"),
emailOptOut: boolean("email_opt_out").notNull().default(false), emailOptOut: boolean("email_opt_out").notNull().default(false),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
smsConsentDate: timestamp("sms_consent_date"),
smsOptOutDate: timestamp("sms_opt_out_date"),
smsConsentText: text("sms_consent_text"),
stripeCustomerId: text("stripe_customer_id"), stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"), status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"), disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, });
(t) => [index("idx_clients_email").on(t.email)]
);
export const pets = pgTable( export const pets = pgTable("pets", {
"pets",
{
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id") clientId: uuid("client_id")
.notNull() .notNull()
@@ -148,9 +138,7 @@ export const pets = pgTable(
image: text("image"), image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, });
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const services = pgTable("services", { export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -312,28 +300,8 @@ export const invoiceTipSplits = pgTable(
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)] (t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
); );
// Refund records with idempotency key support
export const refunds = pgTable(
"refunds",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "restrict" }),
stripeRefundId: text("stripe_refund_id").notNull(),
idempotencyKey: text("idempotency_key").unique(),
amountCents: integer("amount_cents"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("idx_refunds_invoice_id").on(t.invoiceId),
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
]
);
// Tracks which reminder emails have been sent per appointment (prevents duplicates). // Tracks which reminder emails have been sent per appointment (prevents duplicates).
// reminder_type values: "confirmation", "24h", "2h" // reminder_type values: "confirmation", "24h", "2h"
// channel values: "email", "sms"
export const reminderLogs = pgTable( export const reminderLogs = pgTable(
"reminder_logs", "reminder_logs",
{ {
@@ -343,11 +311,9 @@ export const reminderLogs = pgTable(
.references(() => appointments.id, { onDelete: "cascade" }), .references(() => appointments.id, { onDelete: "cascade" }),
// "confirmation" | "24h" | "2h" // "confirmation" | "24h" | "2h"
reminderType: text("reminder_type").notNull(), reminderType: text("reminder_type").notNull(),
// "email" | "sms"
channel: text("channel").notNull().default("email"),
sentAt: timestamp("sent_at").notNull().defaultNow(), sentAt: timestamp("sent_at").notNull().defaultNow(),
}, },
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)] (t) => [unique().on(t.appointmentId, t.reminderType)]
); );
// ─── Impersonation ────────────────────────────────────────────────────────── // ─── Impersonation ──────────────────────────────────────────────────────────
-62
View File
@@ -398,8 +398,6 @@ async function seedKnownUsers() {
id: ADMIN_STAFF_ID, id: ADMIN_STAFF_ID,
name: adminName, name: adminName,
email: adminEmail, email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -426,7 +424,6 @@ async function seedKnownUsers() {
name: "UAT Super User", name: "UAT Super User",
email: "uat-super@groombook.dev", email: "uat-super@groombook.dev",
oidcSub: uatSuperOidcSub, oidcSub: uatSuperOidcSub,
userId: uatSuperOidcSub,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -453,7 +450,6 @@ async function seedKnownUsers() {
name: "UAT Staff Groomer", name: "UAT Staff Groomer",
email: "uat-groomer@groombook.dev", email: "uat-groomer@groombook.dev",
oidcSub: uatStaffOidcSub, oidcSub: uatStaffOidcSub,
userId: uatStaffOidcSub,
role: "groomer", role: "groomer",
isSuperUser: false, isSuperUser: false,
active: true, active: true,
@@ -462,37 +458,6 @@ async function seedKnownUsers() {
} }
} }
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
const [existingGroomer] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, email))
.limit(1);
if (existingGroomer) {
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff groomer '${name}' (${email})`);
}
}
// ── Services: idempotent upsert using name as unique key ───────────────────── // ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first. // UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services. // Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -647,8 +612,6 @@ async function seed() {
id: ADMIN_STAFF_ID, id: ADMIN_STAFF_ID,
name: adminName, name: adminName,
email: adminEmail, email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -660,31 +623,6 @@ async function seed() {
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
} }
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
await db.insert(schema.staff)
.values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
});
console.log(`✓ Upserted groomer '${name}' (${email})`);
}
// ── Services ── // ── Services ──
// Upsert services using name as unique key. With deterministic IDs in // Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is // servicesDef and TRUNCATE clearing downstream tables first, this is
+28
View File
@@ -153,10 +153,38 @@ export interface Invoice {
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
stripePaymentIntentId?: string | null;
stripeRefundId?: string | null;
paymentFailureReason?: string | null;
lineItems?: InvoiceLineItem[]; lineItems?: InvoiceLineItem[];
tipSplits?: InvoiceTipSplit[]; tipSplits?: InvoiceTipSplit[];
} }
export interface StripePaymentInfo {
paymentIntentId: string;
amountPaidCents: number;
status: string;
cardLast4: string | null;
cardBrand: string | null;
refundId: string | null;
refundStatus: string | null;
}
export interface PaymentMethodBreakdown {
paymentMethod: PaymentMethod;
count: number;
totalCents: number;
}
export interface PaymentStats {
revenueCents: number;
outstandingCents: number;
refundsCents: number;
revenueCount: number;
refundCount: number;
paymentMethodBreakdown: PaymentMethodBreakdown[];
}
// ─── Impersonation ────────────────────────────────────────────────────────── // ─── Impersonation ──────────────────────────────────────────────────────────
export type ImpersonationSessionStatus = "active" | "ended" | "expired"; export type ImpersonationSessionStatus = "active" | "ended" | "expired";
+53 -43
View File
@@ -43,9 +43,6 @@ importers:
stripe: stripe:
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15) version: 22.0.1(@types/node@22.19.15)
telnyx:
specifier: ^1.23.0
version: 1.27.0
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -180,7 +177,7 @@ importers:
version: 22.19.15 version: 22.19.15
drizzle-kit: drizzle-kit:
specifier: ^0.30.4 specifier: ^0.30.4
version: 0.30.4 version: 0.30.6
tsx: tsx:
specifier: ^4.19.0 specifier: ^4.19.0
version: 4.21.0 version: 4.21.0
@@ -1699,6 +1696,9 @@ packages:
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -2830,8 +2830,8 @@ packages:
dom-accessibility-api@0.6.3: dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
drizzle-kit@0.30.4: drizzle-kit@0.30.6:
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==} resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true hasBin: true
drizzle-orm@0.38.4: drizzle-orm@0.38.4:
@@ -2955,6 +2955,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
env-paths@3.0.0:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
es-abstract@1.24.1: es-abstract@1.24.1:
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3158,6 +3162,11 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
gel@2.2.0:
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
engines: {node: '>= 18.0.0'}
hasBin: true
generator-function@2.0.1: generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3425,6 +3434,10 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.5:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
istanbul-lib-coverage@3.2.2: istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3606,9 +3619,6 @@ packages:
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3841,10 +3851,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
randombytes@2.1.0: randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -4040,6 +4046,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.0: side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4178,10 +4188,6 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
telnyx@1.27.0:
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
engines: {node: ^6 || >=8}
temp-dir@2.0.0: temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4256,9 +4262,6 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -4348,10 +4351,6 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -4488,6 +4487,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
which@4.0.0:
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
why-is-node-running@2.3.0: why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6219,6 +6223,8 @@ snapshots:
'@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/semantic-conventions@1.40.0': {}
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -7414,12 +7420,13 @@ snapshots:
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
drizzle-kit@0.30.4: drizzle-kit@0.30.6:
dependencies: dependencies:
'@drizzle-team/brocli': 0.10.2 '@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5 '@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.19.12 esbuild: 0.19.12
esbuild-register: 3.6.0(esbuild@0.19.12) esbuild-register: 3.6.0(esbuild@0.19.12)
gel: 2.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7456,6 +7463,8 @@ snapshots:
entities@6.0.1: {} entities@6.0.1: {}
env-paths@3.0.0: {}
es-abstract@1.24.1: es-abstract@1.24.1:
dependencies: dependencies:
array-buffer-byte-length: 1.0.2 array-buffer-byte-length: 1.0.2
@@ -7817,6 +7826,17 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
gel@2.2.0:
dependencies:
'@petamoriken/float16': 3.9.3
debug: 4.4.3
env-paths: 3.0.0
semver: 7.7.4
shell-quote: 1.8.3
which: 4.0.0
transitivePeerDependencies:
- supports-color
generator-function@2.0.1: {} generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
@@ -8081,6 +8101,8 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {} istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1: istanbul-lib-report@3.0.1:
@@ -8249,8 +8271,6 @@ snapshots:
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.sortby@4.7.0: {} lodash.sortby@4.7.0: {}
@@ -8449,10 +8469,6 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qs@6.15.1:
dependencies:
side-channel: 1.1.0
randombytes@2.1.0: randombytes@2.1.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -8687,6 +8703,8 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
side-channel-list@1.0.0: side-channel-list@1.0.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -8840,14 +8858,6 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
telnyx@1.27.0:
dependencies:
lodash.isplainobject: 4.0.6
qs: 6.15.1
safe-buffer: 5.2.1
tweetnacl: 1.0.3
uuid: 9.0.1
temp-dir@2.0.0: {} temp-dir@2.0.0: {}
tempy@0.6.0: tempy@0.6.0:
@@ -8918,8 +8928,6 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tweetnacl@1.0.3: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -9016,8 +9024,6 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.1: {}
victory-vendor@37.3.6: victory-vendor@37.3.6:
dependencies: dependencies:
'@types/d3-array': 3.2.2 '@types/d3-array': 3.2.2
@@ -9195,6 +9201,10 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0: why-is-node-running@2.3.0:
dependencies: dependencies:
siginfo: 2.0.0 siginfo: 2.0.0