Compare commits

..

2 Commits

Author SHA1 Message Date
Flea Flicker 4594bd2307 fix(GRO-655): create corepack cache dir in builder stage
Prevents ENOENT crash in migrate and seed jobs.

Root cause: corepack tries to mkdir /home/node/.cache/node/corepack/v1
but the directory does not exist in the builder stage. This was a
regression in c438f57 where the cache directory was not pre-created.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 21:58:44 +00:00
Flea Flicker d8c0052b54 fix(GRO-634): implement auth & authorization security hardening (8 findings)
- Remove placeholder secret fallback, require BETTER_AUTH_SECRET when AUTH_DISABLED=true
- Fix TOCTOU race in setup: use INSERT...RETURNING for atomic confirmation token creation
- Fix confirmation token replay: atomic UPDATE with WHERE clause prevents double-use
- Add CSRF origin-check middleware for non-safe HTTP methods
- Validate OIDC discovery URL hostname matches configured issuer
- Use timing-safe comparison for iCal authentication tokens
- Add rate limiting (10 req/min per IP) on setup endpoints
- Fix RBAC error messages: correct inversion of privilege check
2026-04-14 17:08:02 +00:00
21 changed files with 187 additions and 674 deletions
-2
View File
@@ -7,5 +7,3 @@ apps/web/dist
apps/api/dist apps/api/dist
packages/db/dist packages/db/dist
packages/types/dist packages/types/dist
.turbo
screenshots/
+9 -24
View File
@@ -20,8 +20,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -44,8 +42,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -66,8 +62,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -107,8 +101,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -246,6 +238,7 @@ jobs:
echo "Deploying images tagged $TAG to groombook-dev..." echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image # Run migration with PR image
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f - cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1 apiVersion: batch/v1
@@ -310,8 +303,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -418,17 +409,11 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}" git push -u origin "chore/update-image-tags-${TAG}"
# Check if PR already exists for this branch # Create PR and merge immediately (no required checks on groombook/infra)
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true) PR_URL=$(gh pr create \
if [ -n "$EXISTING_PR" ]; then --repo groombook/infra \
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR" --base main \
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge --head "chore/update-image-tags-${TAG}" \
else --title "chore: deploy ${TAG} to dev" \
PR_URL=$(gh pr create \ --body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
--repo groombook/infra \ gh pr merge "$PR_URL" --merge
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
fi
-3
View File
@@ -35,9 +35,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000 EXPOSE 3000
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "apps/api/dist/index.js"] CMD ["node", "apps/api/dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database # Migrate stage — runs drizzle-kit migrate against the database
+1 -1
View File
@@ -142,8 +142,8 @@ describe("auth init", () => {
...originalEnv, ...originalEnv,
AUTH_DISABLED: "true", AUTH_DISABLED: "true",
NODE_ENV: "test", NODE_ENV: "test",
BETTER_AUTH_SECRET: "placeholder-for-test-only",
}; };
delete process.env.BETTER_AUTH_SECRET;
const { initAuth, getAuth } = await reimportAuth(); const { initAuth, getAuth } = await reimportAuth();
await expect(initAuth()).resolves.toBeUndefined(); await expect(initAuth()).resolves.toBeUndefined();
+31 -11
View File
@@ -31,11 +31,11 @@ const BASE_APPT = {
// ─── Shared mock DB state ───────────────────────────────────────────────────── // ─── Shared mock DB state ─────────────────────────────────────────────────────
let mockAppt: typeof BASE_APPT | null = BASE_APPT; let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
let lastUpdate: Record<string, unknown> = {}; let lastUpdate: Record<string, unknown> = {};
function resetMock() { function resetMock() {
mockAppt = { ...BASE_APPT }; mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
lastUpdate = {}; lastUpdate = {};
} }
@@ -55,19 +55,39 @@ vi.mock("@groombook/db", () => {
}), }),
}), }),
update: () => ({ update: () => ({
set: (vals: Record<string, unknown>) => ({ set: (vals: Record<string, unknown>) => {
where: () => { const setVals = vals;
lastUpdate = { ...vals }; return {
if (mockAppt) { where: () => {
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT; const preUpdate = mockAppt ? { ...mockAppt } : null;
} const preStatus = preUpdate?.confirmationStatus;
return { returning: () => (mockAppt ? [mockAppt] : []) }; const preStart = preUpdate?.startTime;
}, lastUpdate = { ...setVals };
}), const whereMatched =
preUpdate != null &&
preStatus === "pending" &&
preStart != null &&
preStart > new Date();
if (whereMatched && mockAppt) {
mockAppt = { ...mockAppt, ...setVals } as typeof BASE_APPT & { confirmationToken: string };
}
return {
returning: () => {
if (!preUpdate) return [];
if (preStatus !== "pending") return [];
if (preStart && preStart <= new Date()) return [];
return whereMatched && mockAppt ? [mockAppt] : [];
},
};
},
};
},
}), }),
}), }),
appointments, appointments,
eq: () => ({}), eq: () => ({}),
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
gt: () => ({}),
}; };
}); });
+2 -2
View File
@@ -362,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(/super user privileges required/i); expect(body.error).toMatch(/role 'receptionist' is not permitted/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 () => {
@@ -370,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(/super user privileges required/i); expect(body.error).toMatch(/role 'groomer' is not permitted/i);
}); });
it("allows a manager with multiple allowed roles", async () => { it("allows a manager with multiple allowed roles", async () => {
+17
View File
@@ -42,6 +42,23 @@ app.use(
}) })
); );
// CSRF protection for state-changing requests
app.use("/api/*", async (c, next) => {
const method = c.req.method;
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
await next();
return;
}
const origin = c.req.header("origin");
const trustedOrigin = process.env.CORS_ORIGIN ?? "http://localhost:5173";
if (origin && origin !== trustedOrigin) {
c.status(403);
c.json({ error: "CSRF validation failed: origin mismatch" });
return;
}
await next();
});
// Health check (no auth required) // Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" })); app.get("/health", (c) => c.json({ status: "ok" }));
+35 -11
View File
@@ -86,10 +86,15 @@ export async function initAuth(): Promise<void> {
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder // AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
// config so auth.handler exists (middleware bypasses it anyway) // config so auth.handler exists (middleware bypasses it anyway)
if (process.env.AUTH_DISABLED === "true") { if (process.env.AUTH_DISABLED === "true") {
if (!BETTER_AUTH_SECRET) {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET must be set when AUTH_DISABLED=true"
);
}
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 ?? "placeholder-secret-do-not-use-in-prod", secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: { rateLimit: {
enabled: true, enabled: true,
@@ -199,20 +204,36 @@ export async function initAuth(): Promise<void> {
return url; return url;
} }
}; };
const validateIssuerHost = (url: string, issuerUrl: string): boolean => {
try {
const discovered = new URL(url);
const expected = new URL(issuerUrl);
return discovered.hostname === expected.hostname;
} catch {
return false;
}
};
const authzUrl = discovery.authorization_endpoint; const authzUrl = discovery.authorization_endpoint;
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) {
oidcConfig = { const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
authorizationUrl: authzUrl, const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
tokenUrl: providerConfig.internalBaseUrl const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
? replaceHost(tokenUrl, providerConfig.internalBaseUrl) if (!validAuthz || !validToken || !validUserInfo) {
: tokenUrl, console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
userInfoUrl: providerConfig.internalBaseUrl } else {
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl) oidcConfig = {
: userInfoUrl, authorizationUrl: authzUrl,
}; tokenUrl: providerConfig.internalBaseUrl
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId); ? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
}
} else { } else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only"); console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
} }
@@ -287,6 +308,9 @@ export async function initAuth(): Promise<void> {
enabled: true, enabled: true,
maxAge: 5 * 60, // 5 minutes maxAge: 5 * 60, // 5 minutes
}, },
cookieAttributes: {
sameSite: "strict",
},
}, },
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
}); });
+3 -3
View File
@@ -149,9 +149,9 @@ export function requireRoleOrSuperUser(
} }
return c.json( return c.json(
{ {
error: staffRow.isSuperUser error: hasAllowedRole
? `Forbidden: role '${staffRow.role}' is not permitted` ? "Forbidden: super user privileges required"
: "Forbidden: super user privileges required", : `Forbidden: role '${staffRow.role}' is not permitted`,
}, },
403 403
); );
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services, services,
staff, staff,
} from "@groombook/db"; } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>(); export const appointmentGroupsRouter = new Hono();
// ─── Schemas ────────────────────────────────────────────────────────────────── // ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId"); const clientId = c.req.query("clientId");
const from = c.req.query("from"); const from = c.req.query("from");
const to = c.req.query("to"); const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)] ? [eq(appointmentGroups.clientId, clientId)]
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
})) }))
.filter((g) => !from || g.appointments.length > 0); .filter((g) => !from || g.appointments.length > 0);
if (isGroomer) {
return c.json(
result.filter((g) =>
g.appointments.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
)
);
}
return c.json(result); return c.json(result);
}); });
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => { appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select() .select()
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId, serviceId: appointments.serviceId,
serviceName: services.name, serviceName: services.name,
staffId: appointments.staffId, staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name, staffName: staff.name,
status: appointments.status, status: appointments.status,
startTime: appointments.startTime, startTime: appointments.startTime,
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id)) .where(eq(appointments.groupId, id))
.orderBy(appointments.startTime); .orderBy(appointments.startTime);
if (
isGroomer &&
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
const [client] = await db const [client] = await db
.select({ name: clients.name, email: clients.email }) .select({ name: clients.name, email: clients.email })
.from(clients) .from(clients)
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema), zValidator("json", createGroupSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const staffRow = c.get("staff");
if (staffRow?.role === "groomer") {
return c.json(
{ error: "Forbidden: groomers cannot create group bookings" },
403
);
}
const body = c.req.valid("json"); const body = c.req.valid("json");
const startTime = new Date(body.startTime); const startTime = new Date(body.startTime);
@@ -276,28 +244,6 @@ appointmentGroupsRouter.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 staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
const [updated] = await db const [updated] = await db
.update(appointmentGroups) .update(appointmentGroups)
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => { appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select({ id: appointmentGroups.id }) .select({ id: appointmentGroups.id })
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id)); .where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404); if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
await db await db
.update(appointments) .update(appointments)
.set({ status: "cancelled", updatedAt: new Date() }) .set({ status: "cancelled", updatedAt: new Date() })
+1 -52
View File
@@ -163,28 +163,6 @@ appointmentsRouter.post(
} }
} }
if (apptFields.batherStaffId) {
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (!recurrence) { if (!recurrence) {
// Single appointment // Single appointment
const [inserted] = await tx const [inserted] = await tx
@@ -420,8 +398,7 @@ appointmentsRouter.patch(
const needsConflictCheck = const needsConflictCheck =
updateFields.startTime !== undefined || updateFields.startTime !== undefined ||
updateFields.endTime !== undefined || updateFields.endTime !== undefined ||
updateFields.staffId !== undefined || updateFields.staffId !== undefined;
updateFields.batherStaffId !== undefined;
const update: Record<string, unknown> = { const update: Record<string, unknown> = {
...updateFields, ...updateFields,
@@ -457,11 +434,6 @@ appointmentsRouter.patch(
updateFields.staffId !== undefined updateFields.staffId !== undefined
? updateFields.staffId ? updateFields.staffId
: current.staffId; : current.staffId;
// Use provided batherStaffId (may be null to unassign); fall back to existing
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (end <= start) { if (end <= start) {
throw Object.assign(new Error("end before start"), { throw Object.assign(new Error("end before start"), {
@@ -489,29 +461,6 @@ appointmentsRouter.patch(
} }
} }
if (batherStaffId) {
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
)
)
.limit(1);
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const [updated] = await tx const [updated] = await tx
.update(appointments) .update(appointments)
.set(update) .set(update)
+38 -49
View File
@@ -255,39 +255,37 @@ bookRouter.get("/confirm/:token", async (c) => {
const token = c.req.param("token"); const token = c.req.param("token");
const db = getDb(); const db = getDb();
// Atomic: consume token and confirm in a single query to prevent replay.
// Only future appointments can be confirmed.
const [appt] = await db const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Idempotent confirm: if already confirmed, redirect to success
if (appt.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`);
}
// Reject if already cancelled
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
await db
.update(appointments) .update(appointments)
.set({ .set({
confirmationStatus: "confirmed", confirmationStatus: "confirmed",
confirmedAt: new Date(), confirmedAt: new Date(),
confirmationToken: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(appointments.id, appt.id)); .where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending"),
gt(appointments.startTime, new Date())
)
)
.returning();
if (!appt) {
// Check status for idempotency: already-confirmed → redirect to confirmed
const [existing] = await db
.select({ confirmationStatus: appointments.confirmationStatus })
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (existing?.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`);
}
return c.redirect(`${BASE_URL()}/booking/error`);
}
return c.redirect(`${BASE_URL()}/booking/confirmed`); return c.redirect(`${BASE_URL()}/booking/confirmed`);
}); });
@@ -299,29 +297,9 @@ bookRouter.get("/cancel/:token", async (c) => {
const token = c.req.param("token"); const token = c.req.param("token");
const db = getDb(); const db = getDb();
// Atomic: consume token and cancel in a single query to prevent replay.
// Only future appointments can be cancelled.
const [appt] = await db const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
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") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Single-use cancellation: nullify token after use
await db
.update(appointments) .update(appointments)
.set({ .set({
confirmationStatus: "cancelled", confirmationStatus: "cancelled",
@@ -329,7 +307,18 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null, confirmationToken: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(appointments.id, appt.id)); .where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending"),
gt(appointments.startTime, new Date())
)
)
.returning();
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
return c.redirect(`${BASE_URL()}/booking/cancelled`); return c.redirect(`${BASE_URL()}/booking/cancelled`);
}); });
+7 -2
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { randomBytes } from "node:crypto"; import { randomBytes, timingSafeEqual } from "node:crypto";
import { import {
and, and,
eq, eq,
@@ -84,7 +84,12 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId)) .where(eq(staff.id, staffId))
.limit(1); .limit(1);
if (!staffMember || staffMember.icalToken !== token) { if (
!staffMember ||
!staffMember.icalToken ||
staffMember.icalToken.length !== token.length ||
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
) {
return c.text("Unauthorized", 401); return c.text("Unauthorized", 401);
} }
+6 -93
View File
@@ -1,10 +1,9 @@
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 { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db"; import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>(); export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({ const createLogSchema = z.object({
petId: z.string().uuid(), petId: z.string().uuid(),
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb(); const db = getDb();
const petId = c.req.query("petId"); const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400); if (!petId) return c.json({ error: "petId is required" }, 400);
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
const rows = await db const rows = await db
.select() .select()
.from(groomingVisitLogs) .from(groomingVisitLogs)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema), zValidator("json", createLogSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json"); const { groomedAt, ...rest } = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
if (appointmentId) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.id, appointmentId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
} else {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
}
const [row] = await db const [row] = await db
.insert(groomingVisitLogs) .insert(groomingVisitLogs)
.values({ .values({
...rest, ...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(), groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
}) })
.returning(); .returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => { groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const [row] = await db
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [log] = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.limit(1);
if (!log) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, log.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
await db
.delete(groomingVisitLogs) .delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id)) .where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
-104
View File
@@ -4,7 +4,6 @@ import { z } from "zod/v3";
import { import {
and, and,
eq, eq,
gte,
getDb, getDb,
invoices, invoices,
invoiceLineItems, invoiceLineItems,
@@ -378,106 +377,3 @@ invoicesRouter.post(
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,
});
});
+3 -26
View File
@@ -286,10 +286,6 @@ reportsRouter.get("/clients", async (c) => {
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
const offset = (page - 1) * limit;
const churnRisk = await db const churnRisk = await db
.select({ .select({
clientId: clients.id, clientId: clients.id,
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
.having( .having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
) )
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`) .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
.limit(limit)
.offset(offset);
const [churnCountRow] = await db
.select({ total: sql<number>`count(*)::int` })
.from(
db
.select({ id: clients.id })
.from(clients)
.leftJoin(appointments, eq(appointments.clientId, clients.id))
.groupBy(clients.id)
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.as("churn_count")
);
const churnRiskTotal = churnCountRow?.total ?? 0;
return c.json({ return c.json({
from: from.toISOString(), from: from.toISOString(),
to: to.toISOString(), to: to.toISOString(),
newClients, newClients,
activeInPeriodCount: activeInPeriod.length, activeInPeriodCount: activeInPeriod.length,
churnRisk, churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal, churnRiskTotal: churnRisk.length,
page,
limit,
}); });
}); });
+29
View File
@@ -6,6 +6,25 @@ import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>(); export const setupRouter = new Hono<AppEnv>();
// Simple in-memory rate limiter: 10 req/min per IP for setup endpoints
const setupRateLimitMap = new Map<string, { count: number; resetAt: number }>();
const SETUP_RATE_LIMIT = 10;
const SETUP_RATE_WINDOW_MS = 60 * 1000;
function checkSetupRateLimit(ip: string): boolean {
const now = Date.now();
const entry = setupRateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
setupRateLimitMap.set(ip, { count: 1, resetAt: now + SETUP_RATE_WINDOW_MS });
return true;
}
if (entry.count >= SETUP_RATE_LIMIT) {
return false;
}
entry.count++;
return true;
}
// GET /api/setup/status — public (no auth), returns whether setup is needed // GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown // and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => { setupRouter.get("/status", async (c) => {
@@ -185,6 +204,11 @@ 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";
if (!checkSetupRateLimit(ip)) {
return c.json({ 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)
@@ -254,6 +278,11 @@ 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";
if (!checkSetupRateLimit(ip)) {
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)
-2
View File
@@ -20,5 +20,3 @@ FROM nginx:alpine AS runner
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
+2 -188
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -173,23 +173,6 @@ 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
@@ -288,31 +271,6 @@ 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;
@@ -372,18 +330,6 @@ 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 ── */}
@@ -501,101 +447,10 @@ 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", gap: "0.5rem" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
{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>
); );
} }
@@ -637,8 +492,6 @@ 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;
@@ -660,15 +513,6 @@ 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);
@@ -729,36 +573,6 @@ 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.
+1 -1
View File
@@ -567,7 +567,7 @@ async function seed() {
// ── Staff ── // ── Staff ──
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
); );
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
-28
View File
@@ -153,38 +153,10 @@ 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";