Compare commits

..

1 Commits

Author SHA1 Message Date
Paperclip 6714fff73c fix(gro-540): add missing OIDC env vars to prod API deployment
Updates infra submodule to include OIDC_ISSUER, OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET, and OIDC_INTERNAL_BASE env vars in prod api-patch.yaml.

This fixes the auth initialization failure where initAuth() found no
provider config because OIDC env vars were missing from the prod deployment
even though the groombook-auth sealed secret contained the credentials.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:33:52 +00:00
59 changed files with 89 additions and 529 deletions
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
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 ───────────────────────────────────────────────────────────────────────
PORT=3000
CORS_ORIGIN=http://localhost:8080
-42
View File
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.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", () => {
+1 -7
View File
@@ -105,13 +105,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => {
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
+16 -29
View File
@@ -1,6 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { google, github } from "better-auth/social-providers";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
@@ -90,12 +91,6 @@ export async function initAuth(): Promise<void> {
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
plugins: [
genericOAuth({
config: [
@@ -176,6 +171,20 @@ export async function initAuth(): Promise<void> {
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 socialPlugins = [];
if (hasGoogle) {
socialPlugins.push(google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}));
}
if (hasGitHub) {
socialPlugins.push(github({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}));
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
@@ -183,15 +192,6 @@ export async function initAuth(): Promise<void> {
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
account: {
storeStateStrategy: "cookie" as const,
},
plugins: [
genericOAuth({
config: [
@@ -212,21 +212,8 @@ export async function initAuth(): Promise<void> {
},
],
}),
...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: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
+2 -8
View File
@@ -23,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return;
}
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
const session = await getAuth().api.getSession({
headers: c.req.raw.headers,
});
+20 -1
View File
@@ -1,5 +1,5 @@
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 StaffRow = typeof staff.$inferSelect;
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
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(
{ error: "Forbidden: no staff record found for authenticated user" },
403
-11
View File
@@ -9,17 +9,6 @@ export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown
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();
// Check if any super user exists
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff");
return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
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) => {
const db = getDb();
const id = c.req.param("id");
+1 -5
View File
@@ -44,10 +44,7 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
// Appointments, clients, services, staff, book, etc.
// Appointments, clients, services, staff, invoices, book, etc.
return route.fulfill({ json: [] });
});
});
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
});
@@ -3,13 +3,13 @@ kind: Kustomization
namespace: groombook-uat
images:
- name: ghcr.io/groombook/api
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/web
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/migrate
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/seed
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
resources:
- ../../base
- postgres-sealed-secret.yaml
+1 -1
View File
@@ -15,7 +15,7 @@
"dependencies": {
"@groombook/types": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"better-auth": "^1.0.0",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C853FAECD363909C4A0</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96CFC84D7A9333708F278</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D48D7892E37386B9ACB</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C25663D703833F23607</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D89851C843332073968</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C9C5A03D33730C61AD8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BEB91911B30317E3BE8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BFB7B92D33535D6D90D</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96B8BDF4B473630A2E120</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D78BFFCAD343037C27C</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

+36 -80
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.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 { BrandingProvider, useBranding } from "./BrandingContext.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() {
const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
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);
setError(null);
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
await signIn.social({ provider, callbackURL: window.location.origin });
};
const isGoogle = providers.includes("google");
@@ -74,11 +65,6 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue
</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 && (
<button
onClick={() => handleSocialLogin("google")}
@@ -181,7 +167,6 @@ const NAV_LINKS = [
function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +195,6 @@ function AdminLayout() {
alignItems: "center",
gap: 8,
marginRight: "1.25rem",
flexShrink: 0,
}}>
{logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +208,45 @@ function AdminLayout() {
</strong>
</div>
<GlobalSearch />
<div style={{
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");
}}
<Link
to="/admin/book"
style={{
flexShrink: 0,
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#4b5563",
textDecoration: "none",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}}
>
Logout
</button>
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",
}}
>
{label}
</Link>
);
})}
</nav>
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,
/^\/api\/auth\/oauth2\/callback\//,
],
runtimeCaching: [
{
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
import base64
import requests
import os
import json
import time
from datetime import datetime
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise ValueError("MINIMAX_API_KEY environment variable not set")
url = "https://api.minimax.io/v1/image_generation"
headers = {"Authorization": f"Bearer {api_key}"}
os.makedirs("minimax-output", exist_ok=True)
# Comprehensive list of dog breeds and variations for diverse demo data
dog_prompts = [
# Large breeds
("german-shepherd-alert", "German Shepherd dog with alert expression, standing confidently, professional pet photography, studio lighting, photorealistic"),
("golden-retriever-happy", "Golden Retriever with joyful expression, sitting, golden coat, natural daylight, professional pet photography, photorealistic"),
("labrador-running", "Black Labrador Retriever running towards camera, outdoor park setting, dynamic pose, professional pet photography, photorealistic"),
("german-shepherd-sitting", "German Shepherd sitting in front of studio backdrop, professional portrait, studio lighting, photorealistic"),
("golden-retriever-lying", "Golden Retriever lying down on grass, peaceful expression, outdoor natural lighting, professional pet photography, photorealistic"),
# Medium breeds
("beagle-curious", "Beagle with curious expression, sitting, outdoor garden setting, professional pet photography, photorealistic"),
("cocker-spaniel-groomed", "Cocker Spaniel freshly groomed with fluffy coat, happy expression, professional grooming studio, photorealistic"),
("english-springer-spaniel", "English Springer Spaniel in natural outdoor setting, alert pose, professional pet photography, photorealistic"),
("boxer-playful", "Boxer dog with playful expression, standing, muscular build, professional studio lighting, photorealistic"),
("bulldog-gentle", "English Bulldog with gentle expression, sitting, studio backdrop, professional pet photography, photorealistic"),
# Small breeds
("maltese-fluffy", "Maltese dog with white fluffy coat, sitting, groomed appearance, professional pet photography, studio lighting, photorealistic"),
("shih-tzu-groomed", "Shih Tzu with long groomed coat, sitting pretty, professional grooming studio, photorealistic"),
("pomeranian-alert", "Pomeranian with alert expression, standing, fluffy coat, professional pet photography, photorealistic"),
("yorkshire-terrier", "Yorkshire Terrier with silky coat, sitting, professional grooming environment, photorealistic"),
("pug-curious", "Pug with curious expression, sitting, studio lighting, professional pet photography, photorealistic"),
# Specialty breeds
("poodle-standard-groomed", "Standard Poodle with professionally groomed coat, standing in show stance, professional grooming studio, photorealistic"),
("dachshund-long", "Long-haired Dachshund, lying down, relaxed pose, professional pet photography, photorealistic"),
("corgi-happy", "Welsh Corgi with happy expression, standing, professional outdoor setting, photorealistic"),
("husky-alert", "Siberian Husky with alert expression, sitting, professional pet photography, studio lighting, photorealistic"),
("german-shepherd-lying", "German Shepherd lying down in relaxed pose, indoor setting, professional pet photography, photorealistic"),
# Mixed/rescue variations
("mixed-breed-brown", "Brown and white mixed breed dog, friendly expression, sitting, professional pet photography, photorealistic"),
("mixed-breed-black", "Black mixed breed dog with gentle eyes, standing, outdoor natural lighting, photorealistic"),
("mixed-breed-spotted", "Spotted mixed breed dog, playful pose, outdoor park setting, professional pet photography, photorealistic"),
("terrier-mix-sitting", "Terrier mix dog, alert expression, sitting, professional studio backdrop, photorealistic"),
("spaniel-mix-outdoor", "Spaniel mix dog in outdoor garden, relaxed pose, natural daylight, professional pet photography, photorealistic"),
# Additional variations
("labrador-golden", "Golden Labrador Retriever, calm expression, standing in professional pose, studio lighting, photorealistic"),
("labrador-black-sitting", "Black Labrador Retriever sitting, gentle expression, professional pet photography, photorealistic"),
("rottweiler-calm", "Rottweiler with calm expression, sitting, professional studio, photorealistic"),
("doberman-alert", "Doberman Pinscher with alert expression, standing, professional pet photography, photorealistic"),
("german-shepherd-side", "German Shepherd in side profile, standing, professional outdoor setting, photorealistic"),
]
print(f"Generating {len(dog_prompts)} unique dog images...")
print(f"Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("")
generated = 0
failed = 0
for i, (filename_base, prompt) in enumerate(dog_prompts, 1):
filename = f"dog-{filename_base}.png"
filepath = f"minimax-output/{filename}"
# Check if already exists
if os.path.exists(filepath):
size = os.path.getsize(filepath)
print(f"[{i:2d}/{len(dog_prompts)}] ✓ {filename} (already exists, {size} bytes)")
generated += 1
continue
print(f"[{i:2d}/{len(dog_prompts)}] Generating {filename}...", end=" ", flush=True)
payload = {
"model": "image-01",
"prompt": prompt,
"aspect_ratio": "1:1",
"response_format": "base64",
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=120)
# Check for quota errors
if response.status_code == 429:
print(f"✗ QUOTA EXCEEDED")
print(f"\nQuota limit reached after {generated} successful generations")
break
response.raise_for_status()
data = response.json()
if "data" in data and "image_base64" in data["data"]:
images = data["data"]["image_base64"]
with open(filepath, "wb") as f:
f.write(base64.b64decode(images[0]))
file_size = os.path.getsize(filepath)
print(f"✓ ({file_size} bytes)")
generated += 1
else:
print(f"✗ Unexpected response format")
failed += 1
except requests.exceptions.Timeout:
print(f"✗ Timeout")
failed += 1
except requests.exceptions.RequestException as e:
if "429" in str(e) or "quota" in str(e).lower():
print(f"✗ QUOTA EXCEEDED")
print(f"\nQuota limit reached after {generated} successful generations")
break
else:
print(f"{type(e).__name__}")
failed += 1
except Exception as e:
print(f"{type(e).__name__}")
failed += 1
time.sleep(0.5) # Small delay between requests
print("")
print(f"End time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"✓ Successfully generated: {generated}")
print(f"✗ Failed: {failed}")
print(f"\nCopying images to demo-pets directory...")
# Copy all generated images to demo-pets
import subprocess
result = subprocess.run(
["cp", "-v", "minimax-output/dog-*.png", "apps/web/public/demo-pets/"],
capture_output=True,
text=True
)
if result.returncode == 0:
# Count files in demo-pets
import glob
demo_pets = glob.glob("apps/web/public/demo-pets/dog-*.png")
print(f"✓ Copied to demo-pets. Total dog images: {len(demo_pets)}")
else:
print(f"Note: Copy result - {result.stderr}")
+1 -1
Submodule infra updated: d6c0d13d02...aaedafcb9a
@@ -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
);
@@ -176,13 +176,6 @@
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
}
]
}
+4 -46
View File
@@ -184,7 +184,7 @@ const dogBreeds = [
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd",
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle",
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner",
"Mixed Breed", "Mixed Breed", "Mixed Breed",
];
@@ -281,44 +281,6 @@ const productsUsed = [
"Coconut oil shampoo, leave-in conditioner, cologne",
];
const demoPetImages = [
"/demo-pets/dog-golden-after.png",
"/demo-pets/dog-poodle-groomed.png",
"/demo-pets/dog-black-lab.png",
"/demo-pets/dog-shih-tzu.png",
"/demo-pets/dog-cocker-spaniel.png",
"/demo-pets/dog-schnauzer.png",
"/demo-pets/dog-maltese.png",
"/demo-pets/dog-dachshund.png",
"/demo-pets/dog-pomeranian.png",
"/demo-pets/dog-bichon-frise.png",
"/demo-pets/dog-golden-retriever.png",
"/demo-pets/dog-labrador.png",
"/demo-pets/dog-mixed-breed.png",
"/demo-pets/dog-poodle.png",
"/demo-pets/dog-terrier.png",
"/demo-pets/dog-afghan-hound.png",
"/demo-pets/dog-basset-brown-white.png",
"/demo-pets/dog-bichon-white-groomed.png",
"/demo-pets/dog-boxer-fawn-athletic.png",
"/demo-pets/dog-cavalier-cream-gentle.png",
"/demo-pets/dog-cocker-buff-friendly.png",
"/demo-pets/dog-corgi.png",
"/demo-pets/dog-dachshund-black-tan.png",
"/demo-pets/dog-golden-before.png",
"/demo-pets/dog-pomeranian-white-studio.png",
"/demo-pets/dog-schnauzer-black-groomed.png",
"/demo-pets/dog-setter-red-sunlit.png",
"/demo-pets/dog-sheepdog-merle-running.png",
];
const puggleImages = [
"/demo-pets/dog-puggle-fawn-playful.png",
"/demo-pets/dog-puggle-black-sitting.png",
"/demo-pets/dog-puggle-cream-groomed.png",
"/demo-pets/dog-puggle-fawn-grooming.png",
];
// ── Service definitions ──────────────────────────────────────────────────────
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
@@ -659,7 +621,6 @@ async function seed() {
const clientRecords: ClientRecord[] = [];
const petRecords: PetRecord[] = [];
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
const clientBatchSize = 50;
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
@@ -691,7 +652,7 @@ async function seed() {
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
for (let p = 0; p < petCount; p++) {
const petId = uuid();
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds);
const breed = pick(dogBreeds);
const dob = new Date(now);
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
dob.setMonth(randInt(0, 11));
@@ -710,11 +671,9 @@ async function seed() {
shampooPreference: pick(shampoos),
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
customFields: {},
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
});
petRecords.push({ id: petId, clientId });
petIndex++;
}
}
@@ -745,7 +704,6 @@ async function seed() {
shampooPreference: pet.shampooPreference,
specialCareNotes: pet.specialCareNotes,
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 })
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
await db.insert(schema.pets)
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") })
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") } });
// Create one completed appointment for this client
const apptId = uuid();
const svcIdx = 0;
+1 -1
View File
@@ -87,7 +87,7 @@ importers:
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))
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))
lucide-react:
specifier: ^0.577.0