Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e72a1441 | |||
| 16fb887bbf | |||
| c01c8d93d7 | |||
| e8c81bfccd |
@@ -62,8 +62,6 @@ jobs:
|
||||
fi
|
||||
|
||||
# Update seed Job name to include short SHA (immutable template fix)
|
||||
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
|
||||
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { cors } from "hono/cors";
|
||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||
import { getAuth, initAuth } from "./lib/auth.js";
|
||||
import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
@@ -92,11 +92,6 @@ app.get("/api/setup/status", async (c) => {
|
||||
return c.json({ needsSetup: !superUser });
|
||||
});
|
||||
|
||||
// Public auth providers endpoint — no auth required, tells frontend which login options are available
|
||||
app.get("/api/auth/providers", async (c) => {
|
||||
return c.json({ providers: getActiveProviders() });
|
||||
});
|
||||
|
||||
// Protected API routes
|
||||
const api = app.basePath("/api");
|
||||
api.use("*", authMiddleware);
|
||||
|
||||
@@ -27,21 +27,6 @@ export function getAuthPromise() {
|
||||
return authInitPromise;
|
||||
}
|
||||
|
||||
/** Returns which OAuth/social providers are configured via env vars. */
|
||||
export function getActiveProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
providers.push("google");
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
providers.push("github");
|
||||
}
|
||||
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
|
||||
providers.push("authentik");
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-initializes the Better-Auth instance after auth config changes.
|
||||
*
|
||||
@@ -167,11 +152,6 @@ export async function initAuth(): Promise<void> {
|
||||
console.log("[auth] Using env var config (no DB config found)");
|
||||
}
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
|
||||
|
||||
// Build Better-Auth instance using resolved config
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -199,23 +179,7 @@ export async function initAuth(): Promise<void> {
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
...(hasGoogle ? {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
redirectURI: `${callbackBase}/google`,
|
||||
},
|
||||
} : {}),
|
||||
...(hasGitHub ? {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
redirectURI: `${callbackBase}/github`,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
],
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
|
||||
@@ -44,10 +44,7 @@ test.beforeEach(async ({ page }) => {
|
||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/invoices")) {
|
||||
return route.fulfill({ json: { data: [], total: 0 } });
|
||||
}
|
||||
// Appointments, clients, services, staff, book, etc.
|
||||
// Appointments, clients, services, staff, invoices, book, etc.
|
||||
return route.fulfill({ json: [] });
|
||||
});
|
||||
});
|
||||
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
|
||||
|
||||
test("admin invoices page loads", async ({ page }) => {
|
||||
await page.goto("/admin/invoices");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
|
||||
});
|
||||
|
||||
|
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>
|
||||
|
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>
|
||||
|
Before Width: | Height: | Size: 260 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 226 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 279 KiB |
@@ -22,24 +22,12 @@ import { useSession, signIn } from "./lib/auth-client.js";
|
||||
|
||||
function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/providers")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setProviders(data.providers ?? []))
|
||||
.catch(() => setProviders([]));
|
||||
}, []);
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
await signIn.social({ provider, callbackURL: window.location.origin });
|
||||
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
|
||||
};
|
||||
|
||||
const isGoogle = providers.includes("google");
|
||||
const isGitHub = providers.includes("github");
|
||||
const isAuthentik = providers.includes("authentik");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -65,89 +53,23 @@ function LoginPage() {
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||
Sign in to continue
|
||||
</p>
|
||||
{isGoogle && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#1a202c",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
)}
|
||||
{isGitHub && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#24292f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: isAuthentik ? "0.5rem" : 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
{isAuthentik && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("authentik")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -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,19 +1,20 @@
|
||||
/**
|
||||
* Seed script — generates deterministic, PII-free test data for Groom Book.
|
||||
*
|
||||
* Creates:
|
||||
* - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total)
|
||||
* - 10 services
|
||||
* - 500 clients, each with 1-3 dogs
|
||||
* - ~2 500 appointments spread across the past 12 months
|
||||
* - Invoices for completed appointments with line items and tip splits
|
||||
* - Grooming visit logs for completed appointments
|
||||
* Supports three profiles via SEED_PROFILE env var:
|
||||
* - dev: 4 staff, 100 clients, ~1000 invoices, appointments 7d back / 30d forward
|
||||
* - uat: 8 staff, 500 clients, ~4000 invoices, appointments 30d back / 90d forward
|
||||
* - demo: Same data volume as UAT (for production-like demo environments)
|
||||
*
|
||||
* Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility.
|
||||
*
|
||||
* SEED_KNOWN_USERS_ONLY=true: Minimal prod/demo seed with demo users only.
|
||||
*
|
||||
* Output is fully deterministic: the same seed value always produces the
|
||||
* same rows with the same IDs.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
|
||||
* DATABASE_URL=postgres://... SEED_PROFILE=dev npx tsx packages/db/src/seed.ts
|
||||
*/
|
||||
|
||||
import postgres from "postgres";
|
||||
@@ -21,54 +22,6 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
type SeedProfile = "dev" | "uat" | "demo";
|
||||
|
||||
interface ProfileConfig {
|
||||
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
||||
clientCount: number;
|
||||
appointmentsBackDays: number;
|
||||
appointmentsForwardDays: number;
|
||||
invoiceCount: number;
|
||||
includeUatClients: boolean;
|
||||
}
|
||||
|
||||
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||
dev: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||
clientCount: 100,
|
||||
appointmentsBackDays: 7,
|
||||
appointmentsForwardDays: 30,
|
||||
invoiceCount: 1000,
|
||||
includeUatClients: false,
|
||||
},
|
||||
uat: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clientCount: 500,
|
||||
appointmentsBackDays: 30,
|
||||
appointmentsForwardDays: 90,
|
||||
invoiceCount: 4000,
|
||||
includeUatClients: true,
|
||||
},
|
||||
demo: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clientCount: 500,
|
||||
appointmentsBackDays: 30,
|
||||
appointmentsForwardDays: 90,
|
||||
invoiceCount: 4000,
|
||||
includeUatClients: true,
|
||||
},
|
||||
};
|
||||
|
||||
function getProfile(): SeedProfile {
|
||||
const raw = process.env.SEED_PROFILE?.toLowerCase();
|
||||
if (raw === "dev" || raw === "uat" || raw === "demo") {
|
||||
return raw;
|
||||
}
|
||||
return "uat";
|
||||
}
|
||||
|
||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -87,6 +40,50 @@ function createPrng(seed: number): () => number {
|
||||
|
||||
const rand = createPrng(42);
|
||||
|
||||
// ── Seed profile configuration ───────────────────────────────────────────────
|
||||
|
||||
type SeedProfile = "dev" | "uat" | "demo";
|
||||
|
||||
interface ProfileConfig {
|
||||
staff: {
|
||||
manager: number;
|
||||
receptionist: number;
|
||||
groomer: number;
|
||||
bather: number;
|
||||
};
|
||||
clients: number;
|
||||
appointments: {
|
||||
daysBack: number;
|
||||
daysForward: number;
|
||||
};
|
||||
targetInvoices: number;
|
||||
}
|
||||
|
||||
function getProfileConfig(profile: SeedProfile | undefined): ProfileConfig {
|
||||
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||
dev: {
|
||||
staff: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||
clients: 100,
|
||||
appointments: { daysBack: 7, daysForward: 30 },
|
||||
targetInvoices: 1000,
|
||||
},
|
||||
uat: {
|
||||
staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clients: 500,
|
||||
appointments: { daysBack: 30, daysForward: 90 },
|
||||
targetInvoices: 4000,
|
||||
},
|
||||
demo: {
|
||||
staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clients: 500,
|
||||
appointments: { daysBack: 30, daysForward: 90 },
|
||||
targetInvoices: 4000,
|
||||
},
|
||||
};
|
||||
if (!profile || profile === "uat") return profiles.uat;
|
||||
return profiles[profile] ?? profiles.uat;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return a random element from an array using the seeded PRNG. */
|
||||
@@ -184,7 +181,7 @@ const dogBreeds = [
|
||||
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
|
||||
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
|
||||
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd",
|
||||
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle",
|
||||
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner",
|
||||
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
||||
];
|
||||
|
||||
@@ -281,44 +278,6 @@ const productsUsed = [
|
||||
"Coconut oil shampoo, leave-in conditioner, cologne",
|
||||
];
|
||||
|
||||
const demoPetImages = [
|
||||
"/demo-pets/dog-golden-after.png",
|
||||
"/demo-pets/dog-poodle-groomed.png",
|
||||
"/demo-pets/dog-black-lab.png",
|
||||
"/demo-pets/dog-shih-tzu.png",
|
||||
"/demo-pets/dog-cocker-spaniel.png",
|
||||
"/demo-pets/dog-schnauzer.png",
|
||||
"/demo-pets/dog-maltese.png",
|
||||
"/demo-pets/dog-dachshund.png",
|
||||
"/demo-pets/dog-pomeranian.png",
|
||||
"/demo-pets/dog-bichon-frise.png",
|
||||
"/demo-pets/dog-golden-retriever.png",
|
||||
"/demo-pets/dog-labrador.png",
|
||||
"/demo-pets/dog-mixed-breed.png",
|
||||
"/demo-pets/dog-poodle.png",
|
||||
"/demo-pets/dog-terrier.png",
|
||||
"/demo-pets/dog-afghan-hound.png",
|
||||
"/demo-pets/dog-basset-brown-white.png",
|
||||
"/demo-pets/dog-bichon-white-groomed.png",
|
||||
"/demo-pets/dog-boxer-fawn-athletic.png",
|
||||
"/demo-pets/dog-cavalier-cream-gentle.png",
|
||||
"/demo-pets/dog-cocker-buff-friendly.png",
|
||||
"/demo-pets/dog-corgi.png",
|
||||
"/demo-pets/dog-dachshund-black-tan.png",
|
||||
"/demo-pets/dog-golden-before.png",
|
||||
"/demo-pets/dog-pomeranian-white-studio.png",
|
||||
"/demo-pets/dog-schnauzer-black-groomed.png",
|
||||
"/demo-pets/dog-setter-red-sunlit.png",
|
||||
"/demo-pets/dog-sheepdog-merle-running.png",
|
||||
];
|
||||
|
||||
const puggleImages = [
|
||||
"/demo-pets/dog-puggle-fawn-playful.png",
|
||||
"/demo-pets/dog-puggle-black-sitting.png",
|
||||
"/demo-pets/dog-puggle-cream-groomed.png",
|
||||
"/demo-pets/dog-puggle-fawn-grooming.png",
|
||||
];
|
||||
|
||||
// ── Service definitions ──────────────────────────────────────────────────────
|
||||
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
|
||||
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
|
||||
@@ -553,32 +512,61 @@ async function seed() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Lean prod/demo seed — known users only, no large dataset
|
||||
if (process.env.SEED_KNOWN_USERS_ONLY === "true") {
|
||||
await seedKnownUsers();
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getProfile();
|
||||
const cfg = profiles[profile];
|
||||
const rawProfile = process.env.SEED_PROFILE?.toLowerCase();
|
||||
const profile: SeedProfile | undefined = (rawProfile === "dev" || rawProfile === "uat" || rawProfile === "demo")
|
||||
? rawProfile
|
||||
: undefined;
|
||||
const config = getProfileConfig(profile);
|
||||
|
||||
const client = postgres(url, { max: 5 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
|
||||
const profileLabel = profile ? ` (${profile})` : "";
|
||||
console.log(`Seeding Groom Book database${profileLabel}...\n`);
|
||||
|
||||
// ── Staff ──
|
||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
||||
);
|
||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||
);
|
||||
const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) =>
|
||||
({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
|
||||
);
|
||||
const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) =>
|
||||
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
|
||||
// Deterministic staff IDs so they can be referenced in scripts/tests
|
||||
const staffNames = [
|
||||
{ name: "Jordan Lee", email: "jordan@groombook.dev" },
|
||||
{ name: "Sam Rivera", email: "sam@groombook.dev" },
|
||||
{ name: "Sarah Mitchell", email: "sarah@groombook.dev" },
|
||||
{ name: "James Park", email: "james@groombook.dev" },
|
||||
{ name: "Maria Gonzalez", email: "maria@groombook.dev" },
|
||||
{ name: "Tyler Johnson", email: "tyler@groombook.dev" },
|
||||
{ name: "Ashley Chen", email: "ashley@groombook.dev" },
|
||||
{ name: "Devon Williams", email: "devon@groombook.dev" },
|
||||
];
|
||||
|
||||
const managerStaff = staffNames.slice(0, config.staff.manager).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "manager" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
const receptionistStaff = staffNames.slice(config.staff.manager, config.staff.manager + config.staff.receptionist).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "receptionist" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
const groomers = staffNames.slice(config.staff.manager + config.staff.receptionist, config.staff.manager + config.staff.receptionist + config.staff.groomer).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
|
||||
const bathers = staffNames.slice(config.staff.manager + config.staff.receptionist + config.staff.groomer, config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
const totalStaff = config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather;
|
||||
console.log(`✓ Creating ${totalStaff} staff (${config.staff.manager} manager, ${config.staff.receptionist} receptionist, ${config.staff.groomer} groomers, ${config.staff.bather} bathers)`);
|
||||
|
||||
// Truncate downstream tables before staff upsert — clears stale impersonation
|
||||
// sessions from prior seed runs so the FK constraint on staff_id is never
|
||||
// violated when ON CONFLICT DO UPDATE touches staff rows that still have
|
||||
// impersonation_sessions references.
|
||||
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
|
||||
|
||||
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||||
@@ -597,10 +585,6 @@ async function seed() {
|
||||
set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
|
||||
});
|
||||
}
|
||||
const staffLabel = cfg.staffCount.bather > 0
|
||||
? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)`
|
||||
: `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`;
|
||||
console.log(`✓ Created ${staffLabel}`);
|
||||
|
||||
// ── SEED_ADMIN_EMAIL admin ──
|
||||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||||
@@ -648,10 +632,10 @@ async function seed() {
|
||||
|
||||
// ── Clients & Pets ──
|
||||
const now = new Date();
|
||||
const appointmentsBackDate = new Date(now);
|
||||
appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays);
|
||||
const appointmentsForwardDate = new Date(now);
|
||||
appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays);
|
||||
const appointmentsBack = new Date(now);
|
||||
appointmentsBack.setDate(appointmentsBack.getDate() - config.appointments.daysBack);
|
||||
const appointmentsForward = new Date(now);
|
||||
appointmentsForward.setDate(appointmentsForward.getDate() + config.appointments.daysForward);
|
||||
|
||||
interface ClientRecord { id: string; name: string }
|
||||
interface PetRecord { id: string; clientId: string }
|
||||
@@ -659,9 +643,9 @@ async function seed() {
|
||||
const clientRecords: ClientRecord[] = [];
|
||||
const petRecords: PetRecord[] = [];
|
||||
|
||||
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
|
||||
// Batch insert clients and pets
|
||||
const clientBatchSize = 50;
|
||||
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
|
||||
for (let batch = 0; batch < Math.ceil(config.clients / clientBatchSize); batch++) {
|
||||
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
|
||||
const petBatch: (typeof schema.pets.$inferInsert)[] = [];
|
||||
|
||||
@@ -691,7 +675,7 @@ async function seed() {
|
||||
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
||||
for (let p = 0; p < petCount; p++) {
|
||||
const petId = uuid();
|
||||
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds);
|
||||
const breed = pick(dogBreeds);
|
||||
const dob = new Date(now);
|
||||
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
|
||||
dob.setMonth(randInt(0, 11));
|
||||
@@ -710,11 +694,9 @@ async function seed() {
|
||||
shampooPreference: pick(shampoos),
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
});
|
||||
|
||||
petRecords.push({ id: petId, clientId });
|
||||
petIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,29 +727,27 @@ async function seed() {
|
||||
shampooPreference: pet.shampooPreference,
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`);
|
||||
console.log(`✓ Created ${config.clients} clients with ${petRecords.length} pets`);
|
||||
|
||||
// ── UAT test clients (guaranteed pending invoices) ─────────────────────────────
|
||||
// These 5 clients are deterministic and documented in Shedward AGENTS.md so
|
||||
// UAT can reliably find billing test data without searching.
|
||||
if (cfg.includeUatClients) {
|
||||
interface UatClient {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
interface UatClient {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
|
||||
@@ -780,20 +760,18 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") } });
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
const svc = servicesDef[svcIdx]!;
|
||||
const completedTime = randDate(appointmentsBackDate, now);
|
||||
const completedTime = randDate(appointmentsBack, now);
|
||||
completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000);
|
||||
const uatGroomer = groomers[0]!;
|
||||
const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer;
|
||||
await db.insert(schema.appointments).values({
|
||||
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id,
|
||||
batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
|
||||
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: groomers[0]!.id,
|
||||
batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
|
||||
});
|
||||
// Create a PENDING invoice for that appointment
|
||||
const invoiceId = uuid();
|
||||
@@ -811,12 +789,17 @@ async function seed() {
|
||||
id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id,
|
||||
cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime,
|
||||
});
|
||||
}
|
||||
console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`);
|
||||
}
|
||||
console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`);
|
||||
|
||||
// ── Appointments, Invoices, Visit Logs ──
|
||||
// Generate ~5 appointments per client on average = ~2500 total
|
||||
// Calculate visit count to achieve targetInvoices based on ~65% completion rate
|
||||
const completedRatio = 0.65;
|
||||
const totalVisitsNeeded = Math.ceil(config.targetInvoices / completedRatio);
|
||||
const avgVisitsPerClient = Math.ceil(totalVisitsNeeded / clientRecords.length);
|
||||
const visitCountMin = Math.max(1, Math.floor(avgVisitsPerClient * 0.7));
|
||||
const visitCountMax = Math.max(visitCountMin + 1, Math.ceil(avgVisitsPerClient * 1.3));
|
||||
|
||||
const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [
|
||||
"completed", "completed", "completed", "completed", "completed",
|
||||
"completed", "completed", "scheduled", "confirmed", "cancelled", "no_show",
|
||||
@@ -867,8 +850,7 @@ async function seed() {
|
||||
|
||||
for (const client of clientRecords) {
|
||||
const pets = petsByClient.get(client.id) ?? [];
|
||||
// Each client visits ~3-8 times over the year
|
||||
const visitCount = randInt(3, 8);
|
||||
const visitCount = randInt(visitCountMin, visitCountMax);
|
||||
|
||||
for (let v = 0; v < visitCount; v++) {
|
||||
// Pick a random pet for this visit
|
||||
@@ -877,15 +859,15 @@ async function seed() {
|
||||
const serviceId = serviceIds[serviceIdx]!;
|
||||
const svc = servicesDef[serviceIdx]!;
|
||||
const groomer = pick(groomers);
|
||||
const bather = rand() < 0.6 ? pick(bathers) : null;
|
||||
const bather = rand() < 0.6 && bathers.length > 0 ? pick(bathers) : null;
|
||||
const status = pick(statuses);
|
||||
|
||||
// Schedule within the configured appointment window
|
||||
let startTime: Date;
|
||||
if (status === "scheduled" || status === "confirmed") {
|
||||
startTime = randDate(now, appointmentsForwardDate);
|
||||
startTime = randDate(now, appointmentsForward);
|
||||
} else {
|
||||
startTime = randDate(appointmentsBackDate, now);
|
||||
startTime = randDate(appointmentsBack, now);
|
||||
}
|
||||
// Snap to business hours (8am - 5pm)
|
||||
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
@@ -989,93 +971,6 @@ async function seed() {
|
||||
|
||||
console.log(`✓ Created ${appointmentCount} appointments`);
|
||||
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`);
|
||||
|
||||
// ── Enforce target invoice count ───────────────────────────────────────────
|
||||
// If current invoice count is below target (due to profile having fewer
|
||||
// clients/appointments than the target ratio), generate supplemental
|
||||
// completed appointments for existing clients to fill the gap.
|
||||
if (invoiceCount < cfg.invoiceCount) {
|
||||
const additionalNeeded = cfg.invoiceCount - invoiceCount;
|
||||
console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`);
|
||||
|
||||
const existingClientIds = clientRecords.map(c => c.id);
|
||||
const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20);
|
||||
let supplementalCount = 0;
|
||||
let supplementalInvoices = 0;
|
||||
|
||||
for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) {
|
||||
const clientId = pick(existingClientIds);
|
||||
const pets = petsByClient.get(clientId) ?? [];
|
||||
if (pets.length === 0) continue;
|
||||
|
||||
const petId = pick(pets);
|
||||
const serviceIdx = randInt(0, serviceIds.length - 1);
|
||||
const serviceId = serviceIds[serviceIdx]!;
|
||||
const svc = servicesDef[serviceIdx]!;
|
||||
const groomer = pick(groomers);
|
||||
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
|
||||
|
||||
let startTime = randDate(appointmentsBackDate, now);
|
||||
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
||||
const effectivePrice = svc.price;
|
||||
|
||||
const apptId = uuid();
|
||||
apptBatch.push({
|
||||
id: apptId, clientId, petId, serviceId,
|
||||
staffId: groomer.id, batherStaffId: bather?.id ?? null,
|
||||
status: "completed", startTime, endTime, notes: null, priceCents: null,
|
||||
});
|
||||
appointmentCount++;
|
||||
supplementalCount++;
|
||||
|
||||
const invoiceId = uuid();
|
||||
const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0;
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId, appointmentId: apptId, clientId,
|
||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||
status: "paid" as const,
|
||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||
paidAt, notes: null,
|
||||
});
|
||||
lineItemBatch.push({
|
||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||
unitPriceCents: effectivePrice, totalCents: effectivePrice,
|
||||
});
|
||||
if (tipCents > 0) {
|
||||
if (bather) {
|
||||
const groomerShare = Math.round(tipCents * 0.6);
|
||||
const batherShare = tipCents - groomerShare;
|
||||
tipSplitBatch.push(
|
||||
{ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare },
|
||||
{ id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare },
|
||||
);
|
||||
} else {
|
||||
tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents });
|
||||
}
|
||||
}
|
||||
visitLogBatch.push({
|
||||
id: uuid(), petId, appointmentId: apptId, staffId: groomer.id,
|
||||
cutStyle: pick(cutStyles), productsUsed: pick(productsUsed),
|
||||
notes: pick(visitLogNotes), groomedAt: endTime,
|
||||
});
|
||||
invoiceCount++;
|
||||
supplementalInvoices++;
|
||||
visitLogCount++;
|
||||
|
||||
if (apptBatch.length >= apptBatchSize) {
|
||||
await flushBatches();
|
||||
}
|
||||
}
|
||||
|
||||
await flushBatches();
|
||||
console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`);
|
||||
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`);
|
||||
}
|
||||
console.log(`✓ Created ${visitLogCount} grooming visit logs`);
|
||||
console.log("\nSeed complete!");
|
||||
|
||||
|
||||