Compare commits

..

4 Commits

Author SHA1 Message Date
Paperclip 39e72a1441 fix(gro-527): update infra submodule to SEED_PROFILE wiring
Updates infra submodule to e8bd354 which wires SEED_PROFILE env var
into seed-job patches for dev/uat/prod overlays.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 15:22:49 +00:00
Flea Flicker 16fb887bbf feat(GRO-537): add UAT Super User and Staff Groomer to seed script
In seedKnownUsers(), add staff records for UAT Super User
(manager, superuser) and UAT Staff Groomer (groomer) with oidcSub
read from SEED_UAT_SUPER_OIDC_SUB and SEED_UAT_STAFF_OIDC_SUB
env vars. Only creates records when the env vars are present.
Idempotent: skips if email already exists.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 15:16:01 +00:00
Pawla Abdul c01c8d93d7 docs(GRO-530): Add seed strategy runbook
Documents seed system across environments:
- Environment profiles table (dev/UAT/demo data volumes)
- Seed script env vars (SEED_PROFILE, SEED_KNOWN_USERS_ONLY, etc.)
- How to re-seed each environment (kubectl commands)
- Authentik UAT user personas (references sealed secrets)
- OOBE flag behavior
- Dev-mode access (AUTH_DISABLED, X-Dev-User-Id header)

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 01:26:57 +00:00
Pawla Abdul e8c81bfccd Parameterize seed script with SEED_PROFILE env var
Implements GRO-526: Add SEED_PROFILE env var accepting dev/uat/demo values.

- dev profile: 4 staff (1 manager, 1 receptionist, 2 groomers), 100 clients,
  ~1000 invoices, appointments 7d back / 30d forward
- uat profile: 8 staff (1 manager, 1 receptionist, 3 groomers, 3 bathers),
  500 clients, ~4000 invoices, appointments 30d back / 90d forward
- demo profile: Same data volume as UAT

Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility.
Existing SEED_KNOWN_USERS_ONLY=true path unchanged.

All appointment dates are computed relative to NOW() at seed time.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 01:21:58 +00:00
98 changed files with 852 additions and 3174 deletions
-2
View File
@@ -7,5 +7,3 @@ apps/web/dist
apps/api/dist
packages/db/dist
packages/types/dist
.turbo
screenshots/
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
# database has data but the setup wizard would otherwise block access.
SKIP_OOBE=false
# ── API ───────────────────────────────────────────────────────────────────────
PORT=3000
CORS_ORIGIN=http://localhost:8080
+9 -24
View File
@@ -20,8 +20,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -44,8 +42,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -66,8 +62,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -107,8 +101,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -246,6 +238,7 @@ jobs:
echo "Deploying images tagged $TAG to groombook-dev..."
# 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
cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
@@ -310,8 +303,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -418,17 +409,11 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}"
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
else
PR_URL=$(gh pr create \
--repo groombook/infra \
--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
# Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \
--repo groombook/infra \
--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
-22
View File
@@ -14,29 +14,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Validate tag format
run: |
TAG="${{ inputs.tag }}"
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
exit 1
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in GHCR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ inputs.tag }}"
# Check that the API image exists — if API was pushed, web/migrate were too
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
exit 1
fi
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
-2
View File
@@ -62,8 +62,6 @@ jobs:
fi
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
+1 -5
View File
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/
COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \
@@ -35,9 +34,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
RUN pnpm install --frozen-lockfile --prod
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"]
# Migrate stage — runs drizzle-kit migrate against the database
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
CMD ["pnpm", "db:reset"]
CMD ["pnpm", "db:reset"]
-3
View File
@@ -22,9 +22,6 @@
"hono": "^4.6.17",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"zod": "^4.3.6"
},
"devDependencies": {
+3 -17
View File
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null;
function resetMock() {
selectRows = [];
appointmentRows = [];
insertedValues = [];
updatedValues = [];
deletedId = null;
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
{ 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 {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
from: () => makeChainable(selectRows),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
}),
}),
clients,
appointments,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
};
});
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie");
});
it("creates a client with name and email", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
});
it("rejects empty name", async () => {
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
}),
appointments,
eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
};
});
+2 -3
View File
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
}),
staff,
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");
expect(res.status).toBe(403);
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 () => {
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test");
expect(res.status).toBe(403);
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 () => {
-42
View File
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true);
});
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "true";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=1 also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "1";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=yes also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "yes";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
});
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+4 -49
View File
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
import { getAuth, initAuth } from "./lib/auth.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
@@ -28,31 +28,15 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono();
// 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(
"/api/*",
cors({
origin: (origin, ctx) => {
if (!origin) {
return ALLOWED_ORIGIN;
}
if (TRUSTED_ORIGINS.includes(origin)) {
return origin;
}
ctx.status(403);
return null;
},
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
credentials: true,
})
);
@@ -66,9 +50,6 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
@@ -111,11 +92,6 @@ app.get("/api/setup/status", async (c) => {
return c.json({ needsSetup: !superUser });
});
// Public auth providers endpoint — no auth required, tells frontend which login options are available
app.get("/api/auth/providers", async (c) => {
return c.json({ providers: getActiveProviders() });
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
@@ -124,13 +100,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => {
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
@@ -202,24 +172,9 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
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)
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;
+11 -119
View File
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
@@ -28,21 +27,6 @@ export function getAuthPromise() {
return authInitPromise;
}
/** Returns which OAuth/social providers are configured via env vars. */
export function getActiveProviders(): string[] {
const providers: string[] = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push("google");
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
providers.push("github");
}
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
providers.push("authentik");
}
return providers;
}
/**
* Re-initializes the Better-Auth instance after auth config changes.
*
@@ -89,14 +73,8 @@ export async function initAuth(): Promise<void> {
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET!,
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "memory",
},
plugins: [
genericOAuth({
config: [
@@ -174,63 +152,6 @@ export async function initAuth(): Promise<void> {
console.log("[auth] Using env var config (no DB config found)");
}
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 issuerUrlObj = new URL(providerConfig.issuerUrl);
const issuerHostname = issuerUrlObj.hostname;
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
};
const replaceHost = (url: string, newHost: string) => {
try {
const parsed = new URL(url);
const newParsed = new URL(newHost);
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
};
const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint;
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 = {
authorizationUrl: authzUrl,
tokenUrl: providerConfig.internalBaseUrl
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
} else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
}
} else {
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
}
} catch (err) {
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
@@ -238,28 +159,6 @@ export async function initAuth(): Promise<void> {
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "memory",
},
account: {
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
enabled: true,
emailVerification: {
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
await sendEmail({
to: user.email,
subject: "Verify your GroomBook email",
text: `Click the link to verify your email: ${url}`,
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
});
},
},
},
plugins: [
genericOAuth({
config: [
@@ -267,27 +166,20 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId,
clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr,
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
...(providerConfig.internalBaseUrl
? {
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
}
: {
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
}),
scopes: providerConfig.scopes.split(" ").filter(Boolean),
},
],
}),
],
socialProviders: {
...(hasGoogle ? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
} : {}),
...(hasGitHub ? {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
} : {}),
},
],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
+2 -8
View File
@@ -23,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return;
}
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
const session = await getAuth().api.getSession({
headers: c.req.raw.headers,
});
-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();
};
+29 -27
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db";
import { and, eq, getDb, isNull, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -89,31 +89,33 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
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;
if (!fallbackRow) {
// Auto-link: staff record exists with matching email but no userId — link it now
if (jwt.email) {
const [linkedStaff] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
if (linkedStaff) {
await db
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, linkedStaff.id));
console.log(
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
);
c.set("staff", linkedStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
c.set("staff", fallbackRow);
await next();
};
/**
@@ -166,9 +168,9 @@ export function requireRoleOrSuperUser(
}
return c.json(
{
error: hasAllowedRole
? "Forbidden: super user privileges required"
: `Forbidden: role '${staffRow.role}' is not permitted`,
error: staffRow.isSuperUser
? `Forbidden: role '${staffRow.role}' is not permitted`
: "Forbidden: super user privileges required",
},
403
);
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services,
staff,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>();
export const appointmentGroupsRouter = new Hono();
// ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const from = c.req.query("from");
const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)]
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
}))
.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);
});
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select()
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId,
serviceName: services.name,
staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name,
status: appointments.status,
startTime: appointments.startTime,
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id))
.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
.select({ name: clients.name, email: clients.email })
.from(clients)
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema),
async (c) => {
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 startTime = new Date(body.startTime);
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
const db = getDb();
const id = c.req.param("id");
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
.update(appointmentGroups)
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.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);
}
}
await db
.update(appointments)
.set({ status: "cancelled", updatedAt: new Date() })
+43 -249
View File
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.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>();
const createAppointmentSchema = z.object({
@@ -62,10 +41,6 @@ const createAppointmentSchema = z.object({
frequencyWeeks: z.number().int().min(1).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(),
});
@@ -188,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) {
// Single appointment
const [inserted] = await tx
@@ -233,54 +186,11 @@ appointmentsRouter.post(
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
let first: typeof appointments.$inferSelect | undefined;
const conflictingInstances: number[] = [];
for (let i = 0; i < recurrence.count; i++) {
const instanceStart = new Date(start.getTime() + i * intervalMs);
const instanceEnd = new Date(
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
.insert(appointments)
.values({
@@ -291,19 +201,9 @@ appointmentsRouter.post(
seriesIndex: i,
})
.returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
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");
return first;
});
@@ -321,12 +221,9 @@ appointmentsRouter.post(
}
// Send confirmation email (fire-and-forget — never fails the request)
withRetry(
() => sendConfirmationEmail(db, firstRow),
2,
1000,
`Failed to send confirmation email for appointment ${firstRow.id}`
);
sendConfirmationEmail(db, firstRow).catch((err) => {
console.error("[appointments] Failed to send confirmation email:", err);
});
return c.json(firstRow, 201);
}
@@ -338,35 +235,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect
): Promise<void> {
const [row] = await db
.select({
clientName: clients.name,
clientEmail: clients.email,
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))
const [client] = await db
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!client || !client.email || client.emailOptOut) return;
if (!clientEmail || clientEmailOptOut) return;
if (!petName || !serviceName) return;
const [pet] = await db
.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(
buildConfirmationEmail(clientEmail, {
clientName,
petName,
serviceName,
groomerName: groomerName ?? null,
buildConfirmationEmail(client.email, {
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
})
);
@@ -446,76 +352,6 @@ appointmentsRouter.patch(
let firstUpdated: typeof appointments.$inferSelect | undefined;
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> = {
updatedAt: new Date(),
};
@@ -551,13 +387,6 @@ appointmentsRouter.patch(
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
if (statusCode === 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;
}
@@ -569,8 +398,7 @@ appointmentsRouter.patch(
const needsConflictCheck =
updateFields.startTime !== undefined ||
updateFields.endTime !== undefined ||
updateFields.staffId !== undefined ||
updateFields.batherStaffId !== undefined;
updateFields.staffId !== undefined;
const update: Record<string, unknown> = {
...updateFields,
@@ -606,11 +434,6 @@ appointmentsRouter.patch(
updateFields.staffId !== undefined
? updateFields.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) {
throw Object.assign(new Error("end before start"), {
@@ -638,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
.update(appointments)
.set(update)
@@ -735,12 +535,9 @@ appointmentsRouter.delete("/:id", async (c) => {
const apptDate = current.startTime.toISOString().slice(0, 10);
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
return c.json({ ok: true });
}
@@ -763,12 +560,9 @@ appointmentsRouter.delete("/:id", async (c) => {
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
return c.json({ ok: true });
});
+12 -28
View File
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime().refine(
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
startTime: z.string().datetime(),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
@@ -268,36 +265,29 @@ bookRouter.get("/confirm/:token", async (c) => {
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`);
}
const updated = await db
await db
.update(appointments)
.set({
confirmationStatus: "confirmed",
confirmedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending")
)
)
.returning();
if (updated.length === 0) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/confirmed`);
});
@@ -319,15 +309,19 @@ bookRouter.get("/cancel/:token", async (c) => {
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`);
}
const updated = await db
// Single-use cancellation: nullify token after use
await db
.update(appointments)
.set({
confirmationStatus: "cancelled",
@@ -335,17 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null,
updatedAt: new Date(),
})
.where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending")
)
)
.returning();
if (updated.length === 0) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/cancelled`);
});
+2 -13
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { randomBytes } from "node:crypto";
import {
and,
eq,
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId))
.limit(1);
if (!staffMember || !staffMember.icalToken) {
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)
) {
if (!staffMember || staffMember.icalToken !== token) {
return c.text("Unauthorized", 401);
}
+3 -27
View File
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
address: z.string().max(500).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)
const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(),
smsOptOut: z.boolean().optional(),
});
clientsRouter.patch(
@@ -110,19 +107,13 @@ clientsRouter.patch(
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
// When disabling, set disabledAt; when re-enabling, clear it
if (body.status === "disabled") {
setValues.disabledAt = now;
} else if (body.status === "active") {
setValues.disabledAt = null;
}
if (body.smsOptOut === true) {
setValues.smsOptIn = false;
setValues.smsOptOutDate = now;
delete setValues.smsOptOut;
}
delete setValues.smsOptOut;
const [row] = await db
.update(clients)
.set(setValues)
@@ -144,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
}
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
.delete(clients)
.where(eq(clients.id, clientId))
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
+6 -93
View File
@@ -1,10 +1,9 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
export const groomingLogsRouter = new Hono<AppEnv>();
export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({
petId: z.string().uuid(),
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb();
const petId = c.req.query("petId");
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
.select()
.from(groomingVisitLogs)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
const { groomedAt, petId, appointmentId, ...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 { groomedAt, ...rest } = c.req.valid("json");
const [row] = await db
.insert(groomingVisitLogs)
.values({
...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
})
.returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
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
const [row] = await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+55 -137
View File
@@ -8,15 +8,13 @@ import {
invoices,
invoiceLineItems,
invoiceTipSplits,
refunds,
appointments,
services,
clients,
sql,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
export const invoicesRouter = new Hono();
const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(),
@@ -45,61 +43,53 @@ const updateInvoiceSchema = z.object({
});
// List invoices
const listInvoicesQuerySchema = z.object({
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("/", async (c) => {
const db = getDb();
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 = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
});
invoicesRouter.get(
"/",
zValidator("query", listInvoicesQuerySchema),
async (c) => {
const db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}
);
// Get single invoice with line items and tip splits
invoicesRouter.get("/:id", async (c) => {
const db = getDb();
@@ -126,8 +116,8 @@ const tipSplitSchema = z.object({
})
).min(1).refine(
(splits) => {
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
return totalBps === 10000;
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
return Math.abs(total - 100) < 0.01;
},
{ message: "Split percentages must sum to 100" }
),
@@ -171,13 +161,12 @@ invoicesRouter.post(
}
});
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
const [lineItems, tipSplits] = await Promise.all([
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
const splits = await db
.select()
.from(invoiceTipSplits)
.where(eq(invoiceTipSplits.invoiceId, id));
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
return c.json(splits, 201);
}
);
@@ -302,13 +291,6 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
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
invoicesRouter.patch(
"/:id",
@@ -324,14 +306,8 @@ invoicesRouter.patch(
.where(eq(invoices.id, id));
if (!current) return c.json({ error: "Not found" }, 404);
if (body.status !== undefined) {
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
if (!allowed.includes(body.status)) {
return c.json(
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
422
);
}
if (current.status === "void") {
return c.json({ error: "Cannot modify a voided invoice" }, 422);
}
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
@@ -362,61 +338,3 @@ invoicesRouter.patch(
return c.json({ ...updated, lineItems });
}
);
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
idempotencyKey: z.string().max(255).optional(),
});
invoicesRouter.post(
"/:id/refund",
zValidator("json", refundSchema),
async (c) => {
const db = getDb();
const staff = c.get("staff");
if (!staff) return c.json({ error: "Forbidden" }, 403);
if (staff.role !== "manager" && !staff.isSuperUser) {
return c.json({ error: "Manager role required" }, 403);
}
const id = c.req.param("id");
const body = c.req.valid("json");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
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);
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 });
});
}
);
+111 -125
View File
@@ -1,22 +1,33 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
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 { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
import type { AppEnv } from "../middleware/rbac.js";
export const portalRouter = new Hono<PortalEnv>();
export const portalRouter = new Hono<AppEnv>();
// Apply middleware to all portal routes
portalRouter.use("/*", validatePortalSession, portalAudit);
// ─── Session helper ───────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => {
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);
if (!client) return c.json({ error: "Not found" }, 404);
@@ -24,12 +35,6 @@ portalRouter.get("/me", async (c) => {
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
});
portalRouter.get("/config", async (c) => {
return c.json({
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
});
});
portalRouter.get("/services", async (c) => {
const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true));
@@ -38,7 +43,9 @@ portalRouter.get("/services", async (c) => {
portalRouter.get("/appointments", async (c) => {
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 allAppts = await db
@@ -88,7 +95,9 @@ portalRouter.get("/appointments", async (c) => {
portalRouter.get("/pets", async (c) => {
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));
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 +105,9 @@ portalRouter.get("/pets", async (c) => {
portalRouter.get("/invoices", async (c) => {
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 invoiceIds = clientInvoices.map(i => i.id);
@@ -112,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id,
status: inv.status,
totalCents: inv.totalCents,
date: inv.createdAt,
createdAt: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
})));
});
@@ -131,7 +142,12 @@ portalRouter.patch(
const db = getDb();
const id = c.req.param("id");
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
.select()
@@ -174,7 +190,12 @@ portalRouter.patch(
portalRouter.post("/appointments/:id/confirm", async (c) => {
const db = getDb();
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
.select()
@@ -223,7 +244,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
portalRouter.post("/appointments/:id/cancel", async (c) => {
const db = getDb();
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
.select()
@@ -287,7 +313,28 @@ portalRouter.post(
async (c) => {
const db = getDb();
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
.insert(waitlistEntries)
@@ -311,7 +358,26 @@ portalRouter.patch(
const db = getDb();
const id = c.req.param("id");
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
.select()
@@ -320,7 +386,7 @@ portalRouter.patch(
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) {
if (existing.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -342,7 +408,26 @@ portalRouter.patch(
portalRouter.delete("/waitlist/:id", async (c) => {
const db = getDb();
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
.select()
@@ -351,7 +436,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.limit(1);
if (!entry) return c.json({ error: "Not found" }, 404);
if (entry.clientId !== clientId) {
if (entry.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -363,105 +448,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
return c.json({ ok: true });
});
// ─── Payment routes ───────────────────────────────────────────────────────────
import {
createPaymentIntent,
listPaymentMethods,
detachPaymentMethod,
createSetupIntent,
getOrCreateStripeCustomer,
getStripeClient,
} from "../services/payment.js";
const payMultipleSchema = z.object({
invoiceIds: z.array(z.string().uuid()).min(1),
});
portalRouter.post(
"/invoices/pay-multiple",
zValidator("json", payMultipleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const invoiceRows = await db
.select()
.from(invoices)
.where(inArray(invoices.id, body.invoiceIds));
if (invoiceRows.length !== body.invoiceIds.length) {
return c.json({ error: "One or more invoices not found" }, 404);
}
for (const inv of invoiceRows) {
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
if (inv.status === "draft" || inv.status === "void") {
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
}
if (inv.status === "paid") {
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
}
}
const firstInvoice = invoiceRows[0];
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
if (!allSameClient) {
return c.json({ error: "All invoices must belong to the same client" }, 422);
}
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const result = await createPaymentIntent(body.invoiceIds, clientId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
}
);
portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
return c.json(methods);
});
portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId);
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
const result = await createSetupIntent(customerId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
});
portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId");
const paymentMethodId = c.req.param("id");
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
const stripe = getStripeClient();
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
return c.json({ error: "Payment method not found" }, 404);
}
const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
return c.json({ ok: true });
});
// ─── Dev-mode session creation ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client
// without requiring manager auth. Only available when AUTH_DISABLED=true.
+3 -26
View File
@@ -286,10 +286,6 @@ reportsRouter.get("/clients", async (c) => {
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
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
.select({
clientId: clients.id,
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.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;
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
return c.json({
from: from.toISOString(),
to: to.toISOString(),
newClients,
activeInPeriodCount: activeInPeriod.length,
churnRisk,
churnRiskTotal,
page,
limit,
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal: churnRisk.length,
});
});
+1 -1
View File
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480),
durationMinutes: z.number().int().positive(),
active: z.boolean().default(true),
});
+37 -95
View File
@@ -4,40 +4,11 @@ import { z } from "zod/v3";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
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>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => {
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
if (skipOobe) {
return c.json({
needsSetup: false,
showAuthProviderStep: false,
authConfigExists: false,
authEnvVarsSet: false,
skipped: true,
});
}
const db = getDb();
// Check if any super user exists
@@ -203,74 +174,52 @@ const authProviderTestSchema = z.object({
* After setup completes, this endpoint permanently returns 403.
*/
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();
let row: typeof authProviderConfig.$inferSelect;
try {
row = await db.transaction(async (tx) => {
const [superUser] = await tx
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
// Guard: only allow during fresh install (no super user yet)
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
if (superUser) {
throw Object.assign(new Error("setup-complete"), { code: 403 });
}
if (superUser) {
// 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
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
const [existingConfig] = await db
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
if (existingConfig) {
throw Object.assign(new Error("config-exists"), { code: 409 });
}
if (existingConfig) {
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());
const encryptedSecret = encryptSecret(body.clientSecret);
// Encrypt clientSecret before storing
const encryptedSecret = encryptSecret(body.clientSecret);
const [configRow] = await tx
.insert(authProviderConfig)
.values({
providerId: body.providerId,
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
enabled: true,
})
.returning();
const [row] = await db
.insert(authProviderConfig)
.values({
providerId: body.providerId,
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
enabled: true,
})
.returning();
if (!configRow) {
throw Object.assign(new Error("insert-failed"), { code: 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;
if (!row) {
return c.json({ error: "Failed to save auth provider configuration." }, 500);
}
return c.json({
@@ -294,13 +243,6 @@ setupRouter.post("/auth-provider", async (c) => {
* Only available when needsSetup is true (no super user = fresh install).
*/
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();
// Guard: only allow during fresh install (no super user yet)
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff");
return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row);
});
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
const db = getDb();
const targetId = c.req.param("id");
const body = c.req.valid("json");
const currentStaff = c.get("staff");
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
}
const [existing] = await db
.select()
.from(staff)
.where(eq(staff.id, targetId))
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
const [updated] = await db
.update(staff)
.set({ userId: body.userId, updatedAt: new Date() })
.where(eq(staff.id, targetId))
.returning();
return c.json(updated);
});
staffRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
-119
View File
@@ -1,119 +0,0 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { z } from "zod/v3";
import { eq, getDb, invoices } from "@groombook/db";
import { getStripeClient } from "../services/payment.js";
export const webhooksRouter = new Hono();
webhooksRouter.post("/stripe", async (c) => {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return c.json({ error: "Webhook secret not configured" }, 503);
}
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "Missing signature" }, 401);
}
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
const stripe = getStripeClient();
if (!stripe) {
return c.json({ error: "Stripe not configured" }, 503);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return c.json({ error: message }, 401);
}
const db = getDb();
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
const [inv] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceIdTrimmed))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
await db
.update(invoices)
.set({
status: "paid",
paymentMethod: "card",
paidAt: new Date(),
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "payment_intent.payment_failed") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "charge.refunded") {
const charge = event.data.object as Stripe.Charge;
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
const [inv] = await db
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
.limit(1);
if (inv) {
const refundId =
typeof charge.refunded === "boolean" && charge.refunded
? `ch_${charge.id}_refund`
: null;
await db
.update(invoices)
.set({
status: "void",
stripeRefundId: refundId,
updatedAt: new Date(),
})
.where(eq(invoices.id, inv.id));
}
}
} else if (event.type === "charge.dispute.created") {
const dispute = event.data.object as Stripe.Dispute;
console.error(
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
);
}
return c.json({ received: true });
});
-164
View File
@@ -1,164 +0,0 @@
import Stripe from "stripe";
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
let _stripe: Stripe | null | undefined;
export function getStripeClient(): Stripe | null {
if (_stripe === undefined) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) return null;
_stripe = new Stripe(secretKey);
}
return _stripe;
}
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return null;
if (client.stripeCustomerId) return client.stripeCustomerId;
const customer = await stripe.customers.create({
metadata: { groombook_client_id: clientId },
});
await db
.update(clients)
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
.where(eq(clients.id, clientId));
return customer.id;
}
export async function createPaymentIntent(
invoiceIdOrIds: string | string[],
clientId: string
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
const firstInvoiceId = invoiceIds[0];
if (!firstInvoiceId) return null;
const invoiceRows = await db
.select()
.from(invoices)
.where(eq(invoices.id, firstInvoiceId));
const [invoice] = invoiceRows;
if (!invoice) return null;
let totalCents = invoice.totalCents;
if (invoiceIds.length > 1) {
const allInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(inArray(invoices.id, invoiceIds));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
}
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return null;
const paymentIntent = await stripe.paymentIntents.create({
amount: totalCents,
currency: "usd",
customer: stripeCustomerId,
metadata: {
groombook_invoice_ids: invoiceIds.join(","),
groombook_client_id: clientId,
},
automatic_payment_methods: { enabled: true },
});
for (const invId of invoiceIds) {
await db
.update(invoices)
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
.where(eq(invoices.id, invId));
}
const clientSecret = paymentIntent.client_secret;
if (!clientSecret) return null;
return { clientSecret, paymentIntentId: paymentIntent.id };
}
export async function processRefund(
invoiceId: string,
amountCents?: number
): Promise<{ refundId: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
if (!invoice?.stripePaymentIntentId) return null;
const refund = await stripe.refunds.create({
payment_intent: invoice.stripePaymentIntentId,
amount: amountCents,
});
await db
.update(invoices)
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
.where(eq(invoices.id, invoiceId));
return { refundId: refund.id };
}
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return null;
const methods = await stripe.paymentMethods.list({
customer: stripeCustomerId,
type: "card",
});
return methods.data;
}
export async function attachPaymentMethod(
clientId: string,
paymentMethodId: string
): Promise<boolean> {
const stripe = getStripeClient();
if (!stripe) return false;
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return false;
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
return true;
}
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
const stripe = getStripeClient();
if (!stripe) return false;
await stripe.paymentMethods.detach(paymentMethodId);
return true;
}
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
payment_method_types: ["card"],
});
return { clientSecret: setupIntent.client_secret! };
}
+38 -82
View File
@@ -12,16 +12,14 @@ import {
services,
staff,
reminderLogs,
session,
} from "@groombook/db";
import {
buildReminderEmail,
sendEmail,
} 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 }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -31,14 +29,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> {
const db = getDb();
const now = new Date();
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 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
.select({
id: appointments.id,
@@ -60,38 +64,23 @@ export async function runReminderCheck(): Promise<void> {
);
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 })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
eq(reminderLogs.reminderType, window.label)
)
)
.limit(1);
const [smsLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
if (existing.length > 0) continue; // already sent
// Fetch related records for the email
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
@@ -122,6 +111,8 @@ export async function runReminderCheck(): Promise<void> {
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;
if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex");
@@ -131,74 +122,39 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id));
}
if (!emailLog) {
const sent = await sendEmail(
buildReminderEmail(
client.email,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
confirmationToken
)
);
const sent = await sendEmail(
buildReminderEmail(
client.email,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
confirmationToken
)
);
if (sent) {
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
.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);
}
if (sent) {
// Record send — ignore conflicts (race condition between instances)
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label })
.onConflictDoNothing();
}
}
}
}
// Starts the cron scheduler. Call once at server startup.
export function startReminderScheduler(): void {
// Run every minute
cron.schedule("* * * * *", () => {
runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", err);
});
runSessionCleanup().catch((err) => {
console.error("[reminders] Error during session cleanup:", err);
});
});
console.log("[reminders] Reminder scheduler started");
}
export async function runSessionCleanup(): Promise<void> {
const db = getDb();
const now = new Date();
await db
.delete(session)
.where(lt(session.expiresAt, now));
}
-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>;
};
}
}
+1 -5
View File
@@ -44,10 +44,7 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
// Appointments, clients, services, staff, book, etc.
// Appointments, clients, services, staff, invoices, book, etc.
return route.fulfill({ json: [] });
});
});
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
});
-2
View File
@@ -20,5 +20,3 @@ FROM nginx:alpine AS runner
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
-12
View File
@@ -3,22 +3,10 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
# Proxy API calls to the API service
+1 -3
View File
@@ -14,10 +14,8 @@
},
"dependencies": {
"@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"better-auth": "^1.0.0",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C853FAECD363909C4A0</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96CFC84D7A9333708F278</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D48D7892E37386B9ACB</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C25663D703833F23607</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D89851C843332073968</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C9C5A03D33730C61AD8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BEB91911B30317E3BE8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BFB7B92D33535D6D90D</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96B8BDF4B473630A2E120</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D78BFFCAD343037C27C</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

+54 -176
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
@@ -18,37 +18,16 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn, signOut } from "./lib/auth-client.js";
import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/providers")
.then((r) => r.json())
.then((data) => setProviders(data.providers ?? []))
.catch(() => setProviders([]));
const params = new URLSearchParams(window.location.search);
const authError = params.get("error");
if (authError) setError(authError.replace(/_/g, " "));
}, []);
const handleSocialLogin = async (provider: string) => {
const handleLogin = async () => {
setIsLoading(true);
setError(null);
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
};
const isGoogle = providers.includes("google");
const isGitHub = providers.includes("github");
const isAuthentik = providers.includes("authentik");
return (
<div
style={{
@@ -74,94 +53,23 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue
</p>
{error && (
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
{error}
</div>
)}
{isGoogle && (
<button
onClick={() => handleSocialLogin("google")}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#1a202c",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
marginBottom: "0.5rem",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</button>
)}
{isGitHub && (
<button
onClick={() => handleSocialLogin("github")}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#24292f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
marginBottom: isAuthentik ? "0.5rem" : 0,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Sign in with GitHub
</button>
)}
{isAuthentik && (
<button
onClick={() => handleSocialLogin("authentik")}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "none",
background: "#4f8a6f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
}}
>
{isLoading ? "Redirecting…" : "Sign in with SSO"}
</button>
)}
<button
onClick={handleLogin}
disabled={isLoading}
style={{
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "none",
background: "#4f8a6f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
}}
>
{isLoading ? "Redirecting…" : "Sign in with SSO"}
</button>
</div>
</div>
);
@@ -181,7 +89,6 @@ const NAV_LINKS = [
function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +117,6 @@ function AdminLayout() {
alignItems: "center",
gap: 8,
marginRight: "1.25rem",
flexShrink: 0,
}}>
{logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +130,45 @@ function AdminLayout() {
</strong>
</div>
<GlobalSearch />
<div style={{
display: "flex",
overflowX: "auto",
flex: 1,
minWidth: 0,
gap: "0.25rem",
}}>
<Link
to="/admin/book"
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
flexShrink: 0,
}}
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
flexShrink: 0,
}}
>
{label}
</Link>
);
})}
</div>
<button
onClick={async () => {
await signOut();
navigate("/login");
}}
<Link
to="/admin/book"
style={{
flexShrink: 0,
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#4b5563",
textDecoration: "none",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}}
>
Logout
</button>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
}}
>
{label}
</Link>
);
})}
</nav>
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession, changePassword } = authClient;
export const { signIn, signOut, useSession } = authClient;
+1
View File
@@ -226,6 +226,7 @@ export function CustomerPortal() {
)}
{showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props {
sessionId: string | null;
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword;
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
if (readOnly) {
return (
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
);
}
async function handleSubmit() {
function handleSubmit() {
if (!canSubmit) return;
if (newPassword !== confirmPassword) {
setError("Passwords do not match.");
return;
}
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null);
setLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (authClient as any).changePassword({
currentPassword,
newPassword,
});
if (result.error) {
setError(result.error.message ?? "Failed to change password.");
} else {
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setTimeout(() => setSuccess(false), 4000);
}
} catch {
setError("An unexpected error occurred.");
} finally {
setLoading(false);
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
return (
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Updating..." : "Update Password"}
Update Password
</button>
</div>
</div>
+94 -183
View File
@@ -1,6 +1,4 @@
import { useState, useEffect } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
interface Invoice {
@@ -12,28 +10,31 @@ interface Invoice {
}
interface PaymentMethod {
id: string;
brand: string;
last4: string;
expiryMonth: number;
expiryYear: number;
}
interface Package {
name: string;
remaining: number;
}
interface BillingPaymentsProps {
sessionId: string | null;
readOnly: boolean;
}
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [publishableKey, setPublishableKey] = useState<string>("");
useEffect(() => {
async function fetchData() {
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
}
try {
const [configRes, invoicesRes, methodsRes] = await Promise.all([
fetch("/api/portal/config", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
fetch("/api/portal/invoices", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
fetch("/api/portal/payment-methods", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
]);
const response = await fetch("/api/portal/invoices", {
headers: {
"X-Impersonation-Session-Id": sessionId,
},
});
if (!configRes.ok) throw new Error("Failed to fetch config");
const configData = await configRes.json();
setPublishableKey(configData.stripePublishableKey ?? "");
const invoicesData = await invoicesRes.json();
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
if (methodsRes.ok) {
const methodsData = await methodsRes.json();
setPaymentMethods(
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
id: m.id,
brand: m.card?.brand ?? "unknown",
last4: m.card?.last4 ?? "****",
expiryMonth: m.card?.exp_month ?? 0,
expiryYear: m.card?.exp_year ?? 0,
}))
);
if (!response.ok) {
throw new Error("Failed to fetch invoices");
}
const data = await response.json();
setInvoices(Array.isArray(data) ? data : data.invoices || []);
setPaymentMethods(data.paymentMethods || []);
setPackages(data.packages || []);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
fetchData();
}, [sessionId]);
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
const formatCents = (cents: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
};
const pending = invoices.filter((i) => i.status === "pending");
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
</div>
</div>
);
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalPending > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
</p>
</div>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
))}
</div>
{/* Invoices */}
{tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
@@ -161,7 +152,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<th className="px-5 py-3 font-medium">Description</th>
<th className="px-5 py-3 font-medium">Amount</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium" />
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
@@ -169,7 +160,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
month: "short",
day: "numeric",
year: "numeric",
})}
</td>
<td className="px-5 py-3 text-stone-600">
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Payment Methods */}
{tab === "payment" && (
<div className="space-y-4">
{paymentMethods.length === 0 ? (
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<div className="space-y-3">
{paymentMethods.map((method) => (
<div
key={method.id}
key={`${method.brand}-${method.last4}`}
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
>
<div className="flex items-center gap-3">
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</span>
</div>
{!readOnly && (
<button
onClick={async () => {
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
});
if (res.ok) {
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
}
}}
className="text-sm text-blue-600 hover:underline"
>
<button className="text-sm text-blue-600 hover:underline">
Remove
</button>
)}
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Autopay */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
<div>
<p className="text-sm font-medium text-stone-800">Autopay</p>
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
<p className="text-xs text-stone-500">
Automatically charge after each appointment
</p>
</div>
</div>
{!readOnly ? (
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Packages */}
{tab === "packages" && (
<div className="space-y-4">
{packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p>
) : (
packages.map((pkg, index) => (
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div
key={index}
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
>
<div className="flex items-center justify-between">
<span className="font-medium text-stone-800">{pkg.name}</span>
<span className="text-stone-600">{pkg.remaining} remaining</span>
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{showPaymentModal && publishableKey && (
<PaymentModalWrapper
key={Date.now()}
sessionId={sessionId ?? ""}
publishableKey={publishableKey}
{/* Payment Modal */}
{showPaymentModal && (
<PaymentModal
pending={pending}
totalPending={totalPending}
onClose={() => setShowPaymentModal(false)}
onSuccess={() => {
setInvoices((prev) =>
prev.map((inv) =>
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
)
);
setShowPaymentModal(false);
}}
/>
)}
</div>
);
}
interface PaymentModalWrapperProps {
sessionId: string;
publishableKey: string;
function PaymentModal({
pending,
totalPending: _totalPending,
onClose,
}: {
pending: Invoice[];
totalPending: number;
onClose: () => void;
onSuccess: () => void;
}
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
const [stripePromise] = useState(() =>
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
}) {
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
new Set(pending.map((i) => i.id))
);
return (
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
</Elements>
);
}
interface PaymentModalProps {
sessionId: string;
pending: Invoice[];
onClose: () => void;
onSuccess: () => void;
}
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
const stripe = useStripe();
const elements = useElements();
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
const [saveCard, setSaveCard] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
const toggleInvoice = (id: string) => {
const next = new Set(selectedInvoices);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedInvoices(next);
};
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
const handlePay = async () => {
if (!stripe || !elements) return;
setIsProcessing(true);
setError(null);
try {
const isMulti = selectedInvoices.size > 1;
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Impersonation-Session-Id": sessionId,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Failed to initialize payment");
}
const { clientSecret } = await res.json();
const { error: stripeError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: saveCard
? { setup_future_usage: "off_session" }
: undefined,
redirect: "if_required",
});
if (stripeError) {
setError(stripeError.message ?? "Payment failed");
setIsProcessing(false);
return;
}
setIsComplete(true);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "An unexpected error occurred");
setIsProcessing(false);
}
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsProcessing(false);
setIsComplete(true);
};
const selectedTotal = pending
.filter((i) => selectedInvoices.has(i.id))
.reduce((sum, i) => sum + i.totalCents, 0);
if (isComplete) {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -431,7 +357,10 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
<p className="text-stone-500 text-sm mb-6">
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
</p>
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
<button
onClick={onClose}
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
>
Done
</button>
</div>
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</p>
</div>
</div>
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
<span className="text-sm font-medium text-stone-800">
{formatCents(inv.totalCents)}
</span>
</label>
))}
</div>
<div className="border-t border-stone-200 pt-4 mb-6">
<div className="flex justify-between items-center mb-4">
<div className="flex justify-between items-center">
<span className="text-sm text-stone-600">Total</span>
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
<span className="text-lg font-bold text-stone-800">
{formatCents(selectedTotal)}
</span>
</div>
<PaymentElement />
</div>
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={saveCard}
onChange={(e) => setSaveCard(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
/>
<span className="text-sm text-stone-600">Save card for future payments</span>
</label>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-3">
<button
onClick={onClose}
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</button>
<button
onClick={handlePay}
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
disabled={selectedInvoices.size === 0 || isProcessing}
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? "Processing..." : "Pay Now"}
@@ -529,8 +444,4 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
);
}
export function BillingPayments(props: BillingPaymentsProps) {
return <BillingPaymentsInner {...props} />;
}
export default BillingPayments;
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,
/^\/api\/auth\/oauth2\/callback\//,
],
runtimeCaching: [
{
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
+93
View File
@@ -0,0 +1,93 @@
# Seed Strategy Runbook
This document describes the GroomBook seeding system across environments.
## Environment Profiles
| Profile | Staff | Clients | Invoices | Appointment Window | Auth |
|---------|-------|---------|----------|-------------------|------|
| `dev` | 4 (1 manager, 1 receptionist, 2 groomers) | ~100 | ~1,000 | 7 days back / 30 days forward | Disabled |
| `uat` | 8 (1 manager, 1 receptionist, 3 groomers, 3 bathers) | ~500 | ~4,000 | 30 days back / 90 days forward | Enabled |
| `demo` | 8 (1 manager, 1 receptionist, 3 groomers, 3 bathers) | ~500 | ~4,000 | 30 days back / 90 days forward | Enabled, OOBE enabled |
## Seed Script Environment Variables
| Variable | Values | Effect |
|----------|--------|--------|
| `SEED_PROFILE` | `dev`, `uat`, `demo` | Selects data volume profile (see above). Defaults to `uat` if unset. |
| `SEED_KNOWN_USERS_ONLY` | `true` | Minimal prod/demo seed with demo users only. Overrides `SEED_PROFILE`. |
| `SEED_ADMIN_EMAIL` | email address | Creates an admin staff account with the given email. |
| `SEED_ADMIN_NAME` | name | Display name for admin account. Defaults to "Admin". |
## Re-seeding Environments
### Dev
```bash
# Run seed job manually
kubectl -n groombook-dev exec -it deploy/groombook-api -- \
sh -c 'DATABASE_URL=$DATABASE_URL SEED_PROFILE=dev npm run db:seed'
```
Dev uses `AUTH_DISABLED=true` and accepts the `X-Dev-User-Id` header for staff impersonation.
### UAT
```bash
# Run seed job manually
kubectl -n groombook-uat exec -it deploy/groombook-api -- \
sh -c 'DATABASE_URL=$DATABASE_URL SEED_PROFILE=uat npm run db:seed'
```
UAT uses Authentik OIDC. See Authentik UAT Personas below.
### Demo (Production-like)
Demo uses the same data volume as UAT but with `SEED_KNOWN_USERS_ONLY=true` or is provisioned via the standard seed with OOBE enabled.
```bash
# Trigger seed CronJob
kubectl -n groombook cronjob trigger seed-job --latest
```
## Authentik UAT User Personas
Credentials are stored in sealed secrets — never use plaintext values.
| Persona | Email | Role | Access Level |
|---------|-------|------|--------------|
| UAT Super User | `uat-super@groombook.dev` | Super User | Full admin access |
| UAT Staff | `uat-staff@groombook.dev` | Staff | Standard staff operations |
| UAT Customer | `uat-customer@groombook.dev` | Customer | Customer portal access |
Sealed secret: `authentik-credentials` in `groombook-uat` namespace.
## OOBE (Out-of-Box Experience) Flag
The OOBE flag controls first-run setup flow in Demo/Production environments.
- **Demo/Production**: OOBE is enabled, users see setup wizard on first login
- **Dev/UAT**: OOBE is disabled, full access granted immediately
When `SEED_KNOWN_USERS_ONLY=true`, the demo users are created but OOBE state must be initialized separately.
## Dev-Mode Access
Dev environment disables authentication for local development convenience.
```bash
AUTH_DISABLED=true
```
To impersonate a specific staff user, use the `X-Dev-User-Id` header:
```bash
curl -H "X-Dev-User-Id: <staff-id>" http://localhost:3000/api/...
```
## Seed Idempotency
The seed script is idempotent and deterministic:
- Same `SEED_PROFILE` produces identical data with same IDs
- Re-running seed updates existing records rather than creating duplicates
- Appointments, invoices, and visit logs are truncated before each seed to ensure clean state
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
import base64
import requests
import os
import json
import time
from datetime import datetime
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise ValueError("MINIMAX_API_KEY environment variable not set")
url = "https://api.minimax.io/v1/image_generation"
headers = {"Authorization": f"Bearer {api_key}"}
os.makedirs("minimax-output", exist_ok=True)
# Comprehensive list of dog breeds and variations for diverse demo data
dog_prompts = [
# Large breeds
("german-shepherd-alert", "German Shepherd dog with alert expression, standing confidently, professional pet photography, studio lighting, photorealistic"),
("golden-retriever-happy", "Golden Retriever with joyful expression, sitting, golden coat, natural daylight, professional pet photography, photorealistic"),
("labrador-running", "Black Labrador Retriever running towards camera, outdoor park setting, dynamic pose, professional pet photography, photorealistic"),
("german-shepherd-sitting", "German Shepherd sitting in front of studio backdrop, professional portrait, studio lighting, photorealistic"),
("golden-retriever-lying", "Golden Retriever lying down on grass, peaceful expression, outdoor natural lighting, professional pet photography, photorealistic"),
# Medium breeds
("beagle-curious", "Beagle with curious expression, sitting, outdoor garden setting, professional pet photography, photorealistic"),
("cocker-spaniel-groomed", "Cocker Spaniel freshly groomed with fluffy coat, happy expression, professional grooming studio, photorealistic"),
("english-springer-spaniel", "English Springer Spaniel in natural outdoor setting, alert pose, professional pet photography, photorealistic"),
("boxer-playful", "Boxer dog with playful expression, standing, muscular build, professional studio lighting, photorealistic"),
("bulldog-gentle", "English Bulldog with gentle expression, sitting, studio backdrop, professional pet photography, photorealistic"),
# Small breeds
("maltese-fluffy", "Maltese dog with white fluffy coat, sitting, groomed appearance, professional pet photography, studio lighting, photorealistic"),
("shih-tzu-groomed", "Shih Tzu with long groomed coat, sitting pretty, professional grooming studio, photorealistic"),
("pomeranian-alert", "Pomeranian with alert expression, standing, fluffy coat, professional pet photography, photorealistic"),
("yorkshire-terrier", "Yorkshire Terrier with silky coat, sitting, professional grooming environment, photorealistic"),
("pug-curious", "Pug with curious expression, sitting, studio lighting, professional pet photography, photorealistic"),
# Specialty breeds
("poodle-standard-groomed", "Standard Poodle with professionally groomed coat, standing in show stance, professional grooming studio, photorealistic"),
("dachshund-long", "Long-haired Dachshund, lying down, relaxed pose, professional pet photography, photorealistic"),
("corgi-happy", "Welsh Corgi with happy expression, standing, professional outdoor setting, photorealistic"),
("husky-alert", "Siberian Husky with alert expression, sitting, professional pet photography, studio lighting, photorealistic"),
("german-shepherd-lying", "German Shepherd lying down in relaxed pose, indoor setting, professional pet photography, photorealistic"),
# Mixed/rescue variations
("mixed-breed-brown", "Brown and white mixed breed dog, friendly expression, sitting, professional pet photography, photorealistic"),
("mixed-breed-black", "Black mixed breed dog with gentle eyes, standing, outdoor natural lighting, photorealistic"),
("mixed-breed-spotted", "Spotted mixed breed dog, playful pose, outdoor park setting, professional pet photography, photorealistic"),
("terrier-mix-sitting", "Terrier mix dog, alert expression, sitting, professional studio backdrop, photorealistic"),
("spaniel-mix-outdoor", "Spaniel mix dog in outdoor garden, relaxed pose, natural daylight, professional pet photography, photorealistic"),
# Additional variations
("labrador-golden", "Golden Labrador Retriever, calm expression, standing in professional pose, studio lighting, photorealistic"),
("labrador-black-sitting", "Black Labrador Retriever sitting, gentle expression, professional pet photography, photorealistic"),
("rottweiler-calm", "Rottweiler with calm expression, sitting, professional studio, photorealistic"),
("doberman-alert", "Doberman Pinscher with alert expression, standing, professional pet photography, photorealistic"),
("german-shepherd-side", "German Shepherd in side profile, standing, professional outdoor setting, photorealistic"),
]
print(f"Generating {len(dog_prompts)} unique dog images...")
print(f"Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("")
generated = 0
failed = 0
for i, (filename_base, prompt) in enumerate(dog_prompts, 1):
filename = f"dog-{filename_base}.png"
filepath = f"minimax-output/{filename}"
# Check if already exists
if os.path.exists(filepath):
size = os.path.getsize(filepath)
print(f"[{i:2d}/{len(dog_prompts)}] ✓ {filename} (already exists, {size} bytes)")
generated += 1
continue
print(f"[{i:2d}/{len(dog_prompts)}] Generating {filename}...", end=" ", flush=True)
payload = {
"model": "image-01",
"prompt": prompt,
"aspect_ratio": "1:1",
"response_format": "base64",
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=120)
# Check for quota errors
if response.status_code == 429:
print(f"✗ QUOTA EXCEEDED")
print(f"\nQuota limit reached after {generated} successful generations")
break
response.raise_for_status()
data = response.json()
if "data" in data and "image_base64" in data["data"]:
images = data["data"]["image_base64"]
with open(filepath, "wb") as f:
f.write(base64.b64decode(images[0]))
file_size = os.path.getsize(filepath)
print(f"✓ ({file_size} bytes)")
generated += 1
else:
print(f"✗ Unexpected response format")
failed += 1
except requests.exceptions.Timeout:
print(f"✗ Timeout")
failed += 1
except requests.exceptions.RequestException as e:
if "429" in str(e) or "quota" in str(e).lower():
print(f"✗ QUOTA EXCEEDED")
print(f"\nQuota limit reached after {generated} successful generations")
break
else:
print(f"{type(e).__name__}")
failed += 1
except Exception as e:
print(f"{type(e).__name__}")
failed += 1
time.sleep(0.5) # Small delay between requests
print("")
print(f"End time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"✓ Successfully generated: {generated}")
print(f"✗ Failed: {failed}")
print(f"\nCopying images to demo-pets directory...")
# Copy all generated images to demo-pets
import subprocess
result = subprocess.run(
["cp", "-v", "minimax-output/dog-*.png", "apps/web/public/demo-pets/"],
capture_output=True,
text=True
)
if result.returncode == 0:
# Count files in demo-pets
import glob
demo_pets = glob.glob("apps/web/public/demo-pets/dog-*.png")
print(f"✓ Copied to demo-pets. Total dog images: {len(demo_pets)}")
else:
print(f"Note: Copy result - {result.stderr}")
+1 -1
Submodule infra updated: b667a3f005...e8bd35499d
@@ -1,6 +0,0 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -1,6 +0,0 @@
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
-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;
@@ -1,103 +0,0 @@
{
"id": "0026_stripe_payment",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
-28
View File
@@ -176,34 +176,6 @@
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1775568867192,
"tag": "0026_stripe_payment",
"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
}
]
}
-5
View File
@@ -71,11 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
address: "1 Main St, Springfield, CA 90000",
notes: null,
emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
stripeCustomerId: null,
status: "active",
disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+37 -75
View File
@@ -102,55 +102,43 @@ export const verification = pgTable("verification", {
// ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable(
"clients",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull(),
phone: text("phone"),
address: text("address"),
notes: text("notes"),
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"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_clients_email").on(t.email)]
);
export const clients = pgTable("clients", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
address: text("address"),
notes: text("notes"),
// Set to true if the client has opted out of email reminders/notifications
emailOptOut: boolean("email_opt_out").notNull().default(false),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pets = pgTable(
"pets",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
groomingNotes: text("grooming_notes"),
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const pets = pgTable("pets", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
groomingNotes: text("grooming_notes"),
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -263,9 +251,6 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -274,7 +259,6 @@ export const invoices = pgTable(
index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt),
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
]
);
@@ -312,28 +296,8 @@ export const invoiceTipSplits = pgTable(
(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).
// reminder_type values: "confirmation", "24h", "2h"
// channel values: "email", "sms"
export const reminderLogs = pgTable(
"reminder_logs",
{
@@ -343,11 +307,9 @@ export const reminderLogs = pgTable(
.references(() => appointments.id, { onDelete: "cascade" }),
// "confirmation" | "24h" | "2h"
reminderType: text("reminder_type").notNull(),
// "email" | "sms"
channel: text("channel").notNull().default("email"),
sentAt: timestamp("sent_at").notNull().defaultNow(),
},
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
(t) => [unique().on(t.appointmentId, t.reminderType)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
+133 -300
View File
@@ -1,19 +1,20 @@
/**
* Seed script — generates deterministic, PII-free test data for Groom Book.
*
* Creates:
* - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total)
* - 10 services
* - 500 clients, each with 1-3 dogs
* - ~2 500 appointments spread across the past 12 months
* - Invoices for completed appointments with line items and tip splits
* - Grooming visit logs for completed appointments
* Supports three profiles via SEED_PROFILE env var:
* - dev: 4 staff, 100 clients, ~1000 invoices, appointments 7d back / 30d forward
* - uat: 8 staff, 500 clients, ~4000 invoices, appointments 30d back / 90d forward
* - demo: Same data volume as UAT (for production-like demo environments)
*
* Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility.
*
* SEED_KNOWN_USERS_ONLY=true: Minimal prod/demo seed with demo users only.
*
* Output is fully deterministic: the same seed value always produces the
* same rows with the same IDs.
*
* Usage:
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
* DATABASE_URL=postgres://... SEED_PROFILE=dev npx tsx packages/db/src/seed.ts
*/
import postgres from "postgres";
@@ -21,54 +22,6 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js";
// ── Seed profile configuration ─────────────────────────────────────────────
type SeedProfile = "dev" | "uat" | "demo";
interface ProfileConfig {
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
clientCount: number;
appointmentsBackDays: number;
appointmentsForwardDays: number;
invoiceCount: number;
includeUatClients: boolean;
}
const profiles: Record<SeedProfile, ProfileConfig> = {
dev: {
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
clientCount: 100,
appointmentsBackDays: 7,
appointmentsForwardDays: 30,
invoiceCount: 1000,
includeUatClients: false,
},
uat: {
staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
clientCount: 500,
appointmentsBackDays: 30,
appointmentsForwardDays: 90,
invoiceCount: 4000,
includeUatClients: true,
},
demo: {
staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
clientCount: 500,
appointmentsBackDays: 30,
appointmentsForwardDays: 90,
invoiceCount: 4000,
includeUatClients: true,
},
};
function getProfile(): SeedProfile {
const raw = process.env.SEED_PROFILE?.toLowerCase();
if (raw === "dev" || raw === "uat" || raw === "demo") {
return raw;
}
return "uat";
}
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
/**
@@ -87,6 +40,50 @@ function createPrng(seed: number): () => number {
const rand = createPrng(42);
// ── Seed profile configuration ───────────────────────────────────────────────
type SeedProfile = "dev" | "uat" | "demo";
interface ProfileConfig {
staff: {
manager: number;
receptionist: number;
groomer: number;
bather: number;
};
clients: number;
appointments: {
daysBack: number;
daysForward: number;
};
targetInvoices: number;
}
function getProfileConfig(profile: SeedProfile | undefined): ProfileConfig {
const profiles: Record<SeedProfile, ProfileConfig> = {
dev: {
staff: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
clients: 100,
appointments: { daysBack: 7, daysForward: 30 },
targetInvoices: 1000,
},
uat: {
staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
clients: 500,
appointments: { daysBack: 30, daysForward: 90 },
targetInvoices: 4000,
},
demo: {
staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
clients: 500,
appointments: { daysBack: 30, daysForward: 90 },
targetInvoices: 4000,
},
};
if (!profile || profile === "uat") return profiles.uat;
return profiles[profile] ?? profiles.uat;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Return a random element from an array using the seeded PRNG. */
@@ -184,7 +181,7 @@ const dogBreeds = [
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd",
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle",
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner",
"Mixed Breed", "Mixed Breed", "Mixed Breed",
];
@@ -281,44 +278,6 @@ const productsUsed = [
"Coconut oil shampoo, leave-in conditioner, cologne",
];
const demoPetImages = [
"/demo-pets/dog-golden-after.png",
"/demo-pets/dog-poodle-groomed.png",
"/demo-pets/dog-black-lab.png",
"/demo-pets/dog-shih-tzu.png",
"/demo-pets/dog-cocker-spaniel.png",
"/demo-pets/dog-schnauzer.png",
"/demo-pets/dog-maltese.png",
"/demo-pets/dog-dachshund.png",
"/demo-pets/dog-pomeranian.png",
"/demo-pets/dog-bichon-frise.png",
"/demo-pets/dog-golden-retriever.png",
"/demo-pets/dog-labrador.png",
"/demo-pets/dog-mixed-breed.png",
"/demo-pets/dog-poodle.png",
"/demo-pets/dog-terrier.png",
"/demo-pets/dog-afghan-hound.png",
"/demo-pets/dog-basset-brown-white.png",
"/demo-pets/dog-bichon-white-groomed.png",
"/demo-pets/dog-boxer-fawn-athletic.png",
"/demo-pets/dog-cavalier-cream-gentle.png",
"/demo-pets/dog-cocker-buff-friendly.png",
"/demo-pets/dog-corgi.png",
"/demo-pets/dog-dachshund-black-tan.png",
"/demo-pets/dog-golden-before.png",
"/demo-pets/dog-pomeranian-white-studio.png",
"/demo-pets/dog-schnauzer-black-groomed.png",
"/demo-pets/dog-setter-red-sunlit.png",
"/demo-pets/dog-sheepdog-merle-running.png",
];
const puggleImages = [
"/demo-pets/dog-puggle-fawn-playful.png",
"/demo-pets/dog-puggle-black-sitting.png",
"/demo-pets/dog-puggle-cream-groomed.png",
"/demo-pets/dog-puggle-fawn-grooming.png",
];
// ── Service definitions ──────────────────────────────────────────────────────
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
@@ -398,8 +357,6 @@ async function seedKnownUsers() {
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
@@ -426,7 +383,6 @@ async function seedKnownUsers() {
name: "UAT Super User",
email: "uat-super@groombook.dev",
oidcSub: uatSuperOidcSub,
userId: uatSuperOidcSub,
role: "manager",
isSuperUser: true,
active: true,
@@ -453,7 +409,6 @@ async function seedKnownUsers() {
name: "UAT Staff Groomer",
email: "uat-groomer@groombook.dev",
oidcSub: uatStaffOidcSub,
userId: uatStaffOidcSub,
role: "groomer",
isSuperUser: false,
active: true,
@@ -462,37 +417,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 ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -588,32 +512,61 @@ async function seed() {
process.exit(1);
}
// Lean prod/demo seed — known users only, no large dataset
if (process.env.SEED_KNOWN_USERS_ONLY === "true") {
await seedKnownUsers();
return;
}
const profile = getProfile();
const cfg = profiles[profile];
const rawProfile = process.env.SEED_PROFILE?.toLowerCase();
const profile: SeedProfile | undefined = (rawProfile === "dev" || rawProfile === "uat" || rawProfile === "demo")
? rawProfile
: undefined;
const config = getProfileConfig(profile);
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
const profileLabel = profile ? ` (${profile})` : "";
console.log(`Seeding Groom Book database${profileLabel}...\n`);
// ── Staff ──
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 })
);
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 })
);
const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) =>
({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
);
const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) =>
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
// Deterministic staff IDs so they can be referenced in scripts/tests
const staffNames = [
{ name: "Jordan Lee", email: "jordan@groombook.dev" },
{ name: "Sam Rivera", email: "sam@groombook.dev" },
{ name: "Sarah Mitchell", email: "sarah@groombook.dev" },
{ name: "James Park", email: "james@groombook.dev" },
{ name: "Maria Gonzalez", email: "maria@groombook.dev" },
{ name: "Tyler Johnson", email: "tyler@groombook.dev" },
{ name: "Ashley Chen", email: "ashley@groombook.dev" },
{ name: "Devon Williams", email: "devon@groombook.dev" },
];
const managerStaff = staffNames.slice(0, config.staff.manager).map(
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "manager" as const, isSuperUser: false }),
);
const receptionistStaff = staffNames.slice(config.staff.manager, config.staff.manager + config.staff.receptionist).map(
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "receptionist" as const, isSuperUser: false }),
);
const groomers = staffNames.slice(config.staff.manager + config.staff.receptionist, config.staff.manager + config.staff.receptionist + config.staff.groomer).map(
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }),
);
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
const bathers = staffNames.slice(config.staff.manager + config.staff.receptionist + config.staff.groomer, config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather).map(
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }),
);
const totalStaff = config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather;
console.log(`✓ Creating ${totalStaff} staff (${config.staff.manager} manager, ${config.staff.receptionist} receptionist, ${config.staff.groomer} groomers, ${config.staff.bather} bathers)`);
// Truncate downstream tables before staff upsert — clears stale impersonation
// sessions from prior seed runs so the FK constraint on staff_id is never
// violated when ON CONFLICT DO UPDATE touches staff rows that still have
// impersonation_sessions references.
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
@@ -632,10 +585,6 @@ async function seed() {
set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
});
}
const staffLabel = cfg.staffCount.bather > 0
? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)`
: `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`;
console.log(`✓ Created ${staffLabel}`);
// ── SEED_ADMIN_EMAIL admin ──
const adminEmail = process.env.SEED_ADMIN_EMAIL;
@@ -647,8 +596,6 @@ async function seed() {
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
@@ -660,31 +607,6 @@ async function seed() {
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 ──
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
@@ -710,10 +632,10 @@ async function seed() {
// ── Clients & Pets ──
const now = new Date();
const appointmentsBackDate = new Date(now);
appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays);
const appointmentsForwardDate = new Date(now);
appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays);
const appointmentsBack = new Date(now);
appointmentsBack.setDate(appointmentsBack.getDate() - config.appointments.daysBack);
const appointmentsForward = new Date(now);
appointmentsForward.setDate(appointmentsForward.getDate() + config.appointments.daysForward);
interface ClientRecord { id: string; name: string }
interface PetRecord { id: string; clientId: string }
@@ -721,9 +643,9 @@ async function seed() {
const clientRecords: ClientRecord[] = [];
const petRecords: PetRecord[] = [];
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
// Batch insert clients and pets
const clientBatchSize = 50;
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
for (let batch = 0; batch < Math.ceil(config.clients / clientBatchSize); batch++) {
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
const petBatch: (typeof schema.pets.$inferInsert)[] = [];
@@ -753,7 +675,7 @@ async function seed() {
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
for (let p = 0; p < petCount; p++) {
const petId = uuid();
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds);
const breed = pick(dogBreeds);
const dob = new Date(now);
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
dob.setMonth(randInt(0, 11));
@@ -772,11 +694,9 @@ async function seed() {
shampooPreference: pick(shampoos),
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
customFields: {},
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
});
petRecords.push({ id: petId, clientId });
petIndex++;
}
}
@@ -807,29 +727,27 @@ async function seed() {
shampooPreference: pet.shampooPreference,
specialCareNotes: pet.specialCareNotes,
customFields: pet.customFields,
image: pet.image,
},
});
}
}
console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`);
console.log(`✓ Created ${config.clients} clients with ${petRecords.length} pets`);
// ── UAT test clients (guaranteed pending invoices) ─────────────────────────────
// These 5 clients are deterministic and documented in Shedward AGENTS.md so
// UAT can reliably find billing test data without searching.
if (cfg.includeUatClients) {
interface UatClient {
id: string;
name: string;
email: string;
phone: string;
address: string;
petId: string;
petName: string;
petBreed: string;
}
const uatClients: UatClient[] = [
interface UatClient {
id: string;
name: string;
email: string;
phone: string;
address: string;
petId: string;
petName: string;
petBreed: string;
}
const uatClients: UatClient[] = [
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
@@ -842,20 +760,18 @@ async function seed() {
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
await db.insert(schema.pets)
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") })
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") } });
// Create one completed appointment for this client
const apptId = uuid();
const svcIdx = 0;
const svc = servicesDef[svcIdx]!;
const completedTime = randDate(appointmentsBackDate, now);
const completedTime = randDate(appointmentsBack, now);
completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000);
const uatGroomer = groomers[0]!;
const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer;
await db.insert(schema.appointments).values({
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id,
batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: groomers[0]!.id,
batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
});
// Create a PENDING invoice for that appointment
const invoiceId = uuid();
@@ -873,12 +789,17 @@ async function seed() {
id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id,
cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime,
});
}
console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`);
}
console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`);
// ── Appointments, Invoices, Visit Logs ──
// Generate ~5 appointments per client on average = ~2500 total
// Calculate visit count to achieve targetInvoices based on ~65% completion rate
const completedRatio = 0.65;
const totalVisitsNeeded = Math.ceil(config.targetInvoices / completedRatio);
const avgVisitsPerClient = Math.ceil(totalVisitsNeeded / clientRecords.length);
const visitCountMin = Math.max(1, Math.floor(avgVisitsPerClient * 0.7));
const visitCountMax = Math.max(visitCountMin + 1, Math.ceil(avgVisitsPerClient * 1.3));
const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [
"completed", "completed", "completed", "completed", "completed",
"completed", "completed", "scheduled", "confirmed", "cancelled", "no_show",
@@ -929,8 +850,7 @@ async function seed() {
for (const client of clientRecords) {
const pets = petsByClient.get(client.id) ?? [];
// Each client visits ~3-8 times over the year
const visitCount = randInt(3, 8);
const visitCount = randInt(visitCountMin, visitCountMax);
for (let v = 0; v < visitCount; v++) {
// Pick a random pet for this visit
@@ -939,15 +859,15 @@ async function seed() {
const serviceId = serviceIds[serviceIdx]!;
const svc = servicesDef[serviceIdx]!;
const groomer = pick(groomers);
const bather = rand() < 0.6 ? pick(bathers) : null;
const bather = rand() < 0.6 && bathers.length > 0 ? pick(bathers) : null;
const status = pick(statuses);
// Schedule within the configured appointment window
let startTime: Date;
if (status === "scheduled" || status === "confirmed") {
startTime = randDate(now, appointmentsForwardDate);
startTime = randDate(now, appointmentsForward);
} else {
startTime = randDate(appointmentsBackDate, now);
startTime = randDate(appointmentsBack, now);
}
// Snap to business hours (8am - 5pm)
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
@@ -1051,93 +971,6 @@ async function seed() {
console.log(`✓ Created ${appointmentCount} appointments`);
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`);
// ── Enforce target invoice count ───────────────────────────────────────────
// If current invoice count is below target (due to profile having fewer
// clients/appointments than the target ratio), generate supplemental
// completed appointments for existing clients to fill the gap.
if (invoiceCount < cfg.invoiceCount) {
const additionalNeeded = cfg.invoiceCount - invoiceCount;
console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`);
const existingClientIds = clientRecords.map(c => c.id);
const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20);
let supplementalCount = 0;
let supplementalInvoices = 0;
for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) {
const clientId = pick(existingClientIds);
const pets = petsByClient.get(clientId) ?? [];
if (pets.length === 0) continue;
const petId = pick(pets);
const serviceIdx = randInt(0, serviceIds.length - 1);
const serviceId = serviceIds[serviceIdx]!;
const svc = servicesDef[serviceIdx]!;
const groomer = pick(groomers);
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
let startTime = randDate(appointmentsBackDate, now);
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
const effectivePrice = svc.price;
const apptId = uuid();
apptBatch.push({
id: apptId, clientId, petId, serviceId,
staffId: groomer.id, batherStaffId: bather?.id ?? null,
status: "completed", startTime, endTime, notes: null, priceCents: null,
});
appointmentCount++;
supplementalCount++;
const invoiceId = uuid();
const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0;
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,
unitPriceCents: effectivePrice, totalCents: effectivePrice,
});
if (tipCents > 0) {
if (bather) {
const groomerShare = Math.round(tipCents * 0.6);
const batherShare = tipCents - groomerShare;
tipSplitBatch.push(
{ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare },
{ id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare },
);
} else {
tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents });
}
}
visitLogBatch.push({
id: uuid(), petId, appointmentId: apptId, staffId: groomer.id,
cutStyle: pick(cutStyles), productsUsed: pick(productsUsed),
notes: pick(visitLogNotes), groomedAt: endTime,
});
invoiceCount++;
supplementalInvoices++;
visitLogCount++;
if (apptBatch.length >= apptBatchSize) {
await flushBatches();
}
}
await flushBatches();
console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`);
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`);
}
console.log(`✓ Created ${visitLogCount} grooming visit logs`);
console.log("\nSeed complete!");
+54 -114
View File
@@ -40,12 +40,6 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.10.1
stripe:
specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15)
telnyx:
specifier: ^1.23.0
version: 1.27.0
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -89,17 +83,11 @@ importers:
'@groombook/types':
specifier: workspace:*
version: link:../../packages/types
'@stripe/react-stripe-js':
specifier: ^6.1.0
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@stripe/stripe-js':
specifier: ^9.1.0
version: 9.1.0
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
better-auth:
specifier: ^1.5.6
specifier: ^1.0.0
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
lucide-react:
specifier: ^0.577.0
@@ -180,7 +168,7 @@ importers:
version: 22.19.15
drizzle-kit:
specifier: ^0.30.4
version: 0.30.4
version: 0.30.6
tsx:
specifier: ^4.19.0
version: 4.21.0
@@ -1699,6 +1687,9 @@ packages:
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -2118,17 +2109,6 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/react-stripe-js@6.1.0':
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
peerDependencies:
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.1.0':
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
engines: {node: '>=12.16'}
'@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -2830,8 +2810,8 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
drizzle-kit@0.30.4:
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
drizzle-kit@0.30.6:
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true
drizzle-orm@0.38.4:
@@ -2955,6 +2935,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
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:
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
engines: {node: '>= 0.4'}
@@ -3158,6 +3142,11 @@ packages:
functions-have-names@1.2.3:
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:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -3425,6 +3414,10 @@ packages:
isexe@2.0.0:
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:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -3606,9 +3599,6 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3618,10 +3608,6 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3713,10 +3699,6 @@ packages:
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -3834,17 +3816,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -3853,9 +3828,6 @@ packages:
peerDependencies:
react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -4040,6 +4012,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -4148,15 +4124,6 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.0.1:
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.2.1:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -4178,10 +4145,6 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
telnyx@1.27.0:
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
engines: {node: ^6 || >=8}
temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@@ -4256,9 +4219,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4348,10 +4308,6 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -4488,6 +4444,11 @@ packages:
engines: {node: '>= 8'}
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:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -6219,6 +6180,8 @@ snapshots:
'@opentelemetry/semantic-conventions@1.40.0': {}
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -6708,15 +6671,6 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.1.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.1.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies:
ejs: 3.1.10
@@ -7414,12 +7368,13 @@ snapshots:
dom-accessibility-api@0.6.3: {}
drizzle-kit@0.30.4:
drizzle-kit@0.30.6:
dependencies:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.19.12
esbuild-register: 3.6.0(esbuild@0.19.12)
gel: 2.2.0
transitivePeerDependencies:
- supports-color
@@ -7456,6 +7411,8 @@ snapshots:
entities@6.0.1: {}
env-paths@3.0.0: {}
es-abstract@1.24.1:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -7817,6 +7774,17 @@ snapshots:
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: {}
gensync@1.0.0-beta.2: {}
@@ -8081,6 +8049,8 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -8249,18 +8219,12 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
lodash.sortby@4.7.0: {}
lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@10.4.3: {}
@@ -8335,8 +8299,6 @@ snapshots:
nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -8441,18 +8403,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
punycode@2.3.1: {}
qs@6.15.1:
dependencies:
side-channel: 1.1.0
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -8462,8 +8414,6 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-is@16.13.1: {}
react-is@17.0.2: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
@@ -8687,6 +8637,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -8822,10 +8774,6 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {}
supports-color@7.2.0:
@@ -8840,14 +8788,6 @@ snapshots:
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: {}
tempy@0.6.0:
@@ -8918,8 +8858,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -9016,8 +8954,6 @@ snapshots:
uuid@8.3.2: {}
uuid@9.0.1: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
@@ -9195,6 +9131,10 @@ snapshots:
dependencies:
isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0