Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6714fff73c |
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
|
|||||||
OIDC_ISSUER=https://authentik.example.com
|
OIDC_ISSUER=https://authentik.example.com
|
||||||
OIDC_AUDIENCE=groombook
|
OIDC_AUDIENCE=groombook
|
||||||
|
|
||||||
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
|
||||||
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
|
||||||
# super user exists in the database. Useful in dev/test environments where the
|
|
||||||
# database has data but the setup wizard would otherwise block access.
|
|
||||||
SKIP_OOBE=false
|
|
||||||
|
|
||||||
# ── API ───────────────────────────────────────────────────────────────────────
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
PORT=3000
|
PORT=3000
|
||||||
CORS_ORIGIN=http://localhost:8080
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"hono": "^4.6.17",
|
"hono": "^4.6.17",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
|
|||||||
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
||||||
expect(body.authConfigExists).toBe(true);
|
expect(body.authConfigExists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
|
|
||||||
dbStaffRows = []; // no super user
|
|
||||||
dbAuthConfigRows = [];
|
|
||||||
process.env.SKIP_OOBE = "true";
|
|
||||||
|
|
||||||
const app = makeApp();
|
|
||||||
const { status, body } = await getStatus(app);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.needsSetup).toBe(false);
|
|
||||||
expect(body.showAuthProviderStep).toBe(false);
|
|
||||||
expect(body.authConfigExists).toBe(false);
|
|
||||||
expect(body.authEnvVarsSet).toBe(false);
|
|
||||||
expect(body.skipped).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SKIP_OOBE=1 also bypasses setup check", async () => {
|
|
||||||
dbStaffRows = [];
|
|
||||||
dbAuthConfigRows = [];
|
|
||||||
process.env.SKIP_OOBE = "1";
|
|
||||||
|
|
||||||
const app = makeApp();
|
|
||||||
const { status, body } = await getStatus(app);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.needsSetup).toBe(false);
|
|
||||||
expect(body.skipped).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SKIP_OOBE=yes also bypasses setup check", async () => {
|
|
||||||
dbStaffRows = [];
|
|
||||||
dbAuthConfigRows = [];
|
|
||||||
process.env.SKIP_OOBE = "yes";
|
|
||||||
|
|
||||||
const app = makeApp();
|
|
||||||
const { status, body } = await getStatus(app);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.needsSetup).toBe(false);
|
|
||||||
expect(body.skipped).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
|||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -51,9 +50,6 @@ app.route("/api/book", bookRouter);
|
|||||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||||
app.route("/api/portal", portalRouter);
|
app.route("/api/portal", portalRouter);
|
||||||
|
|
||||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
|
||||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
|
||||||
|
|
||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
@@ -109,13 +105,7 @@ api.use("*", resolveStaffMiddleware);
|
|||||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
authRouter.all("/*", (c) => {
|
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
|
||||||
try {
|
|
||||||
return getAuth().handler(c.req.raw);
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
api.route("/auth", authRouter);
|
api.route("/auth", authRouter);
|
||||||
|
|
||||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
|
import { google, github } from "better-auth/social-providers";
|
||||||
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";
|
||||||
@@ -91,12 +91,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
|
||||||
enabled: true,
|
|
||||||
max: 10,
|
|
||||||
window: 60,
|
|
||||||
storage: "memory",
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
@@ -177,50 +171,18 @@ export async function initAuth(): Promise<void> {
|
|||||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
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 hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
// Fetch OIDC discovery document to derive canonical provider URLs.
|
const socialPlugins = [];
|
||||||
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
if (hasGoogle) {
|
||||||
// while keeping authorizationUrl public for browser redirects.
|
socialPlugins.push(google({
|
||||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
let oidcConfig: Record<string, string> = {};
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
try {
|
}));
|
||||||
const discoveryRes = await fetch(discoveryUrlStr);
|
}
|
||||||
if (discoveryRes.ok) {
|
if (hasGitHub) {
|
||||||
const discovery = await discoveryRes.json() as {
|
socialPlugins.push(github({
|
||||||
authorization_endpoint?: string;
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
token_endpoint?: string;
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
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
|
||||||
@@ -230,28 +192,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
secret: BETTER_AUTH_SECRET,
|
secret: BETTER_AUTH_SECRET,
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
|
||||||
enabled: true,
|
|
||||||
max: 10,
|
|
||||||
window: 60,
|
|
||||||
storage: "memory",
|
|
||||||
},
|
|
||||||
account: {
|
|
||||||
storeStateStrategy: "cookie" as const,
|
|
||||||
},
|
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
|
||||||
emailVerification: {
|
|
||||||
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
|
||||||
await sendEmail({
|
|
||||||
to: user.email,
|
|
||||||
subject: "Verify your GroomBook email",
|
|
||||||
text: `Click the link to verify your email: ${url}`,
|
|
||||||
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
@@ -259,27 +199,21 @@ export async function initAuth(): Promise<void> {
|
|||||||
providerId: providerConfig.providerId,
|
providerId: providerConfig.providerId,
|
||||||
clientId: providerConfig.clientId,
|
clientId: providerConfig.clientId,
|
||||||
clientSecret: providerConfig.clientSecret,
|
clientSecret: providerConfig.clientSecret,
|
||||||
discoveryUrl: discoveryUrlStr,
|
...(providerConfig.internalBaseUrl
|
||||||
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
? {
|
||||||
|
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
|
||||||
|
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
|
||||||
|
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
|
||||||
|
}),
|
||||||
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
...socialPlugins,
|
||||||
],
|
],
|
||||||
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,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
|
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
|
||||||
if (c.req.path.startsWith("/api/auth/")) {
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth;
|
const session = await getAuth().api.getSession({
|
||||||
try {
|
|
||||||
auth = getAuth();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: c.req.raw.headers,
|
headers: c.req.raw.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { eq, getDb, staff } from "@groombook/db";
|
import { and, eq, getDb, isNull, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (!fallbackRow) {
|
if (!fallbackRow) {
|
||||||
|
// Auto-link: staff record exists with matching email but no userId — link it now
|
||||||
|
if (jwt.email) {
|
||||||
|
const [linkedStaff] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
|
||||||
|
if (linkedStaff) {
|
||||||
|
await db
|
||||||
|
.update(staff)
|
||||||
|
.set({ userId: jwt.sub })
|
||||||
|
.where(eq(staff.id, linkedStaff.id));
|
||||||
|
console.log(
|
||||||
|
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
|
||||||
|
);
|
||||||
|
c.set("staff", linkedStaff);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -9,17 +9,6 @@ export const setupRouter = new Hono<AppEnv>();
|
|||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
// and whether the auth provider bootstrap step should be shown
|
// and whether the auth provider bootstrap step should be shown
|
||||||
setupRouter.get("/status", async (c) => {
|
setupRouter.get("/status", async (c) => {
|
||||||
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
|
|
||||||
if (skipOobe) {
|
|
||||||
return c.json({
|
|
||||||
needsSetup: false,
|
|
||||||
showAuthProviderStep: false,
|
|
||||||
authConfigExists: false,
|
|
||||||
authEnvVarsSet: false,
|
|
||||||
skipped: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Check if any super user exists
|
// Check if any super user exists
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
|
|||||||
|
|
||||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||||
|
|
||||||
const linkUserSchema = z.object({
|
|
||||||
userId: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
staffRouter.get("/me", async (c) => {
|
staffRouter.get("/me", async (c) => {
|
||||||
const staffRow = c.get("staff");
|
const staffRow = c.get("staff");
|
||||||
return c.json(staffRow);
|
return c.json(staffRow);
|
||||||
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
|||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const targetId = c.req.param("id");
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
const currentStaff = c.get("staff");
|
|
||||||
|
|
||||||
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
|
|
||||||
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, targetId))
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(staff)
|
|
||||||
.set({ userId: body.userId, updatedAt: new Date() })
|
|
||||||
.where(eq(staff.id, targetId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return c.json(updated);
|
|
||||||
});
|
|
||||||
|
|
||||||
staffRouter.delete("/:id", async (c) => {
|
staffRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import Stripe from "stripe";
|
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
|
||||||
|
|
||||||
export const webhooksRouter = new Hono();
|
|
||||||
|
|
||||||
webhooksRouter.post("/stripe", async (c) => {
|
|
||||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const signature = c.req.header("stripe-signature");
|
|
||||||
if (!signature) {
|
|
||||||
return c.json({ error: "Missing signature" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawBody: string;
|
|
||||||
try {
|
|
||||||
rawBody = await c.req.text();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Could not read body" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
|
||||||
try {
|
|
||||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
|
||||||
return c.json({ error: message }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
if (event.type === "payment_intent.succeeded") {
|
|
||||||
const pi = event.data.object as Stripe.PaymentIntent;
|
|
||||||
if (pi.metadata?.groombook_invoice_ids) {
|
|
||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
|
||||||
for (const invoiceId of invoiceIds) {
|
|
||||||
if (!invoiceId) continue;
|
|
||||||
const [inv] = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.id, invoiceId))
|
|
||||||
.limit(1);
|
|
||||||
if (!inv) continue;
|
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
status: "paid",
|
|
||||||
paymentMethod: "card",
|
|
||||||
paidAt: new Date(),
|
|
||||||
stripePaymentIntentId: pi.id,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, invoiceId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
|
||||||
const pi = event.data.object as Stripe.PaymentIntent;
|
|
||||||
if (pi.metadata?.groombook_invoice_ids) {
|
|
||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
|
||||||
for (const invoiceId of invoiceIds) {
|
|
||||||
if (!invoiceId) continue;
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, invoiceId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "charge.refunded") {
|
|
||||||
const charge = event.data.object as Stripe.Charge;
|
|
||||||
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
|
||||||
const [inv] = await db
|
|
||||||
.select({ id: invoices.id })
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
|
||||||
.limit(1);
|
|
||||||
if (inv) {
|
|
||||||
const refundId =
|
|
||||||
typeof charge.refunded === "boolean" && charge.refunded
|
|
||||||
? `ch_${charge.id}_refund`
|
|
||||||
: null;
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
status: "void",
|
|
||||||
stripeRefundId: refundId,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, inv.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "charge.dispute.created") {
|
|
||||||
const dispute = event.data.object as Stripe.Dispute;
|
|
||||||
console.error(
|
|
||||||
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ received: true });
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
reminderLogs,
|
reminderLogs,
|
||||||
session,
|
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import {
|
import {
|
||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
@@ -156,19 +155,6 @@ export function startReminderScheduler(): void {
|
|||||||
runReminderCheck().catch((err) => {
|
runReminderCheck().catch((err) => {
|
||||||
console.error("[reminders] Error during reminder check:", err);
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
});
|
});
|
||||||
runSessionCleanup().catch((err) => {
|
|
||||||
console.error("[reminders] Error during session cleanup:", err);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
console.log("[reminders] Reminder scheduler started");
|
console.log("[reminders] Reminder scheduler started");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes expired sessions from the database.
|
|
||||||
// Runs every minute alongside reminder checks.
|
|
||||||
export async function runSessionCleanup(): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
await db
|
|
||||||
.delete(session)
|
|
||||||
.where(lt(session.expiresAt, now));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.5.6",
|
"better-auth": "^1.0.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
|
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 |
@@ -1,4 +1,4 @@
|
|||||||
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
|
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
@@ -18,31 +18,22 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
|||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||||
import { useSession, signIn, signOut } from "./lib/auth-client.js";
|
import { useSession, signIn } from "./lib/auth-client.js";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [providers, setProviders] = useState<string[]>([]);
|
const [providers, setProviders] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/providers")
|
fetch("/api/auth/providers")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setProviders(data.providers ?? []))
|
.then((data) => setProviders(data.providers ?? []))
|
||||||
.catch(() => setProviders([]));
|
.catch(() => setProviders([]));
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const authError = params.get("error");
|
|
||||||
if (authError) setError(authError.replace(/_/g, " "));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSocialLogin = async (provider: string) => {
|
const handleSocialLogin = async (provider: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
await signIn.social({ provider, callbackURL: window.location.origin });
|
||||||
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
|
||||||
if (result?.error) {
|
|
||||||
setError(result.error.message ?? "Sign-in failed");
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isGoogle = providers.includes("google");
|
const isGoogle = providers.includes("google");
|
||||||
@@ -74,11 +65,6 @@ function LoginPage() {
|
|||||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||||
Sign in to continue
|
Sign in to continue
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
|
||||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isGoogle && (
|
{isGoogle && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSocialLogin("google")}
|
onClick={() => handleSocialLogin("google")}
|
||||||
@@ -181,7 +167,6 @@ const NAV_LINKS = [
|
|||||||
|
|
||||||
function AdminLayout() {
|
function AdminLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
|
|
||||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||||
@@ -210,7 +195,6 @@ function AdminLayout() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginRight: "1.25rem",
|
marginRight: "1.25rem",
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
}}>
|
||||||
{logoSrc && (
|
{logoSrc && (
|
||||||
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
||||||
@@ -224,73 +208,45 @@ function AdminLayout() {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
<div style={{
|
<Link
|
||||||
display: "flex",
|
to="/admin/book"
|
||||||
overflowX: "auto",
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
gap: "0.25rem",
|
|
||||||
}}>
|
|
||||||
<Link
|
|
||||||
to="/admin/book"
|
|
||||||
style={{
|
|
||||||
padding: "0.4rem 0.85rem",
|
|
||||||
borderRadius: 6,
|
|
||||||
textDecoration: "none",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#fff",
|
|
||||||
background: branding.primaryColor,
|
|
||||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Book
|
|
||||||
</Link>
|
|
||||||
{NAV_LINKS.map(({ to, label }) => {
|
|
||||||
const active =
|
|
||||||
to === "/admin"
|
|
||||||
? location.pathname === "/admin"
|
|
||||||
: location.pathname.startsWith(to);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
style={{
|
|
||||||
padding: "0.4rem 0.75rem",
|
|
||||||
borderRadius: 6,
|
|
||||||
textDecoration: "none",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
color: active ? "#2d6a4f" : "#4b5563",
|
|
||||||
background: active ? "#ecfdf5" : "transparent",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
await signOut();
|
|
||||||
navigate("/login");
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
|
||||||
padding: "0.4rem 0.85rem",
|
padding: "0.4rem 0.85rem",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: "1px solid #e2e8f0",
|
textDecoration: "none",
|
||||||
background: "#fff",
|
|
||||||
color: "#4b5563",
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
cursor: "pointer",
|
color: "#fff",
|
||||||
|
background: branding.primaryColor,
|
||||||
|
marginRight: "0.5rem",
|
||||||
|
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Logout
|
Book
|
||||||
</button>
|
</Link>
|
||||||
|
{NAV_LINKS.map(({ to, label }) => {
|
||||||
|
const active =
|
||||||
|
to === "/admin"
|
||||||
|
? location.pathname === "/admin"
|
||||||
|
: location.pathname.startsWith(to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
color: active ? "#2d6a4f" : "#4b5563",
|
||||||
|
background: active ? "#ecfdf5" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
export const { signIn, signOut, useSession } = authClient;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||||
import { PetForm } from "./PetForm.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
import { authClient } from "../../lib/auth-client.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const passwordsMatch = newPassword === confirmPassword;
|
const passwordsMatch = newPassword === confirmPassword;
|
||||||
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return (
|
return (
|
||||||
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError("Passwords do not match.");
|
setError("Passwords do not match.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// TODO: Wire up to actual password-change API endpoint once backend support exists
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setCurrentPassword("");
|
||||||
try {
|
setNewPassword("");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
setConfirmPassword("");
|
||||||
const result = await (authClient as any).changePassword({
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? "Failed to change password.");
|
|
||||||
} else {
|
|
||||||
setSuccess(true);
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
setTimeout(() => setSuccess(false), 4000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError("An unexpected error occurred.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? "Updating..." : "Update Password"}
|
Update Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export default defineConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||||
navigateFallbackDenylist: [
|
navigateFallbackDenylist: [
|
||||||
/^\/api\/auth\//,
|
/^\/api\/auth\/oauth2\/callback\//,
|
||||||
],
|
],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
|
urlPattern: /^http.*\/api\/.*/i,
|
||||||
handler: "NetworkFirst",
|
handler: "NetworkFirst",
|
||||||
options: {
|
options: {
|
||||||
cacheName: "api-cache",
|
cacheName: "api-cache",
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import base64
|
|
||||||
import requests
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
api_key = os.environ.get("MINIMAX_API_KEY")
|
|
||||||
if not api_key:
|
|
||||||
raise ValueError("MINIMAX_API_KEY environment variable not set")
|
|
||||||
|
|
||||||
url = "https://api.minimax.io/v1/image_generation"
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
|
||||||
|
|
||||||
os.makedirs("minimax-output", exist_ok=True)
|
|
||||||
|
|
||||||
# Comprehensive list of dog breeds and variations for diverse demo data
|
|
||||||
dog_prompts = [
|
|
||||||
# Large breeds
|
|
||||||
("german-shepherd-alert", "German Shepherd dog with alert expression, standing confidently, professional pet photography, studio lighting, photorealistic"),
|
|
||||||
("golden-retriever-happy", "Golden Retriever with joyful expression, sitting, golden coat, natural daylight, professional pet photography, photorealistic"),
|
|
||||||
("labrador-running", "Black Labrador Retriever running towards camera, outdoor park setting, dynamic pose, professional pet photography, photorealistic"),
|
|
||||||
("german-shepherd-sitting", "German Shepherd sitting in front of studio backdrop, professional portrait, studio lighting, photorealistic"),
|
|
||||||
("golden-retriever-lying", "Golden Retriever lying down on grass, peaceful expression, outdoor natural lighting, professional pet photography, photorealistic"),
|
|
||||||
|
|
||||||
# Medium breeds
|
|
||||||
("beagle-curious", "Beagle with curious expression, sitting, outdoor garden setting, professional pet photography, photorealistic"),
|
|
||||||
("cocker-spaniel-groomed", "Cocker Spaniel freshly groomed with fluffy coat, happy expression, professional grooming studio, photorealistic"),
|
|
||||||
("english-springer-spaniel", "English Springer Spaniel in natural outdoor setting, alert pose, professional pet photography, photorealistic"),
|
|
||||||
("boxer-playful", "Boxer dog with playful expression, standing, muscular build, professional studio lighting, photorealistic"),
|
|
||||||
("bulldog-gentle", "English Bulldog with gentle expression, sitting, studio backdrop, professional pet photography, photorealistic"),
|
|
||||||
|
|
||||||
# Small breeds
|
|
||||||
("maltese-fluffy", "Maltese dog with white fluffy coat, sitting, groomed appearance, professional pet photography, studio lighting, photorealistic"),
|
|
||||||
("shih-tzu-groomed", "Shih Tzu with long groomed coat, sitting pretty, professional grooming studio, photorealistic"),
|
|
||||||
("pomeranian-alert", "Pomeranian with alert expression, standing, fluffy coat, professional pet photography, photorealistic"),
|
|
||||||
("yorkshire-terrier", "Yorkshire Terrier with silky coat, sitting, professional grooming environment, photorealistic"),
|
|
||||||
("pug-curious", "Pug with curious expression, sitting, studio lighting, professional pet photography, photorealistic"),
|
|
||||||
|
|
||||||
# Specialty breeds
|
|
||||||
("poodle-standard-groomed", "Standard Poodle with professionally groomed coat, standing in show stance, professional grooming studio, photorealistic"),
|
|
||||||
("dachshund-long", "Long-haired Dachshund, lying down, relaxed pose, professional pet photography, photorealistic"),
|
|
||||||
("corgi-happy", "Welsh Corgi with happy expression, standing, professional outdoor setting, photorealistic"),
|
|
||||||
("husky-alert", "Siberian Husky with alert expression, sitting, professional pet photography, studio lighting, photorealistic"),
|
|
||||||
("german-shepherd-lying", "German Shepherd lying down in relaxed pose, indoor setting, professional pet photography, photorealistic"),
|
|
||||||
|
|
||||||
# Mixed/rescue variations
|
|
||||||
("mixed-breed-brown", "Brown and white mixed breed dog, friendly expression, sitting, professional pet photography, photorealistic"),
|
|
||||||
("mixed-breed-black", "Black mixed breed dog with gentle eyes, standing, outdoor natural lighting, photorealistic"),
|
|
||||||
("mixed-breed-spotted", "Spotted mixed breed dog, playful pose, outdoor park setting, professional pet photography, photorealistic"),
|
|
||||||
("terrier-mix-sitting", "Terrier mix dog, alert expression, sitting, professional studio backdrop, photorealistic"),
|
|
||||||
("spaniel-mix-outdoor", "Spaniel mix dog in outdoor garden, relaxed pose, natural daylight, professional pet photography, photorealistic"),
|
|
||||||
|
|
||||||
# Additional variations
|
|
||||||
("labrador-golden", "Golden Labrador Retriever, calm expression, standing in professional pose, studio lighting, photorealistic"),
|
|
||||||
("labrador-black-sitting", "Black Labrador Retriever sitting, gentle expression, professional pet photography, photorealistic"),
|
|
||||||
("rottweiler-calm", "Rottweiler with calm expression, sitting, professional studio, photorealistic"),
|
|
||||||
("doberman-alert", "Doberman Pinscher with alert expression, standing, professional pet photography, photorealistic"),
|
|
||||||
("german-shepherd-side", "German Shepherd in side profile, standing, professional outdoor setting, photorealistic"),
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"Generating {len(dog_prompts)} unique dog images...")
|
|
||||||
print(f"Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
print("")
|
|
||||||
|
|
||||||
generated = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
for i, (filename_base, prompt) in enumerate(dog_prompts, 1):
|
|
||||||
filename = f"dog-{filename_base}.png"
|
|
||||||
filepath = f"minimax-output/{filename}"
|
|
||||||
|
|
||||||
# Check if already exists
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
size = os.path.getsize(filepath)
|
|
||||||
print(f"[{i:2d}/{len(dog_prompts)}] ✓ {filename} (already exists, {size} bytes)")
|
|
||||||
generated += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"[{i:2d}/{len(dog_prompts)}] Generating {filename}...", end=" ", flush=True)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": "image-01",
|
|
||||||
"prompt": prompt,
|
|
||||||
"aspect_ratio": "1:1",
|
|
||||||
"response_format": "base64",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=120)
|
|
||||||
|
|
||||||
# Check for quota errors
|
|
||||||
if response.status_code == 429:
|
|
||||||
print(f"✗ QUOTA EXCEEDED")
|
|
||||||
print(f"\nQuota limit reached after {generated} successful generations")
|
|
||||||
break
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
if "data" in data and "image_base64" in data["data"]:
|
|
||||||
images = data["data"]["image_base64"]
|
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
|
||||||
f.write(base64.b64decode(images[0]))
|
|
||||||
|
|
||||||
file_size = os.path.getsize(filepath)
|
|
||||||
print(f"✓ ({file_size} bytes)")
|
|
||||||
generated += 1
|
|
||||||
else:
|
|
||||||
print(f"✗ Unexpected response format")
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
|
||||||
print(f"✗ Timeout")
|
|
||||||
failed += 1
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
if "429" in str(e) or "quota" in str(e).lower():
|
|
||||||
print(f"✗ QUOTA EXCEEDED")
|
|
||||||
print(f"\nQuota limit reached after {generated} successful generations")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print(f"✗ {type(e).__name__}")
|
|
||||||
failed += 1
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ {type(e).__name__}")
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
time.sleep(0.5) # Small delay between requests
|
|
||||||
|
|
||||||
print("")
|
|
||||||
print(f"End time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
print(f"✓ Successfully generated: {generated}")
|
|
||||||
print(f"✗ Failed: {failed}")
|
|
||||||
print(f"\nCopying images to demo-pets directory...")
|
|
||||||
|
|
||||||
# Copy all generated images to demo-pets
|
|
||||||
import subprocess
|
|
||||||
result = subprocess.run(
|
|
||||||
["cp", "-v", "minimax-output/dog-*.png", "apps/web/public/demo-pets/"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Count files in demo-pets
|
|
||||||
import glob
|
|
||||||
demo_pets = glob.glob("apps/web/public/demo-pets/dog-*.png")
|
|
||||||
print(f"✓ Copied to demo-pets. Total dog images: {len(demo_pets)}")
|
|
||||||
else:
|
|
||||||
print(f"Note: Copy result - {result.stderr}")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Better-Auth rate limiting table (GRO-574)
|
|
||||||
CREATE TABLE "rate_limit" (
|
|
||||||
key TEXT NOT NULL PRIMARY KEY,
|
|
||||||
count INTEGER NOT NULL,
|
|
||||||
last_request BIGINT NOT NULL
|
|
||||||
);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
|
||||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
|
||||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
|
||||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
|
||||||
@@ -176,13 +176,6 @@
|
|||||||
"when": 1775396067192,
|
"when": 1775396067192,
|
||||||
"tag": "0024_invoice_indexes",
|
"tag": "0024_invoice_indexes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 25,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775482467192,
|
|
||||||
"tag": "0025_rate_limit",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -251,9 +251,6 @@ export const invoices = pgTable(
|
|||||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||||
paymentMethod: paymentMethodEnum("payment_method"),
|
paymentMethod: paymentMethodEnum("payment_method"),
|
||||||
paidAt: timestamp("paid_at"),
|
paidAt: timestamp("paid_at"),
|
||||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
|
||||||
stripeRefundId: text("stripe_refund_id"),
|
|
||||||
paymentFailureReason: text("payment_failure_reason"),
|
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
@@ -262,7 +259,6 @@ export const invoices = pgTable(
|
|||||||
index("idx_invoices_client_id").on(t.clientId),
|
index("idx_invoices_client_id").on(t.clientId),
|
||||||
index("idx_invoices_status").on(t.status),
|
index("idx_invoices_status").on(t.status),
|
||||||
index("idx_invoices_created_at").on(t.createdAt),
|
index("idx_invoices_created_at").on(t.createdAt),
|
||||||
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -184,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", "Puggle",
|
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner",
|
||||||
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -281,44 +281,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).
|
||||||
@@ -659,7 +621,6 @@ 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
|
|
||||||
const clientBatchSize = 50;
|
const clientBatchSize = 50;
|
||||||
for (let batch = 0; batch < Math.ceil(cfg.clientCount / 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)[] = [];
|
||||||
@@ -691,7 +652,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 +671,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,7 +704,6 @@ async function seed() {
|
|||||||
shampooPreference: pet.shampooPreference,
|
shampooPreference: pet.shampooPreference,
|
||||||
specialCareNotes: pet.specialCareNotes,
|
specialCareNotes: pet.specialCareNotes,
|
||||||
customFields: pet.customFields,
|
customFields: pet.customFields,
|
||||||
image: pet.image,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -780,8 +738,8 @@ 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;
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
stripe:
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.0.1(@types/node@22.19.15)
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -90,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.5.6
|
specifier: ^1.0.0
|
||||||
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
@@ -4127,15 +4124,6 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
stripe@22.0.1:
|
|
||||||
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/node': '>=18'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/node':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
strnum@2.2.1:
|
strnum@2.2.1:
|
||||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||||
|
|
||||||
@@ -8786,10 +8774,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
stripe@22.0.1(@types/node@22.19.15):
|
|
||||||
optionalDependencies:
|
|
||||||
'@types/node': 22.19.15
|
|
||||||
|
|
||||||
strnum@2.2.1: {}
|
strnum@2.2.1: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
|
|||||||