Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e72a1441 | |||
| 16fb887bbf | |||
| c01c8d93d7 | |||
| e8c81bfccd |
@@ -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"
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -92,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);
|
||||||
@@ -117,7 +112,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
|
|||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
api.use("/invoices/*", requireRole("manager"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
|
|
||||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||||
|
|||||||
@@ -27,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.
|
||||||
*
|
*
|
||||||
@@ -167,11 +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);
|
|
||||||
|
|
||||||
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
|
|
||||||
|
|
||||||
// Build Better-Auth instance using resolved config
|
// Build Better-Auth instance using resolved config
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
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: {
|
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
|
||||||
|
|||||||
@@ -338,69 +338,3 @@ invoicesRouter.patch(
|
|||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Issue a refund on a paid invoice (Stripe integration placeholder)
|
|
||||||
const refundSchema = z.object({
|
|
||||||
amountCents: z.number().int().positive().optional(), // omitting = full refund
|
|
||||||
});
|
|
||||||
|
|
||||||
invoicesRouter.post(
|
|
||||||
"/:id/refund",
|
|
||||||
zValidator("json", refundSchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
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: "Can only refund paid invoices" }, 422);
|
|
||||||
|
|
||||||
const refundAmount = body.amountCents ?? invoice.totalCents;
|
|
||||||
|
|
||||||
// TODO: Integrate Stripe here
|
|
||||||
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
||||||
// await stripe.refunds.create({ payment_intent: invoice.stripePaymentIntentId, amount: refundAmount });
|
|
||||||
|
|
||||||
// For now, log and mark as refunded in a future version
|
|
||||||
return c.json({ message: "Refund endpoint ready — Stripe integration pending", refundAmount, status: "pending" });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Payment stats for admin dashboard
|
|
||||||
invoicesRouter.get("/stats/summary", async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
|
|
||||||
const [revenueResult] = await db
|
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.status, "paid"));
|
|
||||||
|
|
||||||
const [outstandingResult] = await db
|
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.status, "pending"));
|
|
||||||
|
|
||||||
const [refundsResult] = await db
|
|
||||||
.select({ total: sql<number>`coalesce(sum(tip_cents), 0)` })
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.status, "paid"));
|
|
||||||
|
|
||||||
const methodBreakdown = await db
|
|
||||||
.select({
|
|
||||||
method: invoices.paymentMethod,
|
|
||||||
total: sql<number>`count(*)`,
|
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.status, "paid"))
|
|
||||||
.groupBy(invoices.paymentMethod);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
revenueThisMonth: revenueResult?.total ?? 0,
|
|
||||||
outstanding: outstandingResult?.total ?? 0,
|
|
||||||
refundsThisMonth: refundsResult?.total ?? 0,
|
|
||||||
methodBreakdown,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
|
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() {
|
function LoginPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [providers, setProviders] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLogin = async () => {
|
||||||
fetch("/api/auth/providers")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => setProviders(data.providers ?? []))
|
|
||||||
.catch(() => setProviders([]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSocialLogin = async (provider: string) => {
|
|
||||||
setIsLoading(true);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -65,89 +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>
|
||||||
{isGoogle && (
|
<button
|
||||||
<button
|
onClick={handleLogin}
|
||||||
onClick={() => handleSocialLogin("google")}
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
style={{
|
||||||
style={{
|
padding: "0.6rem 1.5rem",
|
||||||
display: "flex",
|
borderRadius: 6,
|
||||||
alignItems: "center",
|
border: "none",
|
||||||
justifyContent: "center",
|
background: "#4f8a6f",
|
||||||
gap: 8,
|
color: "#fff",
|
||||||
width: "100%",
|
fontWeight: 600,
|
||||||
padding: "0.6rem 1.5rem",
|
fontSize: 14,
|
||||||
borderRadius: 6,
|
cursor: isLoading ? "wait" : "pointer",
|
||||||
border: "1px solid #e2e8f0",
|
opacity: isLoading ? 0.7 : 1,
|
||||||
background: "#fff",
|
}}
|
||||||
color: "#1a202c",
|
>
|
||||||
fontWeight: 600,
|
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||||
fontSize: 14,
|
</button>
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -173,9 +173,6 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
|
||||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
|
||||||
const [partialAmount, setPartialAmount] = useState("");
|
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -274,35 +271,6 @@ function InvoiceDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function issueRefund() {
|
|
||||||
const amountCents = refundType === "partial"
|
|
||||||
? Math.round(parseFloat(partialAmount) * 100)
|
|
||||||
: undefined;
|
|
||||||
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
|
|
||||||
setError("Enter a valid refund amount");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(amountCents ? { amountCents } : {}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json()) as { error?: string };
|
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
setShowRefundDialog(false);
|
|
||||||
onUpdated();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to issue refund");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
||||||
|
|
||||||
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
@@ -479,76 +447,10 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||||
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowRefundDialog(true)}
|
|
||||||
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
|
|
||||||
>
|
|
||||||
Refund
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refund Dialog */}
|
|
||||||
{showRefundDialog && (
|
|
||||||
<Modal onClose={() => setShowRefundDialog(false)}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
|
|
||||||
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
|
||||||
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
|
|
||||||
</p>
|
|
||||||
<div style={{ marginBottom: "0.75rem" }}>
|
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="refundType"
|
|
||||||
value="full"
|
|
||||||
checked={refundType === "full"}
|
|
||||||
onChange={() => setRefundType("full")}
|
|
||||||
/>
|
|
||||||
Full refund
|
|
||||||
</label>
|
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="refundType"
|
|
||||||
value="partial"
|
|
||||||
checked={refundType === "partial"}
|
|
||||||
onChange={() => setRefundType("partial")}
|
|
||||||
/>
|
|
||||||
Partial refund
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{refundType === "partial" && (
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
value={partialAmount}
|
|
||||||
onChange={(e) => setPartialAmount(e.target.value)}
|
|
||||||
style={{ ...inputStyle, width: 120 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
|
||||||
<button
|
|
||||||
onClick={issueRefund}
|
|
||||||
disabled={saving}
|
|
||||||
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
|
|
||||||
>
|
|
||||||
{saving ? "Processing…" : "Issue Refund"}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -590,17 +492,9 @@ export function InvoicesPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/invoices/stats/summary")
|
|
||||||
.then((r) => r.ok ? r.json() : null)
|
|
||||||
.then((data) => { if (data) setPaymentStats(data); })
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadInvoices(newOffset: number) {
|
async function loadInvoices(newOffset: number) {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
@@ -679,34 +573,6 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Stats Summary */}
|
|
||||||
{paymentStats && (
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
|
||||||
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
|
|
||||||
</div>
|
|
||||||
{paymentStats.methodBreakdown.length > 0 && (
|
|
||||||
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#64748b" }}>
|
|
||||||
{paymentStats.methodBreakdown.map((b) => (
|
|
||||||
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoiceList.length === 0 ? (
|
{invoiceList.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280" }}>
|
<p style={{ color: "#6b7280" }}>
|
||||||
No invoices yet. Create one from a completed appointment.
|
No invoices yet. Create one from a completed appointment.
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
@@ -251,7 +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"),
|
|
||||||
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(),
|
||||||
|
|||||||
@@ -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: false })
|
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!");
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export interface Invoice {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
paymentMethod: PaymentMethod | null;
|
paymentMethod: PaymentMethod | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
stripePaymentIntentId: string | null;
|
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||