Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00470ad148 | |||
| 9cce0bc5d9 | |||
| 856096a531 | |||
| 2396eaab4d | |||
| 97b71d5396 | |||
| bbe95df9ca | |||
| 1380d5a9d3 | |||
| 41dff6f0e2 | |||
| 8002a3db96 | |||
| 88e6845027 | |||
| 085c8b9cfa | |||
| 1d76c63137 | |||
| 9a0a63d1df | |||
| 24a032dd9d | |||
| 13f2550ee2 | |||
| f29ac2e40d | |||
| 25dae6af58 | |||
| 4737fc9dd8 | |||
| bdcad0d9dc | |||
| 6da19d51fc | |||
| c919632aea | |||
| b1124e6a6c | |||
| 90794e4e14 | |||
| 522e5dbf63 | |||
| 39f603589b | |||
| 2ab06853e6 | |||
| e7fd820b31 | |||
| 373e35ef8e | |||
| 46416586ea | |||
| 515389e067 | |||
| 191e3499fc | |||
| 921d708ccd | |||
| fafb717e5a | |||
| 5b4562d5d7 | |||
| 7f405ccc67 | |||
| 916a2071d9 | |||
| 0c135ac580 | |||
| 4c1207a5ae | |||
| 8bfc6c970b | |||
| 1255fd91cd | |||
| b8b054316c | |||
| 0f944c537d | |||
| dd646fb273 |
@@ -11,6 +11,12 @@ AUTH_DISABLED=false
|
|||||||
OIDC_ISSUER=https://authentik.example.com
|
OIDC_ISSUER=https://authentik.example.com
|
||||||
OIDC_AUDIENCE=groombook
|
OIDC_AUDIENCE=groombook
|
||||||
|
|
||||||
|
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
||||||
|
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
||||||
|
# super user exists in the database. Useful in dev/test environments where the
|
||||||
|
# database has data but the setup wizard would otherwise block access.
|
||||||
|
SKIP_OOBE=false
|
||||||
|
|
||||||
# ── API ───────────────────────────────────────────────────────────────────────
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
PORT=3000
|
PORT=3000
|
||||||
CORS_ORIGIN=http://localhost:8080
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ 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"
|
||||||
|
|||||||
@@ -418,6 +418,48 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
|
|||||||
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
||||||
expect(body.authConfigExists).toBe(true);
|
expect(body.authConfigExists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
|
||||||
|
dbStaffRows = []; // no super user
|
||||||
|
dbAuthConfigRows = [];
|
||||||
|
process.env.SKIP_OOBE = "true";
|
||||||
|
|
||||||
|
const app = makeApp();
|
||||||
|
const { status, body } = await getStatus(app);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.needsSetup).toBe(false);
|
||||||
|
expect(body.showAuthProviderStep).toBe(false);
|
||||||
|
expect(body.authConfigExists).toBe(false);
|
||||||
|
expect(body.authEnvVarsSet).toBe(false);
|
||||||
|
expect(body.skipped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SKIP_OOBE=1 also bypasses setup check", async () => {
|
||||||
|
dbStaffRows = [];
|
||||||
|
dbAuthConfigRows = [];
|
||||||
|
process.env.SKIP_OOBE = "1";
|
||||||
|
|
||||||
|
const app = makeApp();
|
||||||
|
const { status, body } = await getStatus(app);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.needsSetup).toBe(false);
|
||||||
|
expect(body.skipped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SKIP_OOBE=yes also bypasses setup check", async () => {
|
||||||
|
dbStaffRows = [];
|
||||||
|
dbAuthConfigRows = [];
|
||||||
|
process.env.SKIP_OOBE = "yes";
|
||||||
|
|
||||||
|
const app = makeApp();
|
||||||
|
const { status, body } = await getStatus(app);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.needsSetup).toBe(false);
|
||||||
|
expect(body.skipped).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
||||||
|
|||||||
@@ -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 } from "./lib/auth.js";
|
import { getAuth, initAuth, getActiveProviders } 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,6 +92,11 @@ 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);
|
||||||
@@ -100,7 +105,13 @@ api.use("*", resolveStaffMiddleware);
|
|||||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
|
authRouter.all("/*", (c) => {
|
||||||
|
try {
|
||||||
|
return getAuth().handler(c.req.raw);
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Authentication not configured" }, 503);
|
||||||
|
}
|
||||||
|
});
|
||||||
api.route("/auth", authRouter);
|
api.route("/auth", authRouter);
|
||||||
|
|
||||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
||||||
import { decryptSecret } from "@groombook/db";
|
import { decryptSecret } from "@groombook/db";
|
||||||
|
import { sendEmail } from "../services/email.js";
|
||||||
|
|
||||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||||
@@ -27,6 +28,21 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -75,6 +91,12 @@ export async function initAuth(): Promise<void> {
|
|||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
max: 10,
|
||||||
|
window: 60,
|
||||||
|
storage: "database",
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
@@ -152,6 +174,55 @@ export async function initAuth(): Promise<void> {
|
|||||||
console.log("[auth] Using env var config (no DB config found)");
|
console.log("[auth] Using env var config (no DB config found)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
|
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
|
// Fetch OIDC discovery document to derive canonical provider URLs.
|
||||||
|
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
||||||
|
// while keeping authorizationUrl public for browser redirects.
|
||||||
|
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||||
|
let oidcConfig: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
const discoveryRes = await fetch(discoveryUrlStr);
|
||||||
|
if (discoveryRes.ok) {
|
||||||
|
const discovery = await discoveryRes.json() as {
|
||||||
|
authorization_endpoint?: string;
|
||||||
|
token_endpoint?: string;
|
||||||
|
userinfo_endpoint?: string;
|
||||||
|
};
|
||||||
|
const replaceHost = (url: string, newHost: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const newParsed = new URL(newHost);
|
||||||
|
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const authzUrl = discovery.authorization_endpoint;
|
||||||
|
const tokenUrl = discovery.token_endpoint;
|
||||||
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
|
oidcConfig = {
|
||||||
|
authorizationUrl: authzUrl,
|
||||||
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
|
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||||
|
: tokenUrl,
|
||||||
|
userInfoUrl: providerConfig.internalBaseUrl
|
||||||
|
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||||
|
: userInfoUrl,
|
||||||
|
};
|
||||||
|
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||||
|
} else {
|
||||||
|
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
|
||||||
|
}
|
||||||
|
|
||||||
// Build Better-Auth instance using resolved config
|
// Build Better-Auth instance using resolved config
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -159,6 +230,28 @@ export async function initAuth(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
secret: BETTER_AUTH_SECRET,
|
secret: BETTER_AUTH_SECRET,
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
max: 10,
|
||||||
|
window: 60,
|
||||||
|
storage: "database",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
storeStateStrategy: "cookie" as const,
|
||||||
|
},
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
emailVerification: {
|
||||||
|
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your GroomBook email",
|
||||||
|
text: `Click the link to verify your email: ${url}`,
|
||||||
|
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
@@ -166,20 +259,27 @@ export async function initAuth(): Promise<void> {
|
|||||||
providerId: providerConfig.providerId,
|
providerId: providerConfig.providerId,
|
||||||
clientId: providerConfig.clientId,
|
clientId: providerConfig.clientId,
|
||||||
clientSecret: providerConfig.clientSecret,
|
clientSecret: providerConfig.clientSecret,
|
||||||
...(providerConfig.internalBaseUrl
|
discoveryUrl: discoveryUrlStr,
|
||||||
? {
|
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
||||||
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
|
|
||||||
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
|
|
||||||
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
|
|
||||||
}),
|
|
||||||
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
socialProviders: {
|
||||||
|
...(hasGoogle ? {
|
||||||
|
google: {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
...(hasGitHub ? {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
updateAge: 60 * 60 * 24, // 1 day
|
updateAge: 60 * 60 * 24, // 1 day
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
|
|
||||||
if (c.req.path.startsWith("/api/auth/")) {
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
@@ -37,7 +36,14 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getAuth().api.getSession({
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = getAuth();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Authentication not configured" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
headers: c.req.raw.headers,
|
headers: c.req.raw.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, isNull, staff } from "@groombook/db";
|
import { eq, getDb, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -90,25 +90,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (!fallbackRow) {
|
if (!fallbackRow) {
|
||||||
// Auto-link: staff record exists with matching email but no userId — link it now
|
|
||||||
if (jwt.email) {
|
|
||||||
const [linkedStaff] = await db
|
|
||||||
.select()
|
|
||||||
.from(staff)
|
|
||||||
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
|
|
||||||
if (linkedStaff) {
|
|
||||||
await db
|
|
||||||
.update(staff)
|
|
||||||
.set({ userId: jwt.sub })
|
|
||||||
.where(eq(staff.id, linkedStaff.id));
|
|
||||||
console.log(
|
|
||||||
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
|
|
||||||
);
|
|
||||||
c.set("staff", linkedStaff);
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
@@ -9,6 +9,17 @@ export const setupRouter = new Hono<AppEnv>();
|
|||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
// and whether the auth provider bootstrap step should be shown
|
// and whether the auth provider bootstrap step should be shown
|
||||||
setupRouter.get("/status", async (c) => {
|
setupRouter.get("/status", async (c) => {
|
||||||
|
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
|
||||||
|
if (skipOobe) {
|
||||||
|
return c.json({
|
||||||
|
needsSetup: false,
|
||||||
|
showAuthProviderStep: false,
|
||||||
|
authConfigExists: false,
|
||||||
|
authEnvVarsSet: false,
|
||||||
|
skipped: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Check if any super user exists
|
// Check if any super user exists
|
||||||
@@ -97,21 +108,6 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolvedStaff && jwt.email) {
|
|
||||||
// Try auto-link by email: staff record exists with matching email but no userId
|
|
||||||
const [byEmail] = await tx
|
|
||||||
.select()
|
|
||||||
.from(staff)
|
|
||||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
|
||||||
if (byEmail) {
|
|
||||||
await tx
|
|
||||||
.update(staff)
|
|
||||||
.set({ userId: jwt.sub })
|
|
||||||
.where(eq(staff.id, byEmail.id));
|
|
||||||
resolvedStaff = { ...byEmail, userId: jwt.sub };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedStaff) {
|
if (!resolvedStaff) {
|
||||||
// Brand new user during OOBE — create staff record
|
// Brand new user during OOBE — create staff record
|
||||||
if (!jwt.email) {
|
if (!jwt.email) {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const createStaffSchema = z.object({
|
|||||||
|
|
||||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||||
|
|
||||||
|
const linkUserSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
staffRouter.get("/me", async (c) => {
|
staffRouter.get("/me", async (c) => {
|
||||||
const staffRow = c.get("staff");
|
const staffRow = c.get("staff");
|
||||||
return c.json(staffRow);
|
return c.json(staffRow);
|
||||||
@@ -106,6 +110,32 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
|||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const targetId = c.req.param("id");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
|
||||||
|
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
|
||||||
|
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, targetId))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(staff)
|
||||||
|
.set({ userId: body.userId, updatedAt: new Date() })
|
||||||
|
.where(eq(staff.id, targetId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
staffRouter.delete("/:id", async (c) => {
|
staffRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
reminderLogs,
|
reminderLogs,
|
||||||
|
session,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import {
|
import {
|
||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
@@ -155,6 +156,19 @@ export function startReminderScheduler(): void {
|
|||||||
runReminderCheck().catch((err) => {
|
runReminderCheck().catch((err) => {
|
||||||
console.error("[reminders] Error during reminder check:", err);
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
});
|
});
|
||||||
|
runSessionCleanup().catch((err) => {
|
||||||
|
console.error("[reminders] Error during session cleanup:", err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
console.log("[reminders] Reminder scheduler started");
|
console.log("[reminders] Reminder scheduler started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes expired sessions from the database.
|
||||||
|
// Runs every minute alongside reminder checks.
|
||||||
|
export async function runSessionCleanup(): Promise<void> {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.delete(session)
|
||||||
|
.where(lt(session.expiresAt, now));
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ test.beforeEach(async ({ page }) => {
|
|||||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Appointments, clients, services, staff, invoices, book, etc.
|
if (url.includes("/api/invoices")) {
|
||||||
|
return route.fulfill({ json: { data: [], total: 0 } });
|
||||||
|
}
|
||||||
|
// Appointments, clients, services, staff, book, etc.
|
||||||
return route.fulfill({ json: [] });
|
return route.fulfill({ json: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -82,6 +85,7 @@ 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"better-auth": "^1.0.0",
|
"better-auth": "^1.5.6",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 227 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 279 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
|
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
@@ -18,16 +18,37 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
|||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||||
import { useSession, signIn } from "./lib/auth-client.js";
|
import { useSession, signIn, signOut } from "./lib/auth-client.js";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [providers, setProviders] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
useEffect(() => {
|
||||||
|
fetch("/api/auth/providers")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setProviders(data.providers ?? []))
|
||||||
|
.catch(() => setProviders([]));
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const authError = params.get("error");
|
||||||
|
if (authError) setError(authError.replace(/_/g, " "));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSocialLogin = async (provider: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
|
setError(null);
|
||||||
|
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
||||||
|
if (result?.error) {
|
||||||
|
setError(result.error.message ?? "Sign-in failed");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isGoogle = providers.includes("google");
|
||||||
|
const isGitHub = providers.includes("github");
|
||||||
|
const isAuthentik = providers.includes("authentik");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -53,23 +74,94 @@ 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>
|
||||||
<button
|
{error && (
|
||||||
onClick={handleLogin}
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
|
||||||
disabled={isLoading}
|
{error}
|
||||||
style={{
|
</div>
|
||||||
padding: "0.6rem 1.5rem",
|
)}
|
||||||
borderRadius: 6,
|
{isGoogle && (
|
||||||
border: "none",
|
<button
|
||||||
background: "#4f8a6f",
|
onClick={() => handleSocialLogin("google")}
|
||||||
color: "#fff",
|
disabled={isLoading}
|
||||||
fontWeight: 600,
|
style={{
|
||||||
fontSize: 14,
|
display: "flex",
|
||||||
cursor: isLoading ? "wait" : "pointer",
|
alignItems: "center",
|
||||||
opacity: isLoading ? 0.7 : 1,
|
justifyContent: "center",
|
||||||
}}
|
gap: 8,
|
||||||
>
|
width: "100%",
|
||||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
padding: "0.6rem 1.5rem",
|
||||||
</button>
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -89,6 +181,7 @@ const NAV_LINKS = [
|
|||||||
|
|
||||||
function AdminLayout() {
|
function AdminLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
|
|
||||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||||
@@ -117,6 +210,7 @@ function AdminLayout() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginRight: "1.25rem",
|
marginRight: "1.25rem",
|
||||||
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{logoSrc && (
|
{logoSrc && (
|
||||||
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
||||||
@@ -130,45 +224,73 @@ function AdminLayout() {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
<Link
|
<div style={{
|
||||||
to="/admin/book"
|
display: "flex",
|
||||||
|
overflowX: "auto",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}>
|
||||||
|
<Link
|
||||||
|
to="/admin/book"
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
background: branding.primaryColor,
|
||||||
|
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Book
|
||||||
|
</Link>
|
||||||
|
{NAV_LINKS.map(({ to, label }) => {
|
||||||
|
const active =
|
||||||
|
to === "/admin"
|
||||||
|
? location.pathname === "/admin"
|
||||||
|
: location.pathname.startsWith(to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
color: active ? "#2d6a4f" : "#4b5563",
|
||||||
|
background: active ? "#ecfdf5" : "transparent",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
padding: "0.4rem 0.85rem",
|
padding: "0.4rem 0.85rem",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
textDecoration: "none",
|
border: "1px solid #e2e8f0",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#4b5563",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
color: "#fff",
|
cursor: "pointer",
|
||||||
background: branding.primaryColor,
|
|
||||||
marginRight: "0.5rem",
|
|
||||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Book
|
Logout
|
||||||
</Link>
|
</button>
|
||||||
{NAV_LINKS.map(({ to, label }) => {
|
|
||||||
const active =
|
|
||||||
to === "/admin"
|
|
||||||
? location.pathname === "/admin"
|
|
||||||
: location.pathname.startsWith(to);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
style={{
|
|
||||||
padding: "0.4rem 0.75rem",
|
|
||||||
borderRadius: 6,
|
|
||||||
textDecoration: "none",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
color: active ? "#2d6a4f" : "#4b5563",
|
|
||||||
background: active ? "#ecfdf5" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { signIn, signOut, useSession } = authClient;
|
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||||
import { PetForm } from "./PetForm.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
|
import { authClient } from "../../lib/auth-client.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -148,9 +149,11 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const passwordsMatch = newPassword === confirmPassword;
|
const passwordsMatch = newPassword === confirmPassword;
|
||||||
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
|
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return (
|
return (
|
||||||
@@ -160,17 +163,34 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError("Passwords do not match.");
|
setError("Passwords do not match.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO: Wire up to actual password-change API endpoint once backend support exists
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setCurrentPassword("");
|
setLoading(true);
|
||||||
setNewPassword("");
|
try {
|
||||||
setConfirmPassword("");
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = await (authClient as any).changePassword({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? "Failed to change password.");
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setTimeout(() => setSuccess(false), 4000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("An unexpected error occurred.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,12 +225,13 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Update Password
|
{loading ? "Updating..." : "Update Password"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export default defineConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||||
navigateFallbackDenylist: [
|
navigateFallbackDenylist: [
|
||||||
/^\/api\/auth\/oauth2\/callback\//,
|
/^\/api\/auth\//,
|
||||||
],
|
],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^http.*\/api\/.*/i,
|
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
|
||||||
handler: "NetworkFirst",
|
handler: "NetworkFirst",
|
||||||
options: {
|
options: {
|
||||||
cacheName: "api-cache",
|
cacheName: "api-cache",
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/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}")
|
||||||
@@ -21,6 +21,54 @@ 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) ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +184,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",
|
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle",
|
||||||
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -233,6 +281,44 @@ 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).
|
||||||
@@ -320,6 +406,58 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
|
||||||
|
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
|
||||||
|
if (uatSuperOidcSub) {
|
||||||
|
const UAT_SUPER_STAFF_ID = "00000000-0000-0000-0000-000000000003";
|
||||||
|
const [existingUatSuper] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.staff)
|
||||||
|
.where(eq(schema.staff.email, "uat-super@groombook.dev"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUatSuper) {
|
||||||
|
console.log(`✓ Staff 'UAT Super User' already exists — skipping`);
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.staff).values({
|
||||||
|
id: UAT_SUPER_STAFF_ID,
|
||||||
|
name: "UAT Super User",
|
||||||
|
email: "uat-super@groombook.dev",
|
||||||
|
oidcSub: uatSuperOidcSub,
|
||||||
|
role: "manager",
|
||||||
|
isSuperUser: true,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
console.log(`✓ Created staff 'UAT Super User' (oidcSub: ${uatSuperOidcSub})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff: UAT Staff Groomer (oidcSub from SEED_UAT_STAFF_OIDC_SUB env var) ──
|
||||||
|
const uatStaffOidcSub = process.env.SEED_UAT_STAFF_OIDC_SUB;
|
||||||
|
if (uatStaffOidcSub) {
|
||||||
|
const UAT_STAFF_STAFF_ID = "00000000-0000-0000-0000-000000000004";
|
||||||
|
const [existingUatStaff] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.staff)
|
||||||
|
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUatStaff) {
|
||||||
|
console.log(`✓ Staff 'UAT Staff Groomer' already exists — skipping`);
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.staff).values({
|
||||||
|
id: UAT_STAFF_STAFF_ID,
|
||||||
|
name: "UAT Staff Groomer",
|
||||||
|
email: "uat-groomer@groombook.dev",
|
||||||
|
oidcSub: uatStaffOidcSub,
|
||||||
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
console.log(`✓ Created staff 'UAT Staff Groomer' (oidcSub: ${uatStaffOidcSub})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
@@ -415,44 +553,32 @@ 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 cfg = profiles[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...\n");
|
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
|
||||||
|
|
||||||
// ── Staff ──
|
// ── Staff ──
|
||||||
// Deterministic staff IDs so they can be referenced in scripts/tests
|
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||||
const managerStaff = [
|
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
||||||
{ id: uuid(), name: "Jordan Lee", email: "jordan@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 })
|
||||||
|
);
|
||||||
|
|
||||||
const receptionistStaff = [
|
|
||||||
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const groomers = [
|
|
||||||
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
|
||||||
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
|
||||||
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
|
|
||||||
const bathers = [
|
|
||||||
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
|
||||||
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
|
||||||
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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];
|
||||||
@@ -471,7 +597,10 @@ 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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
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;
|
||||||
@@ -519,8 +648,10 @@ async function seed() {
|
|||||||
|
|
||||||
// ── Clients & Pets ──
|
// ── Clients & Pets ──
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const oneYearAgo = new Date(now);
|
const appointmentsBackDate = new Date(now);
|
||||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays);
|
||||||
|
const appointmentsForwardDate = new Date(now);
|
||||||
|
appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays);
|
||||||
|
|
||||||
interface ClientRecord { id: string; name: string }
|
interface ClientRecord { id: string; name: string }
|
||||||
interface PetRecord { id: string; clientId: string }
|
interface PetRecord { id: string; clientId: string }
|
||||||
@@ -528,9 +659,9 @@ async function seed() {
|
|||||||
const clientRecords: ClientRecord[] = [];
|
const clientRecords: ClientRecord[] = [];
|
||||||
const petRecords: PetRecord[] = [];
|
const petRecords: PetRecord[] = [];
|
||||||
|
|
||||||
// Batch insert clients and pets
|
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
|
||||||
const clientBatchSize = 50;
|
const clientBatchSize = 50;
|
||||||
for (let batch = 0; batch < 500 / clientBatchSize; batch++) {
|
for (let batch = 0; batch < Math.ceil(cfg.clientCount / 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)[] = [];
|
||||||
|
|
||||||
@@ -560,7 +691,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 = pick(dogBreeds);
|
const breed = petIndex < 250 ? "Puggle" : 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));
|
||||||
@@ -579,9 +710,11 @@ 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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,27 +745,29 @@ 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 500 clients with ${petRecords.length} pets`);
|
console.log(`✓ Created ${cfg.clientCount} 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.
|
||||||
interface UatClient {
|
if (cfg.includeUatClients) {
|
||||||
id: string;
|
interface UatClient {
|
||||||
name: string;
|
id: string;
|
||||||
email: string;
|
name: string;
|
||||||
phone: string;
|
email: string;
|
||||||
address: string;
|
phone: string;
|
||||||
petId: string;
|
address: string;
|
||||||
petName: string;
|
petId: string;
|
||||||
petBreed: string;
|
petName: 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" },
|
||||||
@@ -645,18 +780,20 @@ 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") })
|
.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") } });
|
.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) } });
|
||||||
// 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(oneYearAgo, now);
|
const completedTime = randDate(appointmentsBackDate, 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: groomers[0]!.id,
|
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id,
|
||||||
batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
|
batherStaffId: uatBather.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();
|
||||||
@@ -674,8 +811,9 @@ 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
|
// Generate ~5 appointments per client on average = ~2500 total
|
||||||
@@ -742,12 +880,12 @@ async function seed() {
|
|||||||
const bather = rand() < 0.6 ? pick(bathers) : null;
|
const bather = rand() < 0.6 ? pick(bathers) : null;
|
||||||
const status = pick(statuses);
|
const status = pick(statuses);
|
||||||
|
|
||||||
// Schedule within the past year, or next 2 weeks for upcoming
|
// 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, new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000));
|
startTime = randDate(now, appointmentsForwardDate);
|
||||||
} else {
|
} else {
|
||||||
startTime = randDate(oneYearAgo, now);
|
startTime = randDate(appointmentsBackDate, 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);
|
||||||
@@ -851,6 +989,93 @@ 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!");
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ importers:
|
|||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.5.6
|
||||||
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
|
|||||||