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
84 changed files with 448 additions and 2083 deletions
-2
View File
@@ -7,5 +7,3 @@ apps/web/dist
apps/api/dist apps/api/dist
packages/db/dist packages/db/dist
packages/types/dist packages/types/dist
.turbo
screenshots/
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook 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 ─────────────────────────────────────────────────────────────────────── # ── API ───────────────────────────────────────────────────────────────────────
PORT=3000 PORT=3000
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080
+9 -24
View File
@@ -20,8 +20,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -44,8 +42,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -66,8 +62,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -107,8 +101,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -246,6 +238,7 @@ jobs:
echo "Deploying images tagged $TAG to groombook-dev..." echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image # Run migration with PR image
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f - cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1 apiVersion: batch/v1
@@ -310,8 +303,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -418,17 +409,11 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}" git push -u origin "chore/update-image-tags-${TAG}"
# Check if PR already exists for this branch # Create PR and merge immediately (no required checks on groombook/infra)
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true) PR_URL=$(gh pr create \
if [ -n "$EXISTING_PR" ]; then --repo groombook/infra \
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR" --base main \
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge --head "chore/update-image-tags-${TAG}" \
else --title "chore: deploy ${TAG} to dev" \
PR_URL=$(gh pr create \ --body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
--repo groombook/infra \ gh pr merge "$PR_URL" --merge
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
fi
-22
View File
@@ -14,29 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: read
steps: 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 - name: Generate infra repo token
id: infra-token id: infra-token
uses: tibdex/github-app-token@v2 uses: tibdex/github-app-token@v2
-2
View File
@@ -62,8 +62,6 @@ jobs:
fi fi
# Update seed Job name to include short SHA (immutable template fix) # 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" SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" 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 # Build
FROM deps AS builder FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/ COPY packages/ packages/
COPY apps/api/ apps/api/ COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \ 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 RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000 EXPOSE 3000
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "apps/api/dist/index.js"] CMD ["node", "apps/api/dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database # Migrate stage — runs drizzle-kit migrate against the database
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds # Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset FROM builder AS reset
CMD ["pnpm", "db:reset"] CMD ["pnpm", "db:reset"]
-2
View File
@@ -22,8 +22,6 @@
"hono": "^4.6.17", "hono": "^4.6.17",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
-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.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true); 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", () => { describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+3 -33
View File
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { cors } from "hono/cors"; 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 { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js"; import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js"; import { servicesRouter } from "./routes/services.js";
@@ -28,7 +28,6 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js"; import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js"; import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js"; import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono(); const app = new Hono();
@@ -51,9 +50,6 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header // Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter); 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 // Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter); app.route("/api/dev", devRouter);
@@ -96,11 +92,6 @@ app.get("/api/setup/status", async (c) => {
return c.json({ needsSetup: !superUser }); 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 // Protected API routes
const api = app.basePath("/api"); const api = app.basePath("/api");
api.use("*", authMiddleware); api.use("*", authMiddleware);
@@ -109,13 +100,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono(); const authRouter = new Hono();
authRouter.all("/*", (c) => { authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
api.route("/auth", authRouter); api.route("/auth", authRouter);
// ── Role guards ──────────────────────────────────────────────────────────────── // ── Role guards ────────────────────────────────────────────────────────────────
@@ -187,24 +172,9 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
await initAuth(); await initAuth();
console.log(`API server listening on port ${port}`); console.log(`API server listening on port ${port}`);
const server = serve({ fetch: app.fetch, port }); serve({ fetch: app.fetch, port });
// Start background reminder scheduler (runs every minute to check for upcoming appointments) // Start background reminder scheduler (runs every minute to check for upcoming appointments)
startReminderScheduler(); startReminderScheduler();
function shutdown() {
console.log("Shutting down gracefully...");
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
export default app; export default app;
+10 -110
View File
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db"; import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } 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_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
@@ -28,21 +27,6 @@ export function getAuthPromise() {
return authInitPromise; 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. * Re-initializes the Better-Auth instance after auth config changes.
* *
@@ -91,12 +75,6 @@ export async function initAuth(): Promise<void> {
database: drizzleAdapter(getDb(), { provider: "pg" }), database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod", secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "memory",
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -174,55 +152,6 @@ export async function initAuth(): Promise<void> {
console.log("[auth] Using env var config (no DB config found)"); 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);
// Fetch OIDC discovery document to derive canonical provider URLs.
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
// while keeping authorizationUrl public for browser redirects.
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
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) {
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 // Build Better-Auth instance using resolved config
authInstance = betterAuth({ authInstance = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -230,28 +159,6 @@ export async function initAuth(): Promise<void> {
}), }),
secret: BETTER_AUTH_SECRET, secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL, 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: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -259,27 +166,20 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId, providerId: providerConfig.providerId,
clientId: providerConfig.clientId, clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret, clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr, ...(providerConfig.internalBaseUrl
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), ? {
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), 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: { session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day 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) => { 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/")) { if (c.req.path.startsWith("/api/auth/")) {
await next(); await next();
return; return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return; return;
} }
let auth; const session = await getAuth().api.getSession({
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
}); });
+20 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db"; import { and, eq, getDb, isNull, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect; export type StaffRow = typeof staff.$inferSelect;
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff) .from(staff)
.where(eq(staff.oidcSub, jwt.sub)); .where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) { 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( return c.json(
{ error: "Forbidden: no staff record found for authenticated user" }, { error: "Forbidden: no staff record found for authenticated user" },
403 403
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services, services,
staff, staff,
} from "@groombook/db"; } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>(); export const appointmentGroupsRouter = new Hono();
// ─── Schemas ────────────────────────────────────────────────────────────────── // ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId"); const clientId = c.req.query("clientId");
const from = c.req.query("from"); const from = c.req.query("from");
const to = c.req.query("to"); const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)] ? [eq(appointmentGroups.clientId, clientId)]
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
})) }))
.filter((g) => !from || g.appointments.length > 0); .filter((g) => !from || g.appointments.length > 0);
if (isGroomer) {
return c.json(
result.filter((g) =>
g.appointments.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
)
);
}
return c.json(result); return c.json(result);
}); });
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => { appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select() .select()
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId, serviceId: appointments.serviceId,
serviceName: services.name, serviceName: services.name,
staffId: appointments.staffId, staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name, staffName: staff.name,
status: appointments.status, status: appointments.status,
startTime: appointments.startTime, startTime: appointments.startTime,
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id)) .where(eq(appointments.groupId, id))
.orderBy(appointments.startTime); .orderBy(appointments.startTime);
if (
isGroomer &&
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
const [client] = await db const [client] = await db
.select({ name: clients.name, email: clients.email }) .select({ name: clients.name, email: clients.email })
.from(clients) .from(clients)
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema), zValidator("json", createGroupSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const staffRow = c.get("staff");
if (staffRow?.role === "groomer") {
return c.json(
{ error: "Forbidden: groomers cannot create group bookings" },
403
);
}
const body = c.req.valid("json"); const body = c.req.valid("json");
const startTime = new Date(body.startTime); const startTime = new Date(body.startTime);
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
const [updated] = await db const [updated] = await db
.update(appointmentGroups) .update(appointmentGroups)
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => { appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select({ id: appointmentGroups.id }) .select({ id: appointmentGroups.id })
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id)); .where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404); if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
await db await db
.update(appointments) .update(appointments)
.set({ status: "cancelled", updatedAt: new Date() }) .set({ status: "cancelled", updatedAt: new Date() })
+1 -52
View File
@@ -163,28 +163,6 @@ appointmentsRouter.post(
} }
} }
if (apptFields.batherStaffId) {
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (!recurrence) { if (!recurrence) {
// Single appointment // Single appointment
const [inserted] = await tx const [inserted] = await tx
@@ -420,8 +398,7 @@ appointmentsRouter.patch(
const needsConflictCheck = const needsConflictCheck =
updateFields.startTime !== undefined || updateFields.startTime !== undefined ||
updateFields.endTime !== undefined || updateFields.endTime !== undefined ||
updateFields.staffId !== undefined || updateFields.staffId !== undefined;
updateFields.batherStaffId !== undefined;
const update: Record<string, unknown> = { const update: Record<string, unknown> = {
...updateFields, ...updateFields,
@@ -457,11 +434,6 @@ appointmentsRouter.patch(
updateFields.staffId !== undefined updateFields.staffId !== undefined
? updateFields.staffId ? updateFields.staffId
: current.staffId; : current.staffId;
// Use provided batherStaffId (may be null to unassign); fall back to existing
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (end <= start) { if (end <= start) {
throw Object.assign(new Error("end before start"), { throw Object.assign(new Error("end before start"), {
@@ -489,29 +461,6 @@ appointmentsRouter.patch(
} }
} }
if (batherStaffId) {
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
)
)
.limit(1);
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const [updated] = await tx const [updated] = await tx
.update(appointments) .update(appointments)
.set(update) .set(update)
+6 -93
View File
@@ -1,10 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db"; import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>(); export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({ const createLogSchema = z.object({
petId: z.string().uuid(), petId: z.string().uuid(),
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb(); const db = getDb();
const petId = c.req.query("petId"); const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400); if (!petId) return c.json({ error: "petId is required" }, 400);
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
const rows = await db const rows = await db
.select() .select()
.from(groomingVisitLogs) .from(groomingVisitLogs)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema), zValidator("json", createLogSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json"); const { groomedAt, ...rest } = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
if (appointmentId) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.id, appointmentId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
} else {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
}
const [row] = await db const [row] = await db
.insert(groomingVisitLogs) .insert(groomingVisitLogs)
.values({ .values({
...rest, ...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(), groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
}) })
.returning(); .returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => { groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const [row] = await db
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [log] = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.limit(1);
if (!log) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, log.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
await db
.delete(groomingVisitLogs) .delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id)) .where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
+1 -40
View File
@@ -13,9 +13,8 @@ import {
clients, clients,
sql, sql,
} from "@groombook/db"; } 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({ const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(), appointmentId: z.string().uuid().optional(),
@@ -339,41 +338,3 @@ invoicesRouter.patch(
return c.json({ ...updated, lineItems }); return c.json({ ...updated, lineItems });
} }
); );
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().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);
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
return c.json({ refundId: result.refundId });
}
);
+1 -114
View File
@@ -35,12 +35,6 @@ portalRouter.get("/me", async (c) => {
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone }); 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) => { portalRouter.get("/services", async (c) => {
const db = getDb(); const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true)); const allServices = await db.select().from(services).where(eq(services.active, true));
@@ -129,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id, id: inv.id,
status: inv.status, status: inv.status,
totalCents: inv.totalCents, 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 })), lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
}))); })));
}); });
@@ -454,113 +448,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
return c.json({ ok: true }); 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 sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db
.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 sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
return c.json(methods);
});
portalRouter.post("/payment-methods", async (c) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const 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 sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id");
const 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 ────────────────────────────────────────────── // ─── Dev-mode session creation ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client // Allows the dev login selector to vend an impersonation session for a client
// without requiring manager auth. Only available when AUTH_DISABLED=true. // 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); ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
const offset = (page - 1) * limit;
const churnRisk = await db const churnRisk = await db
.select({ .select({
clientId: clients.id, clientId: clients.id,
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
.having( .having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
) )
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`) .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
.limit(limit)
.offset(offset);
const [churnCountRow] = await db
.select({ total: sql<number>`count(*)::int` })
.from(
db
.select({ id: clients.id })
.from(clients)
.leftJoin(appointments, eq(appointments.clientId, clients.id))
.groupBy(clients.id)
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.as("churn_count")
);
const churnRiskTotal = churnCountRow?.total ?? 0;
return c.json({ return c.json({
from: from.toISOString(), from: from.toISOString(),
to: to.toISOString(), to: to.toISOString(),
newClients, newClients,
activeInPeriodCount: activeInPeriod.length, activeInPeriodCount: activeInPeriod.length,
churnRisk, churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal, churnRiskTotal: churnRisk.length,
page,
limit,
}); });
}); });
-11
View File
@@ -9,17 +9,6 @@ export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed // GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown // and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => { setupRouter.get("/status", async (c) => {
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(); const db = getDb();
// Check if any super user exists // Check if any super user exists
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => { staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff"); const staffRow = c.get("staff");
return c.json(staffRow); return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row); 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) => { staffRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
-112
View File
@@ -1,112 +0,0 @@
import { Hono } from "hono";
import Stripe from "stripe";
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 [inv] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.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, invoiceId));
}
}
} 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;
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
}
}
} 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! };
}
-14
View File
@@ -12,7 +12,6 @@ import {
services, services,
staff, staff,
reminderLogs, reminderLogs,
session,
} from "@groombook/db"; } from "@groombook/db";
import { import {
buildReminderEmail, buildReminderEmail,
@@ -156,19 +155,6 @@ export function startReminderScheduler(): void {
runReminderCheck().catch((err) => { runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", 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"); console.log("[reminders] Reminder scheduler started");
} }
// Deletes expired sessions from the database.
// Runs every minute alongside reminder checks.
export async function runSessionCleanup(): Promise<void> {
const db = getDb();
const now = new Date();
await db
.delete(session)
.where(lt(session.expiresAt, now));
}
+1 -5
View File
@@ -44,10 +44,7 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 }, json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
}); });
} }
if (url.includes("/api/invoices")) { // Appointments, clients, services, staff, invoices, book, etc.
return route.fulfill({ json: { data: [], total: 0 } });
}
// Appointments, clients, services, staff, book, etc.
return route.fulfill({ json: [] }); return route.fulfill({ json: [] });
}); });
}); });
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => { test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices"); await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).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 apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
-12
View File
@@ -3,22 +3,10 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 # Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ { location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; 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 # Proxy API calls to the API service
+1 -3
View File
@@ -14,10 +14,8 @@
}, },
"dependencies": { "dependencies": {
"@groombook/types": "workspace:*", "@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6", "better-auth": "^1.0.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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 { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js"; import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.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 { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.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() { function LoginPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => { const handleLogin = async () => {
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) => {
setIsLoading(true); setIsLoading(true);
setError(null); await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
}; };
const isGoogle = providers.includes("google");
const isGitHub = providers.includes("github");
const isAuthentik = providers.includes("authentik");
return ( return (
<div <div
style={{ style={{
@@ -74,94 +53,23 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}> <p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue Sign in to continue
</p> </p>
{error && ( <button
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}> onClick={handleLogin}
{error} disabled={isLoading}
</div> style={{
)} padding: "0.6rem 1.5rem",
{isGoogle && ( borderRadius: 6,
<button border: "none",
onClick={() => handleSocialLogin("google")} background: "#4f8a6f",
disabled={isLoading} color: "#fff",
style={{ fontWeight: 600,
display: "flex", fontSize: 14,
alignItems: "center", cursor: isLoading ? "wait" : "pointer",
justifyContent: "center", opacity: isLoading ? 0.7 : 1,
gap: 8, }}
width: "100%", >
padding: "0.6rem 1.5rem", {isLoading ? "Redirecting…" : "Sign in with SSO"}
borderRadius: 6, </button>
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>
)}
</div> </div>
</div> </div>
); );
@@ -181,7 +89,6 @@ const NAV_LINKS = [
function AdminLayout() { function AdminLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding(); const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +117,6 @@ function AdminLayout() {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
marginRight: "1.25rem", marginRight: "1.25rem",
flexShrink: 0,
}}> }}>
{logoSrc && ( {logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} /> <img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +130,45 @@ function AdminLayout() {
</strong> </strong>
</div> </div>
<GlobalSearch /> <GlobalSearch />
<div style={{ <Link
display: "flex", to="/admin/book"
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");
}}
style={{ style={{
flexShrink: 0,
padding: "0.4rem 0.85rem", padding: "0.4rem 0.85rem",
borderRadius: 6, borderRadius: 6,
border: "1px solid #e2e8f0", textDecoration: "none",
background: "#fff",
color: "#4b5563",
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 600,
cursor: "pointer", color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}} }}
> >
Logout Book
</button> </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> </nav>
<main style={{ padding: "1.25rem 1.5rem" }}> <main style={{ padding: "1.25rem 1.5rem" }}>
<Routes> <Routes>
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "", 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 && ( {showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow <RescheduleFlow
appointment={rescheduleAppointment as any} appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }} onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react"; import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js"; import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props { interface Props {
sessionId: string | null; sessionId: string | null;
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword; const passwordsMatch = newPassword === confirmPassword;
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading; const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
if (readOnly) { if (readOnly) {
return ( return (
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
); );
} }
async function handleSubmit() { function handleSubmit() {
if (!canSubmit) return; if (!canSubmit) return;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError("Passwords do not match."); setError("Passwords do not match.");
return; return;
} }
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null); setError(null);
setLoading(true); setCurrentPassword("");
try { setNewPassword("");
// eslint-disable-next-line @typescript-eslint/no-explicit-any setConfirmPassword("");
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);
}
} }
return ( return (
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} 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" 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> </button>
</div> </div>
</div> </div>
+94 -183
View File
@@ -1,6 +1,4 @@
import { useState, useEffect } from "react"; 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"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
interface Invoice { interface Invoice {
@@ -12,28 +10,31 @@ interface Invoice {
} }
interface PaymentMethod { interface PaymentMethod {
id: string;
brand: string; brand: string;
last4: string; last4: string;
expiryMonth: number; expiryMonth: number;
expiryYear: number; expiryYear: number;
} }
interface Package {
name: string;
remaining: number;
}
interface BillingPaymentsProps { interface BillingPaymentsProps {
sessionId: string | null; sessionId: string | null;
readOnly: boolean; readOnly: boolean;
} }
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) { export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]); const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]); const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]); const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices"); const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false); const [autopay, setAutopay] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false);
const [publishableKey, setPublishableKey] = useState<string>("");
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
} }
try { try {
const [configRes, invoicesRes, methodsRes] = await Promise.all([ const response = await fetch("/api/portal/invoices", {
fetch("/api/portal/config", { headers: {
headers: { "X-Impersonation-Session-Id": sessionId }, "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 },
}),
]);
if (!configRes.ok) throw new Error("Failed to fetch config"); if (!response.ok) {
const configData = await configRes.json(); throw new Error("Failed to fetch invoices");
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,
}))
);
} }
const data = await response.json();
setInvoices(Array.isArray(data) ? data : data.invoices || []);
setPaymentMethods(data.paymentMethods || []);
setPackages(data.packages || []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "An error occurred"); setError(err instanceof Error ? err.message : "An error occurred");
} finally { } finally {
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
fetchData(); fetchData();
}, [sessionId]); }, [sessionId]);
const formatCents = (cents: number) => const formatCents = (cents: number) => {
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
};
const pending = invoices.filter((i) => i.status === "pending"); const pending = invoices.filter((i) => i.status === "pending");
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0); const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3" /> <div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded" /> <div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded" /> <div className="h-24 bg-gray-200 rounded"></div>
</div> </div>
</div> </div>
); );
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalPending > 0 && ( {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 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> <div>
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""} {pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
</p> </p>
</div> </div>
<button <button
onClick={() => setShowPaymentModal(true)} onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)" className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
> >
Pay Now Pay Now
</button> </button>
</div> </div>
)} )}
{/* Tabs */}
<div className="flex gap-2"> <div className="flex gap-2">
{([ {([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "invoices" as const, label: "Invoices", icon: DollarSign },
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
))} ))}
</div> </div>
{/* Invoices */}
{tab === "invoices" && ( {tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto"> <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">Description</th>
<th className="px-5 py-3 font-medium">Amount</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">Status</th>
<th className="px-5 py-3 font-medium" /> <th className="px-5 py-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <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"> <tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700"> <td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", { {new Date(inv.date).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric", month: "short",
day: "numeric",
year: "numeric",
})} })}
</td> </td>
<td className="px-5 py-3 text-stone-600"> <td className="px-5 py-3 text-stone-600">
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{/* Payment Methods */}
{tab === "payment" && ( {tab === "payment" && (
<div className="space-y-4"> <div className="space-y-4">
{paymentMethods.length === 0 ? ( {paymentMethods.length === 0 ? (
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<div className="space-y-3"> <div className="space-y-3">
{paymentMethods.map((method) => ( {paymentMethods.map((method) => (
<div <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" className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</span> </span>
</div> </div>
{!readOnly && ( {!readOnly && (
<button <button className="text-sm text-blue-600 hover:underline">
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"
>
Remove Remove
</button> </button>
)} )}
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{/* Autopay */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"> <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 justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
<div> <div>
<p className="text-sm font-medium text-stone-800">Autopay</p> <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>
</div> </div>
{!readOnly ? ( {!readOnly ? (
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{/* Packages */}
{tab === "packages" && ( {tab === "packages" && (
<div className="space-y-4"> <div className="space-y-4">
{packages.length === 0 ? ( {packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p> <p className="text-gray-500 italic">No packages purchased</p>
) : ( ) : (
packages.map((pkg, index) => ( 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"> <div className="flex items-center justify-between">
<span className="font-medium text-stone-800">{pkg.name}</span> <span className="font-medium text-stone-800">{pkg.name}</span>
<span className="text-stone-600">{pkg.remaining} remaining</span> <span className="text-stone-600">{pkg.remaining} remaining</span>
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{showPaymentModal && publishableKey && ( {/* Payment Modal */}
<PaymentModalWrapper {showPaymentModal && (
key={Date.now()} <PaymentModal
sessionId={sessionId ?? ""}
publishableKey={publishableKey}
pending={pending} pending={pending}
totalPending={totalPending}
onClose={() => setShowPaymentModal(false)} 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> </div>
); );
} }
interface PaymentModalWrapperProps { function PaymentModal({
sessionId: string; pending,
publishableKey: string; totalPending: _totalPending,
onClose,
}: {
pending: Invoice[]; pending: Invoice[];
totalPending: number;
onClose: () => void; onClose: () => void;
onSuccess: () => void; }) {
} const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
new Set(pending.map((i) => i.id))
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
const [stripePromise] = useState(() =>
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
); );
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 [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false); const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const formatCents = (cents: number) => 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 toggleInvoice = (id: string) => {
const next = new Set(selectedInvoices); const next = new Set(selectedInvoices);
if (next.has(id)) next.delete(id); if (next.has(id)) {
else next.add(id); next.delete(id);
} else {
next.add(id);
}
setSelectedInvoices(next); setSelectedInvoices(next);
}; };
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
const handlePay = async () => { const handlePay = async () => {
if (!stripe || !elements) return;
setIsProcessing(true); setIsProcessing(true);
setError(null); await new Promise((resolve) => setTimeout(resolve, 1500));
setIsProcessing(false);
try { setIsComplete(true);
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);
}
}; };
const selectedTotal = pending
.filter((i) => selectedInvoices.has(i.id))
.reduce((sum, i) => sum + i.totalCents, 0);
if (isComplete) { if (isComplete) {
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <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"> <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. Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
</p> </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 Done
</button> </button>
</div> </div>
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</p> </p>
</div> </div>
</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> </label>
))} ))}
</div> </div>
<div className="border-t border-stone-200 pt-4 mb-6"> <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-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> </div>
<PaymentElement />
</div> </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"> <div className="flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</button> </button>
<button <button
onClick={handlePay} 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" 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"} {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; export default BillingPayments;
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [ navigateFallbackDenylist: [
/^\/api\/auth\//, /^\/api\/auth\/oauth2\/callback\//,
], ],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^http.*\/api\/(?!auth\/).*/i, urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst", handler: "NetworkFirst",
options: { options: {
cacheName: "api-cache", 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");
@@ -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": {}
}
-14
View File
@@ -176,20 +176,6 @@
"when": 1775396067192, "when": 1775396067192,
"tag": "0024_invoice_indexes", "tag": "0024_invoice_indexes",
"breakpoints": true "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
} }
] ]
} }
-1
View File
@@ -71,7 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
address: "1 Main St, Springfield, CA 90000", address: "1 Main St, Springfield, CA 90000",
notes: null, notes: null,
emailOptOut: false, emailOptOut: false,
stripeCustomerId: null,
status: "active", status: "active",
disabledAt: null, disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"), createdAt: new Date("2025-01-01T00:00:00Z"),
+1 -5
View File
@@ -109,8 +109,8 @@ export const clients = pgTable("clients", {
phone: text("phone"), phone: text("phone"),
address: text("address"), address: text("address"),
notes: text("notes"), notes: text("notes"),
// Set to true if the client has opted out of email reminders/notifications
emailOptOut: boolean("email_opt_out").notNull().default(false), emailOptOut: boolean("email_opt_out").notNull().default(false),
stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"), status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"), disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
@@ -251,9 +251,6 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"), status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"), paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"), paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -262,7 +259,6 @@ export const invoices = pgTable(
index("idx_invoices_client_id").on(t.clientId), index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status), index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt), index("idx_invoices_created_at").on(t.createdAt),
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
] ]
); );
+133 -238
View File
@@ -1,19 +1,20 @@
/** /**
* Seed script — generates deterministic, PII-free test data for Groom Book. * Seed script — generates deterministic, PII-free test data for Groom Book.
* *
* Creates: * Supports three profiles via SEED_PROFILE env var:
* - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total) * - dev: 4 staff, 100 clients, ~1000 invoices, appointments 7d back / 30d forward
* - 10 services * - uat: 8 staff, 500 clients, ~4000 invoices, appointments 30d back / 90d forward
* - 500 clients, each with 1-3 dogs * - demo: Same data volume as UAT (for production-like demo environments)
* - ~2 500 appointments spread across the past 12 months *
* - Invoices for completed appointments with line items and tip splits * Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility.
* - Grooming visit logs for completed appointments *
* SEED_KNOWN_USERS_ONLY=true: Minimal prod/demo seed with demo users only.
* *
* Output is fully deterministic: the same seed value always produces the * Output is fully deterministic: the same seed value always produces the
* same rows with the same IDs. * same rows with the same IDs.
* *
* Usage: * 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"; import postgres from "postgres";
@@ -21,54 +22,6 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js"; 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) ────────────────────────────────────────── // ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
/** /**
@@ -87,6 +40,50 @@ function createPrng(seed: number): () => number {
const rand = createPrng(42); 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 ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
/** Return a random element from an array using the seeded PRNG. */ /** Return a random element from an array using the seeded PRNG. */
@@ -184,7 +181,7 @@ const dogBreeds = [
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise", "Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie", "West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd", "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", "Mixed Breed", "Mixed Breed", "Mixed Breed",
]; ];
@@ -281,44 +278,6 @@ const productsUsed = [
"Coconut oil shampoo, leave-in conditioner, cologne", "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 ────────────────────────────────────────────────────── // ── Service definitions ──────────────────────────────────────────────────────
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent: // Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name). // first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
@@ -553,32 +512,61 @@ async function seed() {
process.exit(1); process.exit(1);
} }
// Lean prod/demo seed — known users only, no large dataset
if (process.env.SEED_KNOWN_USERS_ONLY === "true") { if (process.env.SEED_KNOWN_USERS_ONLY === "true") {
await seedKnownUsers(); await seedKnownUsers();
return; return;
} }
const profile = getProfile(); const rawProfile = process.env.SEED_PROFILE?.toLowerCase();
const cfg = profiles[profile]; const profile: SeedProfile | undefined = (rawProfile === "dev" || rawProfile === "uat" || rawProfile === "demo")
? rawProfile
: undefined;
const config = getProfileConfig(profile);
const client = postgres(url, { max: 5 }); const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema }); 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 ── // ── Staff ──
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => // Deterministic staff IDs so they can be referenced in scripts/tests
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) const staffNames = [
); { name: "Jordan Lee", email: "jordan@groombook.dev" },
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => { name: "Sam Rivera", email: "sam@groombook.dev" },
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) { name: "Sarah Mitchell", email: "sarah@groombook.dev" },
); { name: "James Park", email: "james@groombook.dev" },
const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) => { name: "Maria Gonzalez", email: "maria@groombook.dev" },
({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) { name: "Tyler Johnson", email: "tyler@groombook.dev" },
); { name: "Ashley Chen", email: "ashley@groombook.dev" },
const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) => { name: "Devon Williams", email: "devon@groombook.dev" },
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) ];
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`); 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]; const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
@@ -597,10 +585,6 @@ async function seed() {
set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true }, 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 ── // ── SEED_ADMIN_EMAIL admin ──
const adminEmail = process.env.SEED_ADMIN_EMAIL; const adminEmail = process.env.SEED_ADMIN_EMAIL;
@@ -648,10 +632,10 @@ async function seed() {
// ── Clients & Pets ── // ── Clients & Pets ──
const now = new Date(); const now = new Date();
const appointmentsBackDate = new Date(now); const appointmentsBack = new Date(now);
appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays); appointmentsBack.setDate(appointmentsBack.getDate() - config.appointments.daysBack);
const appointmentsForwardDate = new Date(now); const appointmentsForward = new Date(now);
appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays); appointmentsForward.setDate(appointmentsForward.getDate() + config.appointments.daysForward);
interface ClientRecord { id: string; name: string } interface ClientRecord { id: string; name: string }
interface PetRecord { id: string; clientId: string } interface PetRecord { id: string; clientId: string }
@@ -659,9 +643,9 @@ async function seed() {
const clientRecords: ClientRecord[] = []; const clientRecords: ClientRecord[] = [];
const petRecords: PetRecord[] = []; 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; 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 clientBatch: (typeof schema.clients.$inferInsert)[] = [];
const petBatch: (typeof schema.pets.$inferInsert)[] = []; const petBatch: (typeof schema.pets.$inferInsert)[] = [];
@@ -691,7 +675,7 @@ async function seed() {
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3; const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
for (let p = 0; p < petCount; p++) { for (let p = 0; p < petCount; p++) {
const petId = uuid(); const petId = uuid();
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds); const breed = pick(dogBreeds);
const dob = new Date(now); const dob = new Date(now);
dob.setFullYear(dob.getFullYear() - randInt(1, 14)); dob.setFullYear(dob.getFullYear() - randInt(1, 14));
dob.setMonth(randInt(0, 11)); dob.setMonth(randInt(0, 11));
@@ -710,11 +694,9 @@ async function seed() {
shampooPreference: pick(shampoos), shampooPreference: pick(shampoos),
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
customFields: {}, customFields: {},
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
}); });
petRecords.push({ id: petId, clientId }); petRecords.push({ id: petId, clientId });
petIndex++;
} }
} }
@@ -745,29 +727,27 @@ async function seed() {
shampooPreference: pet.shampooPreference, shampooPreference: pet.shampooPreference,
specialCareNotes: pet.specialCareNotes, specialCareNotes: pet.specialCareNotes,
customFields: pet.customFields, 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) ───────────────────────────── // ── UAT test clients (guaranteed pending invoices) ─────────────────────────────
// These 5 clients are deterministic and documented in Shedward AGENTS.md so // These 5 clients are deterministic and documented in Shedward AGENTS.md so
// UAT can reliably find billing test data without searching. // UAT can reliably find billing test data without searching.
if (cfg.includeUatClients) { interface UatClient {
interface UatClient { id: string;
id: string; name: string;
name: string; email: string;
email: string; phone: string;
phone: string; address: string;
address: string; petId: string;
petId: string; petName: string;
petName: string; petBreed: string;
petBreed: string; }
} const uatClients: UatClient[] = [
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 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 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" }, { 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" },
@@ -780,20 +760,18 @@ async function seed() {
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) .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 } }); .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
await db.insert(schema.pets) 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) }) .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"), 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") } });
// Create one completed appointment for this client // Create one completed appointment for this client
const apptId = uuid(); const apptId = uuid();
const svcIdx = 0; const svcIdx = 0;
const svc = servicesDef[svcIdx]!; 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); completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000); 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({ await db.insert(schema.appointments).values({
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id, id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: groomers[0]!.id,
batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price, batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
}); });
// Create a PENDING invoice for that appointment // Create a PENDING invoice for that appointment
const invoiceId = uuid(); const invoiceId = uuid();
@@ -811,12 +789,17 @@ async function seed() {
id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id, id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id,
cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime, 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 ── // ── 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][] = [ const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [
"completed", "completed", "completed", "completed", "completed", "completed", "completed", "completed", "completed", "completed",
"completed", "completed", "scheduled", "confirmed", "cancelled", "no_show", "completed", "completed", "scheduled", "confirmed", "cancelled", "no_show",
@@ -867,8 +850,7 @@ async function seed() {
for (const client of clientRecords) { for (const client of clientRecords) {
const pets = petsByClient.get(client.id) ?? []; const pets = petsByClient.get(client.id) ?? [];
// Each client visits ~3-8 times over the year const visitCount = randInt(visitCountMin, visitCountMax);
const visitCount = randInt(3, 8);
for (let v = 0; v < visitCount; v++) { for (let v = 0; v < visitCount; v++) {
// Pick a random pet for this visit // Pick a random pet for this visit
@@ -877,15 +859,15 @@ async function seed() {
const serviceId = serviceIds[serviceIdx]!; const serviceId = serviceIds[serviceIdx]!;
const svc = servicesDef[serviceIdx]!; const svc = servicesDef[serviceIdx]!;
const groomer = pick(groomers); 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); const status = pick(statuses);
// Schedule within the configured appointment window // Schedule within the configured appointment window
let startTime: Date; let startTime: Date;
if (status === "scheduled" || status === "confirmed") { if (status === "scheduled" || status === "confirmed") {
startTime = randDate(now, appointmentsForwardDate); startTime = randDate(now, appointmentsForward);
} else { } else {
startTime = randDate(appointmentsBackDate, now); startTime = randDate(appointmentsBack, now);
} }
// Snap to business hours (8am - 5pm) // Snap to business hours (8am - 5pm)
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
@@ -989,93 +971,6 @@ async function seed() {
console.log(`✓ Created ${appointmentCount} appointments`); console.log(`✓ Created ${appointmentCount} appointments`);
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); 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(`✓ Created ${visitLogCount} grooming visit logs`);
console.log("\nSeed complete!"); console.log("\nSeed complete!");
+1 -71
View File
@@ -40,9 +40,6 @@ importers:
nodemailer: nodemailer:
specifier: ^6.9.16 specifier: ^6.9.16
version: 6.10.1 version: 6.10.1
stripe:
specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15)
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -86,17 +83,11 @@ importers:
'@groombook/types': '@groombook/types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/types 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': '@tailwindcss/vite':
specifier: ^4.2.2 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)) 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: 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)) 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: lucide-react:
specifier: ^0.577.0 specifier: ^0.577.0
@@ -2118,17 +2109,6 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} 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': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -3628,10 +3608,6 @@ packages:
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} 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: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3723,10 +3699,6 @@ packages:
nwsapi@2.2.23: nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} 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: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3844,9 +3816,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} 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: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3859,9 +3828,6 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.4 react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -4158,15 +4124,6 @@ packages:
strip-literal@3.1.0: strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 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: strnum@2.2.1:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -6714,15 +6671,6 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@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': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10
@@ -8277,10 +8225,6 @@ snapshots:
lodash@4.17.23: {} lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {} loupe@3.2.1: {}
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
@@ -8355,8 +8299,6 @@ snapshots:
nwsapi@2.2.23: {} nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
object-keys@1.1.1: {} object-keys@1.1.1: {}
@@ -8461,12 +8403,6 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 17.0.2 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: {} punycode@2.3.1: {}
randombytes@2.1.0: randombytes@2.1.0:
@@ -8478,8 +8414,6 @@ snapshots:
react: 19.2.4 react: 19.2.4
scheduler: 0.27.0 scheduler: 0.27.0
react-is@16.13.1: {}
react-is@17.0.2: {} react-is@17.0.2: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
@@ -8840,10 +8774,6 @@ snapshots:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {} strnum@2.2.1: {}
supports-color@7.2.0: supports-color@7.2.0: