Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e72a1441 | |||
| 16fb887bbf | |||
| c01c8d93d7 | |||
| e8c81bfccd |
@@ -7,5 +7,3 @@ apps/web/dist
|
||||
apps/api/dist
|
||||
packages/db/dist
|
||||
packages/types/dist
|
||||
.turbo
|
||||
screenshots/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -44,8 +42,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -66,8 +62,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -107,8 +101,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -246,6 +238,7 @@ jobs:
|
||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||
|
||||
# Run migration with PR image
|
||||
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
|
||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||
apiVersion: batch/v1
|
||||
@@ -310,8 +303,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -418,17 +409,11 @@ jobs:
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
# Check if PR already exists for this branch
|
||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||
else
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
fi
|
||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
|
||||
@@ -14,29 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Validate tag format
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
|
||||
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag format valid: $TAG"
|
||||
|
||||
- name: Verify image exists in GHCR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
# Check that the API image exists — if API was pushed, web/migrate were too
|
||||
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
|
||||
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
|
||||
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
|
||||
@@ -62,8 +62,6 @@ jobs:
|
||||
fi
|
||||
|
||||
# Update seed Job name to include short SHA (immutable template fix)
|
||||
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
|
||||
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
|
||||
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
@@ -35,9 +34,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3000
|
||||
RUN apk add --no-cache curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
|
||||
# Migrate stage — runs drizzle-kit migrate against the database
|
||||
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "db:reset"]
|
||||
CMD ["pnpm", "db:reset"]
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
"hono": "^4.6.17",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
"telnyx": "^1.23.0",
|
||||
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
|
||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
let deletedId: string | null = null;
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
appointmentRows = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
deletedId = null;
|
||||
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const tableName = (table as { _name?: string })._name;
|
||||
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
||||
return makeChainable(rows);
|
||||
},
|
||||
from: () => makeChainable(selectRows),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
}),
|
||||
clients,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
|
||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||
});
|
||||
|
||||
it("creates a client with name and email", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||
it("creates a client with only required name field", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]!.name).toBe("Dana");
|
||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||
});
|
||||
|
||||
it("rejects empty name", async () => {
|
||||
|
||||
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
and: (..._clauses: unknown[]) => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
staff,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -363,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("allows a manager with multiple allowed roles", async () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { cors } from "hono/cors";
|
||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||
import { getAuth, initAuth } from "./lib/auth.js";
|
||||
import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
@@ -28,31 +28,15 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Global middleware
|
||||
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
||||
.split(",")
|
||||
.map((o) => o.trim());
|
||||
|
||||
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
|
||||
app.use("*", logger());
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: (origin, ctx) => {
|
||||
if (!origin) {
|
||||
return ALLOWED_ORIGIN;
|
||||
}
|
||||
if (TRUSTED_ORIGINS.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
ctx.status(403);
|
||||
return null;
|
||||
},
|
||||
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
@@ -66,9 +50,6 @@ app.route("/api/book", bookRouter);
|
||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||
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
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
@@ -111,11 +92,6 @@ app.get("/api/setup/status", async (c) => {
|
||||
return c.json({ needsSetup: !superUser });
|
||||
});
|
||||
|
||||
// Public auth providers endpoint — no auth required, tells frontend which login options are available
|
||||
app.get("/api/auth/providers", async (c) => {
|
||||
return c.json({ providers: getActiveProviders() });
|
||||
});
|
||||
|
||||
// Protected API routes
|
||||
const api = app.basePath("/api");
|
||||
api.use("*", authMiddleware);
|
||||
@@ -124,13 +100,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 ────────────────────────────────────────────────────────────────
|
||||
@@ -202,24 +172,9 @@ api.route("/search", searchRouter);
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await initAuth();
|
||||
console.log(`API server listening on port ${port}`);
|
||||
const server = serve({ fetch: app.fetch, port });
|
||||
serve({ fetch: app.fetch, port });
|
||||
|
||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||
startReminderScheduler();
|
||||
|
||||
function shutdown() {
|
||||
console.log("Shutting down gracefully...");
|
||||
server.close(() => {
|
||||
console.log("HTTP server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.error("Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb, authProviderConfig, eq } 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_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
@@ -28,21 +27,6 @@ export function getAuthPromise() {
|
||||
return authInitPromise;
|
||||
}
|
||||
|
||||
/** Returns which OAuth/social providers are configured via env vars. */
|
||||
export function getActiveProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
providers.push("google");
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
providers.push("github");
|
||||
}
|
||||
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
|
||||
providers.push("authentik");
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-initializes the Better-Auth instance after auth config changes.
|
||||
*
|
||||
@@ -89,14 +73,8 @@ export async function initAuth(): Promise<void> {
|
||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET!,
|
||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
storage: "memory",
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
@@ -174,63 +152,6 @@ export async function initAuth(): Promise<void> {
|
||||
console.log("[auth] Using env var config (no DB config found)");
|
||||
}
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
||||
const issuerHostname = issuerUrlObj.hostname;
|
||||
|
||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||
let oidcConfig: Record<string, string> = {};
|
||||
try {
|
||||
const discoveryRes = await fetch(discoveryUrlStr);
|
||||
if (discoveryRes.ok) {
|
||||
const discovery = await discoveryRes.json() as {
|
||||
authorization_endpoint?: string;
|
||||
token_endpoint?: string;
|
||||
userinfo_endpoint?: string;
|
||||
};
|
||||
const replaceHost = (url: string, newHost: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const newParsed = new URL(newHost);
|
||||
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const authzUrl = discovery.authorization_endpoint;
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const authzUrlObj = new URL(authzUrl);
|
||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
||||
if (authzUrlObj.hostname !== issuerHostname) {
|
||||
throw new Error(
|
||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||
);
|
||||
}
|
||||
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
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -238,28 +159,6 @@ export async function initAuth(): Promise<void> {
|
||||
}),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
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: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
@@ -267,27 +166,20 @@ export async function initAuth(): Promise<void> {
|
||||
providerId: providerConfig.providerId,
|
||||
clientId: providerConfig.clientId,
|
||||
clientSecret: providerConfig.clientSecret,
|
||||
discoveryUrl: discoveryUrlStr,
|
||||
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
||||
...(providerConfig.internalBaseUrl
|
||||
? {
|
||||
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),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
* Server-side audit logging middleware for portal routes.
|
||||
* Applied after validatePortalSession in the middleware chain.
|
||||
*
|
||||
* After the route handler completes (await next()), inserts an audit log entry
|
||||
* into impersonationAuditLogs:
|
||||
* - sessionId: from c.get("portalSessionId")
|
||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||
* - pageVisited: c.req.path
|
||||
* - metadata: { method, statusCode: c.res.status }
|
||||
*
|
||||
* Log entries are written for both success and error responses.
|
||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||
* request is not affected.
|
||||
*/
|
||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
await next();
|
||||
|
||||
const sessionId = c.get("portalSessionId");
|
||||
if (!sessionId) return;
|
||||
|
||||
const method = c.req.method;
|
||||
const routePath = c.req.path;
|
||||
const pageVisited = c.req.path;
|
||||
const statusCode = c.res.status;
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId,
|
||||
action: `${method} ${routePath}`,
|
||||
pageVisited,
|
||||
metadata: { method, statusCode },
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
console.error("[portalAudit] Failed to write audit log:", err);
|
||||
}
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
portalClientId: string;
|
||||
portalSessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||
* Must be applied to all portal routes.
|
||||
*
|
||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||
* Returns 401 if session is invalid/missing/expired.
|
||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||
*/
|
||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("portalClientId", session.clientId);
|
||||
c.set("portalSessionId", session.id);
|
||||
await next();
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, 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;
|
||||
@@ -89,31 +89,33 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (fallbackRow) {
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Auto-link by email: staff record exists with matching email but no userId
|
||||
if (jwt.email) {
|
||||
const [byEmail] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||
if (byEmail) {
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub, updatedAt: new Date() })
|
||||
.where(eq(staff.id, byEmail.id));
|
||||
c.set("staff", { ...byEmail, userId: jwt.sub });
|
||||
await next();
|
||||
return;
|
||||
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
|
||||
);
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -166,9 +168,9 @@ export function requireRoleOrSuperUser(
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
error: staffRow.isSuperUser
|
||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||
: "Forbidden: super user privileges required",
|
||||
},
|
||||
403
|
||||
);
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
export const appointmentGroupsRouter = new Hono();
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
const clientId = c.req.query("clientId");
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const groupConditions = clientId
|
||||
? [eq(appointmentGroups.clientId, clientId)]
|
||||
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
}))
|
||||
.filter((g) => !from || g.appointments.length > 0);
|
||||
|
||||
if (isGroomer) {
|
||||
return c.json(
|
||||
result.filter((g) =>
|
||||
g.appointments.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select()
|
||||
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
serviceId: appointments.serviceId,
|
||||
serviceName: services.name,
|
||||
staffId: appointments.staffId,
|
||||
batherStaffId: appointments.batherStaffId,
|
||||
staffName: staff.name,
|
||||
status: appointments.status,
|
||||
startTime: appointments.startTime,
|
||||
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
.where(eq(appointments.groupId, id))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
if (
|
||||
isGroomer &&
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email })
|
||||
.from(clients)
|
||||
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
|
||||
zValidator("json", createGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
if (staffRow?.role === "groomer") {
|
||||
return c.json(
|
||||
{ error: "Forbidden: groomers cannot create group bookings" },
|
||||
403
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
const startTime = new Date(body.startTime);
|
||||
|
||||
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointmentGroups)
|
||||
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
|
||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
|
||||
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
delayMs: number,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[appointments] ${context}: ${lastError}`);
|
||||
}
|
||||
|
||||
export const appointmentsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createAppointmentSchema = z.object({
|
||||
@@ -62,10 +41,6 @@ const createAppointmentSchema = z.object({
|
||||
frequencyWeeks: z.number().int().min(1).max(52),
|
||||
count: z.number().int().min(2).max(52),
|
||||
})
|
||||
.refine(
|
||||
(r) => r.frequencyWeeks * r.count <= 52,
|
||||
{ message: "Recurrence series must not exceed 1 year" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -188,28 +163,6 @@ appointmentsRouter.post(
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrence) {
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
@@ -233,54 +186,11 @@ appointmentsRouter.post(
|
||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let first: typeof appointments.$inferSelect | undefined;
|
||||
const conflictingInstances: number[] = [];
|
||||
for (let i = 0; i < recurrence.count; i++) {
|
||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||
const instanceEnd = new Date(
|
||||
instanceStart.getTime() + durationMs
|
||||
);
|
||||
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
@@ -291,19 +201,9 @@ appointmentsRouter.post(
|
||||
seriesIndex: i,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||
if (i === 0) first = inserted;
|
||||
}
|
||||
|
||||
if (conflictingInstances.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
||||
),
|
||||
{ statusCode: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!first) throw new Error("No appointments created");
|
||||
return first;
|
||||
});
|
||||
@@ -321,12 +221,9 @@ appointmentsRouter.post(
|
||||
}
|
||||
|
||||
// Send confirmation email (fire-and-forget — never fails the request)
|
||||
withRetry(
|
||||
() => sendConfirmationEmail(db, firstRow),
|
||||
2,
|
||||
1000,
|
||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||
);
|
||||
sendConfirmationEmail(db, firstRow).catch((err) => {
|
||||
console.error("[appointments] Failed to send confirmation email:", err);
|
||||
});
|
||||
|
||||
return c.json(firstRow, 201);
|
||||
}
|
||||
@@ -338,35 +235,44 @@ async function sendConfirmationEmail(
|
||||
db: ReturnType<typeof getDb>,
|
||||
appt: typeof appointments.$inferSelect
|
||||
): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(eq(appointments.id, appt.id))
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
if (!client || !client.email || client.emailOptOut) return;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, appt.petId))
|
||||
.limit(1);
|
||||
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, appt.serviceId))
|
||||
.limit(1);
|
||||
|
||||
let groomerName: string | null = null;
|
||||
if (appt.staffId) {
|
||||
const [groomer] = await db
|
||||
.select({ name: staff.name })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, appt.staffId))
|
||||
.limit(1);
|
||||
groomerName = groomer?.name ?? null;
|
||||
}
|
||||
|
||||
if (!pet || !service) return;
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildConfirmationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
buildConfirmationEmail(client.email, {
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
})
|
||||
);
|
||||
@@ -446,76 +352,6 @@ appointmentsRouter.patch(
|
||||
|
||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||
for (const appt of affected) {
|
||||
const newStart =
|
||||
startDeltaMs !== 0
|
||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
||||
: appt.startTime;
|
||||
const newEnd =
|
||||
endDeltaMs !== 0
|
||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
||||
: appt.endTime;
|
||||
const newStaffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: appt.staffId;
|
||||
const newBatherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: appt.batherStaffId;
|
||||
|
||||
if (
|
||||
newStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.staffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, newStaffId),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newBatherStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.batherStaffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, newBatherStaffId),
|
||||
eq(appointments.batherStaffId, newBatherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const apptUpdate: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -551,13 +387,6 @@ appointmentsRouter.patch(
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -569,8 +398,7 @@ appointmentsRouter.patch(
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
updateFields.staffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
@@ -606,11 +434,6 @@ appointmentsRouter.patch(
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
@@ -638,29 +461,6 @@ appointmentsRouter.patch(
|
||||
}
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, batherStaffId),
|
||||
eq(appointments.batherStaffId, batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
@@ -735,12 +535,9 @@ appointmentsRouter.delete("/:id", async (c) => {
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||
console.error("[appointments] Failed to notify waitlist:", err);
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
@@ -763,12 +560,9 @@ appointmentsRouter.delete("/:id", async (c) => {
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||
console.error("[appointments] Failed to notify waitlist:", err);
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const bookingSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
startTime: z.string().datetime().refine(
|
||||
(dt) => new Date(dt) > new Date(),
|
||||
{ message: "Appointment must be in the future" }
|
||||
),
|
||||
startTime: z.string().datetime(),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
@@ -268,36 +265,29 @@ bookRouter.get("/confirm/:token", async (c) => {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Idempotent confirm: if already confirmed, redirect to success
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
@@ -319,15 +309,19 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
||||
// but guard against edge cases where token lookup still works)
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
// Single-use cancellation: nullify token after use
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
@@ -335,17 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (!staffMember || !staffMember.icalToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const storedToken = staffMember.icalToken;
|
||||
const incomingToken = token;
|
||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
||||
if (
|
||||
storedBuf.length !== incomingBuf.length ||
|
||||
!timingSafeEqual(storedBuf, incomingBuf)
|
||||
) {
|
||||
if (!staffMember || staffMember.icalToken !== token) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
smsOptIn: z.boolean().optional(),
|
||||
smsConsentText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -97,7 +95,6 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
smsOptOut: z.boolean().optional(),
|
||||
});
|
||||
|
||||
clientsRouter.patch(
|
||||
@@ -110,19 +107,13 @@ clientsRouter.patch(
|
||||
|
||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||
|
||||
// When disabling, set disabledAt; when re-enabling, clear it
|
||||
if (body.status === "disabled") {
|
||||
setValues.disabledAt = now;
|
||||
} else if (body.status === "active") {
|
||||
setValues.disabledAt = null;
|
||||
}
|
||||
|
||||
if (body.smsOptOut === true) {
|
||||
setValues.smsOptIn = false;
|
||||
setValues.smsOptOutDate = now;
|
||||
delete setValues.smsOptOut;
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
@@ -144,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
|
||||
const [existingAppt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existingAppt) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.where(eq(clients.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
export const groomingLogsRouter = new Hono();
|
||||
|
||||
const createLogSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.query("petId");
|
||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
|
||||
zValidator("json", createLogSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
if (appointmentId) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.id, appointmentId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
} else {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const { groomedAt, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
|
||||
|
||||
groomingLogsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [log] = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.limit(1);
|
||||
if (!log) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, log.petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
const [row] = await db
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
invoices,
|
||||
invoiceLineItems,
|
||||
invoiceTipSplits,
|
||||
refunds,
|
||||
appointments,
|
||||
services,
|
||||
clients,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
export const invoicesRouter = new Hono();
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
@@ -45,61 +43,53 @@ const updateInvoiceSchema = z.object({
|
||||
});
|
||||
|
||||
// List invoices
|
||||
const listInvoicesQuerySchema = z.object({
|
||||
clientId: z.string().uuid().optional(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
invoicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const appointmentId = c.req.query("appointmentId");
|
||||
const status = c.req.query("status");
|
||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
});
|
||||
|
||||
invoicesRouter.get(
|
||||
"/",
|
||||
zValidator("query", listInvoicesQuerySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
}
|
||||
);
|
||||
|
||||
// Get single invoice with line items and tip splits
|
||||
invoicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
@@ -126,8 +116,8 @@ const tipSplitSchema = z.object({
|
||||
})
|
||||
).min(1).refine(
|
||||
(splits) => {
|
||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
return totalBps === 10000;
|
||||
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||
return Math.abs(total - 100) < 0.01;
|
||||
},
|
||||
{ message: "Split percentages must sum to 100" }
|
||||
),
|
||||
@@ -171,13 +161,12 @@ invoicesRouter.post(
|
||||
}
|
||||
});
|
||||
|
||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
const splits = await db
|
||||
.select()
|
||||
.from(invoiceTipSplits)
|
||||
.where(eq(invoiceTipSplits.invoiceId, id));
|
||||
|
||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
||||
return c.json(splits, 201);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -302,13 +291,6 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
||||
});
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["pending", "void"],
|
||||
pending: ["draft", "paid", "void"],
|
||||
paid: ["void"],
|
||||
void: [],
|
||||
};
|
||||
|
||||
// Update invoice
|
||||
invoicesRouter.patch(
|
||||
"/:id",
|
||||
@@ -324,14 +306,8 @@ invoicesRouter.patch(
|
||||
.where(eq(invoices.id, id));
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (body.status !== undefined) {
|
||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||
if (!allowed.includes(body.status)) {
|
||||
return c.json(
|
||||
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
||||
422
|
||||
);
|
||||
}
|
||||
if (current.status === "void") {
|
||||
return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||
@@ -362,61 +338,3 @@ invoicesRouter.patch(
|
||||
return c.json({ ...updated, lineItems });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { processRefund } from "../services/payment.js";
|
||||
|
||||
const refundSchema = z.object({
|
||||
amountCents: z.number().int().nonnegative().optional(),
|
||||
idempotencyKey: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/refund",
|
||||
zValidator("json", refundSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staff = c.get("staff");
|
||||
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
||||
if (staff.role !== "manager" && !staff.isSuperUser) {
|
||||
return c.json({ error: "Manager role required" }, 403);
|
||||
}
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
if (!invoice.stripePaymentIntentId) {
|
||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
if (body.idempotencyKey) {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
||||
if (existing) {
|
||||
return c.json({ refundId: existing.stripeRefundId });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: result.refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId: result.refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
export const portalRouter = new Hono<AppEnv>();
|
||||
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||
if (!sessionId) return null;
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
if (!session || session.expiresAt <= new Date()) return null;
|
||||
return session.clientId;
|
||||
}
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
@@ -24,12 +35,6 @@ portalRouter.get("/me", async (c) => {
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/config", async (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
@@ -38,7 +43,9 @@ portalRouter.get("/services", async (c) => {
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
@@ -88,7 +95,9 @@ portalRouter.get("/appointments", async (c) => {
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||
@@ -96,7 +105,9 @@ portalRouter.get("/pets", async (c) => {
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
@@ -112,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
date: inv.createdAt,
|
||||
createdAt: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
@@ -131,7 +142,12 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -174,7 +190,12 @@ portalRouter.patch(
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -223,7 +244,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -287,7 +313,28 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
let clientId: string | null = null;
|
||||
if (sessionId) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
clientId = session.clientId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
@@ -311,7 +358,26 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -320,7 +386,7 @@ portalRouter.patch(
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== clientId) {
|
||||
if (existing.clientId !== session.clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -342,7 +408,26 @@ portalRouter.patch(
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
@@ -351,7 +436,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== clientId) {
|
||||
if (entry.clientId !== session.clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -363,105 +448,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
getStripeClient,
|
||||
} from "../services/payment.js";
|
||||
|
||||
const payMultipleSchema = z.object({
|
||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/pay-multiple",
|
||||
zValidator("json", payMultipleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, body.invoiceIds));
|
||||
|
||||
if (invoiceRows.length !== body.invoiceIds.length) {
|
||||
return c.json({ error: "One or more invoices not found" }, 404);
|
||||
}
|
||||
|
||||
for (const inv of invoiceRows) {
|
||||
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (inv.status === "draft" || inv.status === "void") {
|
||||
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
||||
}
|
||||
if (inv.status === "paid") {
|
||||
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
||||
}
|
||||
}
|
||||
|
||||
const firstInvoice = invoiceRows[0];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||
if (!allSameClient) {
|
||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
return c.json(methods);
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
||||
|
||||
const result = await createSetupIntent(customerId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
||||
return c.json({ error: "Payment method not found" }, 404);
|
||||
}
|
||||
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||
// Allows the dev login selector to vend an impersonation session for a client
|
||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||
|
||||
@@ -286,10 +286,6 @@ reportsRouter.get("/clients", async (c) => {
|
||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||
|
||||
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const churnRisk = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [churnCountRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(
|
||||
db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.as("churn_count")
|
||||
);
|
||||
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
newClients,
|
||||
activeInPeriodCount: activeInPeriod.length,
|
||||
churnRisk,
|
||||
churnRiskTotal,
|
||||
page,
|
||||
limit,
|
||||
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
||||
churnRiskTotal: churnRisk.length,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
basePriceCents: z.number().int().positive(),
|
||||
durationMinutes: z.number().int().positive().max(480),
|
||||
durationMinutes: z.number().int().positive(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,40 +4,11 @@ import { z } from "zod/v3";
|
||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
const RATE_LIMIT_MAX = 10;
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
||||
}
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count };
|
||||
}
|
||||
|
||||
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
|
||||
@@ -203,74 +174,52 @@ const authProviderTestSchema = z.object({
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
let row: typeof authProviderConfig.$inferSelect;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [superUser] = await tx
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
||||
}
|
||||
if (superUser) {
|
||||
// Setup already completed — lock this endpoint permanently
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
|
||||
}
|
||||
|
||||
const [existingConfig] = await tx
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
|
||||
const [existingConfig] = await db
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig) {
|
||||
throw Object.assign(new Error("config-exists"), { code: 409 });
|
||||
}
|
||||
if (existingConfig) {
|
||||
return c.json({ error: "Auth provider is already configured." }, 409);
|
||||
}
|
||||
|
||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
// Encrypt clientSecret before storing
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
|
||||
const [configRow] = await tx
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
const [row] = await db
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!configRow) {
|
||||
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
||||
}
|
||||
|
||||
return configRow;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { code?: number };
|
||||
if (e.message === "setup-complete") {
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
||||
}
|
||||
if (e.message === "config-exists") {
|
||||
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
||||
}
|
||||
if (e.message === "insert-failed") {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
||||
}
|
||||
throw err;
|
||||
if (!row) {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -294,13 +243,6 @@ setupRouter.post("/auth-provider", async (c) => {
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
*/
|
||||
setupRouter.post("/auth-provider/test", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
|
||||
@@ -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,119 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
webhooksRouter.post("/stripe", async (c) => {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
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 = getStripeClient();
|
||||
if (!stripe) {
|
||||
return c.json({ error: "Stripe not configured" }, 503);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} 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 parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||
.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, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} 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 });
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import Stripe from "stripe";
|
||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
||||
|
||||
let _stripe: Stripe | null | undefined;
|
||||
|
||||
export function getStripeClient(): Stripe | null {
|
||||
if (_stripe === undefined) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) return null;
|
||||
_stripe = new Stripe(secretKey);
|
||||
}
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return null;
|
||||
|
||||
if (client.stripeCustomerId) return client.stripeCustomerId;
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
metadata: { groombook_client_id: clientId },
|
||||
});
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
export async function createPaymentIntent(
|
||||
invoiceIdOrIds: string | string[],
|
||||
clientId: string
|
||||
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||
const firstInvoiceId = invoiceIds[0];
|
||||
if (!firstInvoiceId) return null;
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, firstInvoiceId));
|
||||
|
||||
const [invoice] = invoiceRows;
|
||||
if (!invoice) return null;
|
||||
|
||||
let totalCents = invoice.totalCents;
|
||||
if (invoiceIds.length > 1) {
|
||||
const allInvoices = await db
|
||||
.select({ totalCents: invoices.totalCents })
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, invoiceIds));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||
}
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: totalCents,
|
||||
currency: "usd",
|
||||
customer: stripeCustomerId,
|
||||
metadata: {
|
||||
groombook_invoice_ids: invoiceIds.join(","),
|
||||
groombook_client_id: clientId,
|
||||
},
|
||||
automatic_payment_methods: { enabled: true },
|
||||
});
|
||||
|
||||
for (const invId of invoiceIds) {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invId));
|
||||
}
|
||||
|
||||
const clientSecret = paymentIntent.client_secret;
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return { clientSecret, paymentIntentId: paymentIntent.id };
|
||||
}
|
||||
|
||||
export async function processRefund(
|
||||
invoiceId: string,
|
||||
amountCents?: number
|
||||
): Promise<{ refundId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
||||
if (!invoice?.stripePaymentIntentId) return null;
|
||||
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: invoice.stripePaymentIntentId,
|
||||
amount: amountCents,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
return { refundId: refund.id };
|
||||
}
|
||||
|
||||
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const methods = await stripe.paymentMethods.list({
|
||||
customer: stripeCustomerId,
|
||||
type: "card",
|
||||
});
|
||||
|
||||
return methods.data;
|
||||
}
|
||||
|
||||
export async function attachPaymentMethod(
|
||||
clientId: string,
|
||||
paymentMethodId: string
|
||||
): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return false;
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
await stripe.paymentMethods.detach(paymentMethodId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
});
|
||||
|
||||
return { clientSecret: setupIntent.client_secret! };
|
||||
}
|
||||
@@ -12,16 +12,14 @@ import {
|
||||
services,
|
||||
staff,
|
||||
reminderLogs,
|
||||
session,
|
||||
} from "@groombook/db";
|
||||
import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
} from "./email.js";
|
||||
import { smsSend } from "./sms.js";
|
||||
|
||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||
|
||||
// How many hours before the appointment to send each reminder.
|
||||
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||
function getReminderWindows(): { label: string; hours: number }[] {
|
||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||
@@ -31,14 +29,20 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
// Checks for upcoming appointments that need reminders and sends them.
|
||||
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||
export async function runReminderCheck(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
for (const window of getReminderWindows()) {
|
||||
// Target window: appointments starting between (hours - 1) and hours from now.
|
||||
// Running every minute means we check a 1-minute slice; the 1-hour window
|
||||
// ensures we catch appointments that started between heartbeats.
|
||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||
|
||||
// Find upcoming appointments in this time window that haven't been cancelled/completed
|
||||
const upcoming = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
@@ -60,38 +64,23 @@ export async function runReminderCheck(): Promise<void> {
|
||||
);
|
||||
|
||||
for (const appt of upcoming) {
|
||||
const [emailLog] = await db
|
||||
// Check if reminder already sent (unique constraint prevents double-send)
|
||||
const existing = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
eq(reminderLogs.channel, "email")
|
||||
eq(reminderLogs.reminderType, window.label)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const [smsLog] = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
eq(reminderLogs.channel, "sms")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) continue; // already sent
|
||||
|
||||
// Fetch related records for the email
|
||||
const [client] = await db
|
||||
.select({
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
emailOptOut: clients.emailOptOut,
|
||||
smsOptIn: clients.smsOptIn,
|
||||
phone: clients.phone,
|
||||
})
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
@@ -122,6 +111,8 @@ export async function runReminderCheck(): Promise<void> {
|
||||
|
||||
if (!pet || !service) continue;
|
||||
|
||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||
let confirmationToken = appt.confirmationToken;
|
||||
if (!confirmationToken) {
|
||||
confirmationToken = randomBytes(32).toString("hex");
|
||||
@@ -131,74 +122,39 @@ export async function runReminderCheck(): Promise<void> {
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
if (!emailLog) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsLog && client.smsOptIn && client.phone) {
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||
const smsBody = [
|
||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
||||
`Confirm: ${confirmUrl}`,
|
||||
`Cancel: ${cancelUrl}`,
|
||||
TCPA_OPT_OUT,
|
||||
].join(". ");
|
||||
try {
|
||||
const smsOk = await smsSend(client.phone, smsBody);
|
||||
if (smsOk) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[reminders] SMS send failed:", err);
|
||||
}
|
||||
if (sent) {
|
||||
// Record send — ignore conflicts (race condition between instances)
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the cron scheduler. Call once at server startup.
|
||||
export function startReminderScheduler(): void {
|
||||
// Run every minute
|
||||
cron.schedule("* * * * *", () => {
|
||||
runReminderCheck().catch((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");
|
||||
}
|
||||
|
||||
export async function runSessionCleanup(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.delete(session)
|
||||
.where(lt(session.expiresAt, now));
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||
validateWebhookSignature(req: Request): boolean;
|
||||
}
|
||||
|
||||
interface TelnyxSmsResult {
|
||||
message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function createTelnyxClient(): Telnyx | null {
|
||||
const apiKey = process.env.TELNYX_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return new Telnyx(apiKey);
|
||||
}
|
||||
|
||||
let _client: Telnyx | null | undefined;
|
||||
|
||||
function getClient(): Telnyx | null {
|
||||
if (_client === undefined) _client = createTelnyxClient();
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getFromNumber(): string | null {
|
||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
}
|
||||
|
||||
function isE164(phone: string): boolean {
|
||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||
|
||||
const from = getFromNumber();
|
||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||
|
||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
};
|
||||
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
payload.media_urls = mediaUrls;
|
||||
}
|
||||
|
||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||
return {
|
||||
messageId: smsResult.message_id,
|
||||
status: smsResult.status,
|
||||
};
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements SmsProvider {
|
||||
async sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return sendSms(to, body, mediaUrls);
|
||||
}
|
||||
|
||||
validateWebhookSignature(req: Request): boolean {
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const signature = req.headers.get("telnyx-signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
const sigByte = sigBuf[i] ?? 0;
|
||||
const expByte = expBuf[i] ?? 0;
|
||||
diff |= sigByte ^ expByte;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: SmsProvider | null | undefined;
|
||||
|
||||
export function createSmsProvider(): SmsProvider | null {
|
||||
if (_provider === undefined) {
|
||||
if (process.env.SMS_ENABLED !== "true") {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
switch (process.env.SMS_PROVIDER) {
|
||||
case "telnyx": {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
_provider = new TelnyxProvider();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_provider = null;
|
||||
}
|
||||
}
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export async function smsSend(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<boolean> {
|
||||
const provider = createSmsProvider();
|
||||
if (!provider) return false;
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
declare module "telnyx" {
|
||||
export interface MessageResult {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface MessagesCreateParams {
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
media_urls?: string[];
|
||||
}
|
||||
|
||||
export class Telnyx {
|
||||
constructor(apiKey: string);
|
||||
messages: {
|
||||
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -20,5 +20,3 @@ FROM nginx:alpine AS runner
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:80/ || exit 1
|
||||
|
||||
@@ -3,22 +3,10 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
}
|
||||
|
||||
# Proxy API calls to the API service
|
||||
|
||||
@@ -14,10 +14,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@groombook/types": "workspace:*",
|
||||
"@stripe/react-stripe-js": "^6.1.0",
|
||||
"@stripe/stripe-js": "^9.1.0",
|
||||
"@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",
|
||||
|
||||
|
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 { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
@@ -18,37 +18,16 @@ 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) => {
|
||||
const handleLogin = async () => {
|
||||
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: "authentik", callbackURL: window.location.origin });
|
||||
};
|
||||
|
||||
const isGoogle = providers.includes("google");
|
||||
const isGitHub = providers.includes("github");
|
||||
const isAuthentik = providers.includes("authentik");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -74,94 +53,23 @@ 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")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#1a202c",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
)}
|
||||
{isGitHub && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#24292f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: isAuthentik ? "0.5rem" : 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
{isAuthentik && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("authentik")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -181,7 +89,6 @@ const NAV_LINKS = [
|
||||
|
||||
function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { branding } = useBranding();
|
||||
|
||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||
@@ -210,7 +117,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 +130,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>
|
||||
|
||||
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
@@ -226,6 +226,7 @@ export function CustomerPortal() {
|
||||
)}
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment as any}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||
import { PetForm } from "./PetForm.js";
|
||||
import { authClient } from "../../lib/auth-client.js";
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordsMatch = newPassword === confirmPassword;
|
||||
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
||||
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
// TODO: Wire up to actual password-change API endpoint once backend support exists
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (authClient as any).changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Failed to change password.");
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
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"
|
||||
>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||
|
||||
interface Invoice {
|
||||
@@ -12,28 +10,31 @@ interface Invoice {
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
brand: string;
|
||||
last4: string;
|
||||
expiryMonth: number;
|
||||
expiryYear: number;
|
||||
}
|
||||
|
||||
interface Package {
|
||||
name: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface BillingPaymentsProps {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||
const [autopay, setAutopay] = useState(false);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [publishableKey, setPublishableKey] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
||||
fetch("/api/portal/config", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
fetch("/api/portal/invoices", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
fetch("/api/portal/payment-methods", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
]);
|
||||
const response = await fetch("/api/portal/invoices", {
|
||||
headers: {
|
||||
"X-Impersonation-Session-Id": sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!configRes.ok) throw new Error("Failed to fetch config");
|
||||
const configData = await configRes.json();
|
||||
setPublishableKey(configData.stripePublishableKey ?? "");
|
||||
|
||||
const invoicesData = await invoicesRes.json();
|
||||
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
|
||||
|
||||
if (methodsRes.ok) {
|
||||
const methodsData = await methodsRes.json();
|
||||
setPaymentMethods(
|
||||
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
|
||||
id: m.id,
|
||||
brand: m.card?.brand ?? "unknown",
|
||||
last4: m.card?.last4 ?? "****",
|
||||
expiryMonth: m.card?.exp_month ?? 0,
|
||||
expiryYear: m.card?.exp_year ?? 0,
|
||||
}))
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch invoices");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setInvoices(Array.isArray(data) ? data : data.invoices || []);
|
||||
setPaymentMethods(data.paymentMethods || []);
|
||||
setPackages(data.packages || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||
const formatCents = (cents: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cents / 100);
|
||||
};
|
||||
|
||||
const pending = invoices.filter((i) => i.status === "pending");
|
||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Outstanding Balance Banner */}
|
||||
{totalPending > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
{tab === "invoices" && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -161,7 +152,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
<th className="px-5 py-3 font-medium">Description</th>
|
||||
<th className="px-5 py-3 font-medium">Amount</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium" />
|
||||
<th className="px-5 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -169,7 +160,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||
<td className="px-5 py-3 text-stone-700">
|
||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-stone-600">
|
||||
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Methods */}
|
||||
{tab === "payment" && (
|
||||
<div className="space-y-4">
|
||||
{paymentMethods.length === 0 ? (
|
||||
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={method.id}
|
||||
key={`${method.brand}-${method.last4}`}
|
||||
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
if (res.ok) {
|
||||
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<button className="text-sm text-blue-600 hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autopay */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
Automatically charge after each appointment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{tab === "packages" && (
|
||||
<div className="space-y-4">
|
||||
{packages.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No packages purchased</p>
|
||||
) : (
|
||||
packages.map((pkg, index) => (
|
||||
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPaymentModal && publishableKey && (
|
||||
<PaymentModalWrapper
|
||||
key={Date.now()}
|
||||
sessionId={sessionId ?? ""}
|
||||
publishableKey={publishableKey}
|
||||
{/* Payment Modal */}
|
||||
{showPaymentModal && (
|
||||
<PaymentModal
|
||||
pending={pending}
|
||||
totalPending={totalPending}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
onSuccess={() => {
|
||||
setInvoices((prev) =>
|
||||
prev.map((inv) =>
|
||||
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
|
||||
)
|
||||
);
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentModalWrapperProps {
|
||||
sessionId: string;
|
||||
publishableKey: string;
|
||||
function PaymentModal({
|
||||
pending,
|
||||
totalPending: _totalPending,
|
||||
onClose,
|
||||
}: {
|
||||
pending: Invoice[];
|
||||
totalPending: number;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
|
||||
const [stripePromise] = useState(() =>
|
||||
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
|
||||
}) {
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
||||
new Set(pending.map((i) => i.id))
|
||||
);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
|
||||
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentModalProps {
|
||||
sessionId: string;
|
||||
pending: Invoice[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
|
||||
const [saveCard, setSaveCard] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cents / 100);
|
||||
|
||||
const toggleInvoice = (id: string) => {
|
||||
const next = new Set(selectedInvoices);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
setSelectedInvoices(next);
|
||||
};
|
||||
|
||||
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!stripe || !elements) return;
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isMulti = selectedInvoices.size > 1;
|
||||
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
|
||||
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Impersonation-Session-Id": sessionId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Failed to initialize payment");
|
||||
}
|
||||
|
||||
const { clientSecret } = await res.json();
|
||||
|
||||
const { error: stripeError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
clientSecret,
|
||||
confirmParams: saveCard
|
||||
? { setup_future_usage: "off_session" }
|
||||
: undefined,
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message ?? "Payment failed");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsComplete(true);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An unexpected error occurred");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setIsProcessing(false);
|
||||
setIsComplete(true);
|
||||
};
|
||||
|
||||
const selectedTotal = pending
|
||||
.filter((i) => selectedInvoices.has(i.id))
|
||||
.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -431,7 +357,10 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
<p className="text-stone-500 text-sm mb-6">
|
||||
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
||||
</p>
|
||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
|
||||
<span className="text-sm font-medium text-stone-800">
|
||||
{formatCents(inv.totalCents)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-stone-600">Total</span>
|
||||
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
||||
<span className="text-lg font-bold text-stone-800">
|
||||
{formatCents(selectedTotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PaymentElement />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveCard}
|
||||
onChange={(e) => setSaveCard(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||
/>
|
||||
<span className="text-sm text-stone-600">Save card for future payments</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
||||
disabled={selectedInvoices.size === 0 || isProcessing}
|
||||
className="flex-1 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"
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Pay Now"}
|
||||
@@ -529,8 +444,4 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
);
|
||||
}
|
||||
|
||||
export function BillingPayments(props: BillingPaymentsProps) {
|
||||
return <BillingPaymentsInner {...props} />;
|
||||
}
|
||||
|
||||
export default BillingPayments;
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Seed Strategy Runbook
|
||||
|
||||
This document describes the GroomBook seeding system across environments.
|
||||
|
||||
## Environment Profiles
|
||||
|
||||
| Profile | Staff | Clients | Invoices | Appointment Window | Auth |
|
||||
|---------|-------|---------|----------|-------------------|------|
|
||||
| `dev` | 4 (1 manager, 1 receptionist, 2 groomers) | ~100 | ~1,000 | 7 days back / 30 days forward | Disabled |
|
||||
| `uat` | 8 (1 manager, 1 receptionist, 3 groomers, 3 bathers) | ~500 | ~4,000 | 30 days back / 90 days forward | Enabled |
|
||||
| `demo` | 8 (1 manager, 1 receptionist, 3 groomers, 3 bathers) | ~500 | ~4,000 | 30 days back / 90 days forward | Enabled, OOBE enabled |
|
||||
|
||||
## Seed Script Environment Variables
|
||||
|
||||
| Variable | Values | Effect |
|
||||
|----------|--------|--------|
|
||||
| `SEED_PROFILE` | `dev`, `uat`, `demo` | Selects data volume profile (see above). Defaults to `uat` if unset. |
|
||||
| `SEED_KNOWN_USERS_ONLY` | `true` | Minimal prod/demo seed with demo users only. Overrides `SEED_PROFILE`. |
|
||||
| `SEED_ADMIN_EMAIL` | email address | Creates an admin staff account with the given email. |
|
||||
| `SEED_ADMIN_NAME` | name | Display name for admin account. Defaults to "Admin". |
|
||||
|
||||
## Re-seeding Environments
|
||||
|
||||
### Dev
|
||||
|
||||
```bash
|
||||
# Run seed job manually
|
||||
kubectl -n groombook-dev exec -it deploy/groombook-api -- \
|
||||
sh -c 'DATABASE_URL=$DATABASE_URL SEED_PROFILE=dev npm run db:seed'
|
||||
```
|
||||
|
||||
Dev uses `AUTH_DISABLED=true` and accepts the `X-Dev-User-Id` header for staff impersonation.
|
||||
|
||||
### UAT
|
||||
|
||||
```bash
|
||||
# Run seed job manually
|
||||
kubectl -n groombook-uat exec -it deploy/groombook-api -- \
|
||||
sh -c 'DATABASE_URL=$DATABASE_URL SEED_PROFILE=uat npm run db:seed'
|
||||
```
|
||||
|
||||
UAT uses Authentik OIDC. See Authentik UAT Personas below.
|
||||
|
||||
### Demo (Production-like)
|
||||
|
||||
Demo uses the same data volume as UAT but with `SEED_KNOWN_USERS_ONLY=true` or is provisioned via the standard seed with OOBE enabled.
|
||||
|
||||
```bash
|
||||
# Trigger seed CronJob
|
||||
kubectl -n groombook cronjob trigger seed-job --latest
|
||||
```
|
||||
|
||||
## Authentik UAT User Personas
|
||||
|
||||
Credentials are stored in sealed secrets — never use plaintext values.
|
||||
|
||||
| Persona | Email | Role | Access Level |
|
||||
|---------|-------|------|--------------|
|
||||
| UAT Super User | `uat-super@groombook.dev` | Super User | Full admin access |
|
||||
| UAT Staff | `uat-staff@groombook.dev` | Staff | Standard staff operations |
|
||||
| UAT Customer | `uat-customer@groombook.dev` | Customer | Customer portal access |
|
||||
|
||||
Sealed secret: `authentik-credentials` in `groombook-uat` namespace.
|
||||
|
||||
## OOBE (Out-of-Box Experience) Flag
|
||||
|
||||
The OOBE flag controls first-run setup flow in Demo/Production environments.
|
||||
|
||||
- **Demo/Production**: OOBE is enabled, users see setup wizard on first login
|
||||
- **Dev/UAT**: OOBE is disabled, full access granted immediately
|
||||
|
||||
When `SEED_KNOWN_USERS_ONLY=true`, the demo users are created but OOBE state must be initialized separately.
|
||||
|
||||
## Dev-Mode Access
|
||||
|
||||
Dev environment disables authentication for local development convenience.
|
||||
|
||||
```bash
|
||||
AUTH_DISABLED=true
|
||||
```
|
||||
|
||||
To impersonate a specific staff user, use the `X-Dev-User-Id` header:
|
||||
|
||||
```bash
|
||||
curl -H "X-Dev-User-Id: <staff-id>" http://localhost:3000/api/...
|
||||
```
|
||||
|
||||
## Seed Idempotency
|
||||
|
||||
The seed script is idempotent and deterministic:
|
||||
- Same `SEED_PROFILE` produces identical data with same IDs
|
||||
- Re-running seed updates existing records rather than creating duplicates
|
||||
- Appointments, invoices, and visit logs are truncated before each seed to ensure clean state
|
||||
@@ -1,152 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import requests
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
api_key = os.environ.get("MINIMAX_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("MINIMAX_API_KEY environment variable not set")
|
||||
|
||||
url = "https://api.minimax.io/v1/image_generation"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
os.makedirs("minimax-output", exist_ok=True)
|
||||
|
||||
# Comprehensive list of dog breeds and variations for diverse demo data
|
||||
dog_prompts = [
|
||||
# Large breeds
|
||||
("german-shepherd-alert", "German Shepherd dog with alert expression, standing confidently, professional pet photography, studio lighting, photorealistic"),
|
||||
("golden-retriever-happy", "Golden Retriever with joyful expression, sitting, golden coat, natural daylight, professional pet photography, photorealistic"),
|
||||
("labrador-running", "Black Labrador Retriever running towards camera, outdoor park setting, dynamic pose, professional pet photography, photorealistic"),
|
||||
("german-shepherd-sitting", "German Shepherd sitting in front of studio backdrop, professional portrait, studio lighting, photorealistic"),
|
||||
("golden-retriever-lying", "Golden Retriever lying down on grass, peaceful expression, outdoor natural lighting, professional pet photography, photorealistic"),
|
||||
|
||||
# Medium breeds
|
||||
("beagle-curious", "Beagle with curious expression, sitting, outdoor garden setting, professional pet photography, photorealistic"),
|
||||
("cocker-spaniel-groomed", "Cocker Spaniel freshly groomed with fluffy coat, happy expression, professional grooming studio, photorealistic"),
|
||||
("english-springer-spaniel", "English Springer Spaniel in natural outdoor setting, alert pose, professional pet photography, photorealistic"),
|
||||
("boxer-playful", "Boxer dog with playful expression, standing, muscular build, professional studio lighting, photorealistic"),
|
||||
("bulldog-gentle", "English Bulldog with gentle expression, sitting, studio backdrop, professional pet photography, photorealistic"),
|
||||
|
||||
# Small breeds
|
||||
("maltese-fluffy", "Maltese dog with white fluffy coat, sitting, groomed appearance, professional pet photography, studio lighting, photorealistic"),
|
||||
("shih-tzu-groomed", "Shih Tzu with long groomed coat, sitting pretty, professional grooming studio, photorealistic"),
|
||||
("pomeranian-alert", "Pomeranian with alert expression, standing, fluffy coat, professional pet photography, photorealistic"),
|
||||
("yorkshire-terrier", "Yorkshire Terrier with silky coat, sitting, professional grooming environment, photorealistic"),
|
||||
("pug-curious", "Pug with curious expression, sitting, studio lighting, professional pet photography, photorealistic"),
|
||||
|
||||
# Specialty breeds
|
||||
("poodle-standard-groomed", "Standard Poodle with professionally groomed coat, standing in show stance, professional grooming studio, photorealistic"),
|
||||
("dachshund-long", "Long-haired Dachshund, lying down, relaxed pose, professional pet photography, photorealistic"),
|
||||
("corgi-happy", "Welsh Corgi with happy expression, standing, professional outdoor setting, photorealistic"),
|
||||
("husky-alert", "Siberian Husky with alert expression, sitting, professional pet photography, studio lighting, photorealistic"),
|
||||
("german-shepherd-lying", "German Shepherd lying down in relaxed pose, indoor setting, professional pet photography, photorealistic"),
|
||||
|
||||
# Mixed/rescue variations
|
||||
("mixed-breed-brown", "Brown and white mixed breed dog, friendly expression, sitting, professional pet photography, photorealistic"),
|
||||
("mixed-breed-black", "Black mixed breed dog with gentle eyes, standing, outdoor natural lighting, photorealistic"),
|
||||
("mixed-breed-spotted", "Spotted mixed breed dog, playful pose, outdoor park setting, professional pet photography, photorealistic"),
|
||||
("terrier-mix-sitting", "Terrier mix dog, alert expression, sitting, professional studio backdrop, photorealistic"),
|
||||
("spaniel-mix-outdoor", "Spaniel mix dog in outdoor garden, relaxed pose, natural daylight, professional pet photography, photorealistic"),
|
||||
|
||||
# Additional variations
|
||||
("labrador-golden", "Golden Labrador Retriever, calm expression, standing in professional pose, studio lighting, photorealistic"),
|
||||
("labrador-black-sitting", "Black Labrador Retriever sitting, gentle expression, professional pet photography, photorealistic"),
|
||||
("rottweiler-calm", "Rottweiler with calm expression, sitting, professional studio, photorealistic"),
|
||||
("doberman-alert", "Doberman Pinscher with alert expression, standing, professional pet photography, photorealistic"),
|
||||
("german-shepherd-side", "German Shepherd in side profile, standing, professional outdoor setting, photorealistic"),
|
||||
]
|
||||
|
||||
print(f"Generating {len(dog_prompts)} unique dog images...")
|
||||
print(f"Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("")
|
||||
|
||||
generated = 0
|
||||
failed = 0
|
||||
|
||||
for i, (filename_base, prompt) in enumerate(dog_prompts, 1):
|
||||
filename = f"dog-{filename_base}.png"
|
||||
filepath = f"minimax-output/{filename}"
|
||||
|
||||
# Check if already exists
|
||||
if os.path.exists(filepath):
|
||||
size = os.path.getsize(filepath)
|
||||
print(f"[{i:2d}/{len(dog_prompts)}] ✓ {filename} (already exists, {size} bytes)")
|
||||
generated += 1
|
||||
continue
|
||||
|
||||
print(f"[{i:2d}/{len(dog_prompts)}] Generating {filename}...", end=" ", flush=True)
|
||||
|
||||
payload = {
|
||||
"model": "image-01",
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": "1:1",
|
||||
"response_format": "base64",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=120)
|
||||
|
||||
# Check for quota errors
|
||||
if response.status_code == 429:
|
||||
print(f"✗ QUOTA EXCEEDED")
|
||||
print(f"\nQuota limit reached after {generated} successful generations")
|
||||
break
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if "data" in data and "image_base64" in data["data"]:
|
||||
images = data["data"]["image_base64"]
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(base64.b64decode(images[0]))
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
print(f"✓ ({file_size} bytes)")
|
||||
generated += 1
|
||||
else:
|
||||
print(f"✗ Unexpected response format")
|
||||
failed += 1
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"✗ Timeout")
|
||||
failed += 1
|
||||
except requests.exceptions.RequestException as e:
|
||||
if "429" in str(e) or "quota" in str(e).lower():
|
||||
print(f"✗ QUOTA EXCEEDED")
|
||||
print(f"\nQuota limit reached after {generated} successful generations")
|
||||
break
|
||||
else:
|
||||
print(f"✗ {type(e).__name__}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"✗ {type(e).__name__}")
|
||||
failed += 1
|
||||
|
||||
time.sleep(0.5) # Small delay between requests
|
||||
|
||||
print("")
|
||||
print(f"End time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"✓ Successfully generated: {generated}")
|
||||
print(f"✗ Failed: {failed}")
|
||||
print(f"\nCopying images to demo-pets directory...")
|
||||
|
||||
# Copy all generated images to demo-pets
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["cp", "-v", "minimax-output/dog-*.png", "apps/web/public/demo-pets/"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Count files in demo-pets
|
||||
import glob
|
||||
demo_pets = glob.glob("apps/web/public/demo-pets/dog-*.png")
|
||||
print(f"✓ Copied to demo-pets. Total dog images: {len(demo_pets)}")
|
||||
else:
|
||||
print(f"Note: Copy result - {result.stderr}")
|
||||
@@ -1,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,6 +0,0 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
|
||||
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");
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE "refunds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
|
||||
"stripe_refund_id" text NOT NULL,
|
||||
"idempotency_key" text UNIQUE,
|
||||
"amount_cents" integer,
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
|
||||
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
|
||||
@@ -1,15 +0,0 @@
|
||||
-- SMS opt-in fields for clients (idempotent)
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
||||
|
||||
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
||||
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
||||
|
||||
-- Drop old unique constraints if they exist (idempotent)
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
||||
|
||||
-- Add new unique constraint with channel
|
||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Migration: 0029_db_indexes_constraints.sql
|
||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
||||
|
||||
-- Backfill NULL emails before setting NOT NULL
|
||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
||||
|
||||
-- Add indexes on appointments table
|
||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||
|
||||
-- Add index on pets table
|
||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
||||
|
||||
-- Add index on clients table
|
||||
CREATE INDEX idx_clients_email ON clients(email);
|
||||
|
||||
-- Set NOT NULL on clients.email (after backfill)
|
||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
||||
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"id": "0026_stripe_payment",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"authProviderConfig": {
|
||||
"name": "auth_provider_config",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"businessSettings": {
|
||||
"name": "business_settings",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
||||
},
|
||||
"invoices": {
|
||||
"name": "invoices",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -176,34 +176,6 @@
|
||||
"when": 1775396067192,
|
||||
"tag": "0024_invoice_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1775482467192,
|
||||
"tag": "0025_rate_limit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1775568867192,
|
||||
"tag": "0026_stripe_payment",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1775655267192,
|
||||
"tag": "0027_refunds",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1775741667192,
|
||||
"tag": "0028_sms_reminders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,11 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
smsOptIn: false,
|
||||
smsConsentDate: null,
|
||||
smsOptOutDate: null,
|
||||
smsConsentText: null,
|
||||
stripeCustomerId: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
|
||||
@@ -102,55 +102,43 @@ export const verification = pgTable("verification", {
|
||||
|
||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const clients = pgTable(
|
||||
"clients",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||
smsConsentDate: timestamp("sms_consent_date"),
|
||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||
smsConsentText: text("sms_consent_text"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_clients_email").on(t.email)]
|
||||
);
|
||||
export const clients = pgTable("clients", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
// Set to true if the client has opted out of email reminders/notifications
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const pets = pgTable(
|
||||
"pets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
||||
);
|
||||
export const pets = pgTable("pets", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const services = pgTable("services", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -263,9 +251,6 @@ export const invoices = pgTable(
|
||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||
paymentMethod: paymentMethodEnum("payment_method"),
|
||||
paidAt: timestamp("paid_at"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
stripeRefundId: text("stripe_refund_id"),
|
||||
paymentFailureReason: text("payment_failure_reason"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
@@ -274,7 +259,6 @@ export const invoices = pgTable(
|
||||
index("idx_invoices_client_id").on(t.clientId),
|
||||
index("idx_invoices_status").on(t.status),
|
||||
index("idx_invoices_created_at").on(t.createdAt),
|
||||
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -312,28 +296,8 @@ export const invoiceTipSplits = pgTable(
|
||||
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
||||
);
|
||||
|
||||
// Refund records with idempotency key support
|
||||
export const refunds = pgTable(
|
||||
"refunds",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
invoiceId: uuid("invoice_id")
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "restrict" }),
|
||||
stripeRefundId: text("stripe_refund_id").notNull(),
|
||||
idempotencyKey: text("idempotency_key").unique(),
|
||||
amountCents: integer("amount_cents"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_refunds_invoice_id").on(t.invoiceId),
|
||||
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
|
||||
]
|
||||
);
|
||||
|
||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||
// reminder_type values: "confirmation", "24h", "2h"
|
||||
// channel values: "email", "sms"
|
||||
export const reminderLogs = pgTable(
|
||||
"reminder_logs",
|
||||
{
|
||||
@@ -343,11 +307,9 @@ export const reminderLogs = pgTable(
|
||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||
// "confirmation" | "24h" | "2h"
|
||||
reminderType: text("reminder_type").notNull(),
|
||||
// "email" | "sms"
|
||||
channel: text("channel").notNull().default("email"),
|
||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||
);
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* Seed script — generates deterministic, PII-free test data for Groom Book.
|
||||
*
|
||||
* Creates:
|
||||
* - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total)
|
||||
* - 10 services
|
||||
* - 500 clients, each with 1-3 dogs
|
||||
* - ~2 500 appointments spread across the past 12 months
|
||||
* - Invoices for completed appointments with line items and tip splits
|
||||
* - Grooming visit logs for completed appointments
|
||||
* Supports three profiles via SEED_PROFILE env var:
|
||||
* - dev: 4 staff, 100 clients, ~1000 invoices, appointments 7d back / 30d forward
|
||||
* - uat: 8 staff, 500 clients, ~4000 invoices, appointments 30d back / 90d forward
|
||||
* - demo: Same data volume as UAT (for production-like demo environments)
|
||||
*
|
||||
* Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility.
|
||||
*
|
||||
* SEED_KNOWN_USERS_ONLY=true: Minimal prod/demo seed with demo users only.
|
||||
*
|
||||
* Output is fully deterministic: the same seed value always produces the
|
||||
* same rows with the same IDs.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
|
||||
* DATABASE_URL=postgres://... SEED_PROFILE=dev npx tsx packages/db/src/seed.ts
|
||||
*/
|
||||
|
||||
import postgres from "postgres";
|
||||
@@ -21,54 +22,6 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
type SeedProfile = "dev" | "uat" | "demo";
|
||||
|
||||
interface ProfileConfig {
|
||||
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
||||
clientCount: number;
|
||||
appointmentsBackDays: number;
|
||||
appointmentsForwardDays: number;
|
||||
invoiceCount: number;
|
||||
includeUatClients: boolean;
|
||||
}
|
||||
|
||||
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||
dev: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||
clientCount: 100,
|
||||
appointmentsBackDays: 7,
|
||||
appointmentsForwardDays: 30,
|
||||
invoiceCount: 1000,
|
||||
includeUatClients: false,
|
||||
},
|
||||
uat: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clientCount: 500,
|
||||
appointmentsBackDays: 30,
|
||||
appointmentsForwardDays: 90,
|
||||
invoiceCount: 4000,
|
||||
includeUatClients: true,
|
||||
},
|
||||
demo: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clientCount: 500,
|
||||
appointmentsBackDays: 30,
|
||||
appointmentsForwardDays: 90,
|
||||
invoiceCount: 4000,
|
||||
includeUatClients: true,
|
||||
},
|
||||
};
|
||||
|
||||
function getProfile(): SeedProfile {
|
||||
const raw = process.env.SEED_PROFILE?.toLowerCase();
|
||||
if (raw === "dev" || raw === "uat" || raw === "demo") {
|
||||
return raw;
|
||||
}
|
||||
return "uat";
|
||||
}
|
||||
|
||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -87,6 +40,50 @@ function createPrng(seed: number): () => number {
|
||||
|
||||
const rand = createPrng(42);
|
||||
|
||||
// ── Seed profile configuration ───────────────────────────────────────────────
|
||||
|
||||
type SeedProfile = "dev" | "uat" | "demo";
|
||||
|
||||
interface ProfileConfig {
|
||||
staff: {
|
||||
manager: number;
|
||||
receptionist: number;
|
||||
groomer: number;
|
||||
bather: number;
|
||||
};
|
||||
clients: number;
|
||||
appointments: {
|
||||
daysBack: number;
|
||||
daysForward: number;
|
||||
};
|
||||
targetInvoices: number;
|
||||
}
|
||||
|
||||
function getProfileConfig(profile: SeedProfile | undefined): ProfileConfig {
|
||||
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||
dev: {
|
||||
staff: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||
clients: 100,
|
||||
appointments: { daysBack: 7, daysForward: 30 },
|
||||
targetInvoices: 1000,
|
||||
},
|
||||
uat: {
|
||||
staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clients: 500,
|
||||
appointments: { daysBack: 30, daysForward: 90 },
|
||||
targetInvoices: 4000,
|
||||
},
|
||||
demo: {
|
||||
staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 },
|
||||
clients: 500,
|
||||
appointments: { daysBack: 30, daysForward: 90 },
|
||||
targetInvoices: 4000,
|
||||
},
|
||||
};
|
||||
if (!profile || profile === "uat") return profiles.uat;
|
||||
return profiles[profile] ?? profiles.uat;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return a random element from an array using the seeded PRNG. */
|
||||
@@ -184,7 +181,7 @@ const dogBreeds = [
|
||||
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
|
||||
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
|
||||
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd",
|
||||
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle",
|
||||
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner",
|
||||
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
||||
];
|
||||
|
||||
@@ -281,44 +278,6 @@ const productsUsed = [
|
||||
"Coconut oil shampoo, leave-in conditioner, cologne",
|
||||
];
|
||||
|
||||
const demoPetImages = [
|
||||
"/demo-pets/dog-golden-after.png",
|
||||
"/demo-pets/dog-poodle-groomed.png",
|
||||
"/demo-pets/dog-black-lab.png",
|
||||
"/demo-pets/dog-shih-tzu.png",
|
||||
"/demo-pets/dog-cocker-spaniel.png",
|
||||
"/demo-pets/dog-schnauzer.png",
|
||||
"/demo-pets/dog-maltese.png",
|
||||
"/demo-pets/dog-dachshund.png",
|
||||
"/demo-pets/dog-pomeranian.png",
|
||||
"/demo-pets/dog-bichon-frise.png",
|
||||
"/demo-pets/dog-golden-retriever.png",
|
||||
"/demo-pets/dog-labrador.png",
|
||||
"/demo-pets/dog-mixed-breed.png",
|
||||
"/demo-pets/dog-poodle.png",
|
||||
"/demo-pets/dog-terrier.png",
|
||||
"/demo-pets/dog-afghan-hound.png",
|
||||
"/demo-pets/dog-basset-brown-white.png",
|
||||
"/demo-pets/dog-bichon-white-groomed.png",
|
||||
"/demo-pets/dog-boxer-fawn-athletic.png",
|
||||
"/demo-pets/dog-cavalier-cream-gentle.png",
|
||||
"/demo-pets/dog-cocker-buff-friendly.png",
|
||||
"/demo-pets/dog-corgi.png",
|
||||
"/demo-pets/dog-dachshund-black-tan.png",
|
||||
"/demo-pets/dog-golden-before.png",
|
||||
"/demo-pets/dog-pomeranian-white-studio.png",
|
||||
"/demo-pets/dog-schnauzer-black-groomed.png",
|
||||
"/demo-pets/dog-setter-red-sunlit.png",
|
||||
"/demo-pets/dog-sheepdog-merle-running.png",
|
||||
];
|
||||
|
||||
const puggleImages = [
|
||||
"/demo-pets/dog-puggle-fawn-playful.png",
|
||||
"/demo-pets/dog-puggle-black-sitting.png",
|
||||
"/demo-pets/dog-puggle-cream-groomed.png",
|
||||
"/demo-pets/dog-puggle-fawn-grooming.png",
|
||||
];
|
||||
|
||||
// ── Service definitions ──────────────────────────────────────────────────────
|
||||
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
|
||||
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
|
||||
@@ -398,8 +357,6 @@ async function seedKnownUsers() {
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
userId: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -426,7 +383,6 @@ async function seedKnownUsers() {
|
||||
name: "UAT Super User",
|
||||
email: "uat-super@groombook.dev",
|
||||
oidcSub: uatSuperOidcSub,
|
||||
userId: uatSuperOidcSub,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -453,7 +409,6 @@ async function seedKnownUsers() {
|
||||
name: "UAT Staff Groomer",
|
||||
email: "uat-groomer@groombook.dev",
|
||||
oidcSub: uatStaffOidcSub,
|
||||
userId: uatStaffOidcSub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
@@ -462,37 +417,6 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
const [existingGroomer] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingGroomer) {
|
||||
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||
@@ -588,32 +512,61 @@ async function seed() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Lean prod/demo seed — known users only, no large dataset
|
||||
if (process.env.SEED_KNOWN_USERS_ONLY === "true") {
|
||||
await seedKnownUsers();
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getProfile();
|
||||
const cfg = profiles[profile];
|
||||
const rawProfile = process.env.SEED_PROFILE?.toLowerCase();
|
||||
const profile: SeedProfile | undefined = (rawProfile === "dev" || rawProfile === "uat" || rawProfile === "demo")
|
||||
? rawProfile
|
||||
: undefined;
|
||||
const config = getProfileConfig(profile);
|
||||
|
||||
const client = postgres(url, { max: 5 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
|
||||
const profileLabel = profile ? ` (${profile})` : "";
|
||||
console.log(`Seeding Groom Book database${profileLabel}...\n`);
|
||||
|
||||
// ── Staff ──
|
||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
||||
);
|
||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||
);
|
||||
const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) =>
|
||||
({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
|
||||
);
|
||||
const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) =>
|
||||
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
|
||||
// Deterministic staff IDs so they can be referenced in scripts/tests
|
||||
const staffNames = [
|
||||
{ name: "Jordan Lee", email: "jordan@groombook.dev" },
|
||||
{ name: "Sam Rivera", email: "sam@groombook.dev" },
|
||||
{ name: "Sarah Mitchell", email: "sarah@groombook.dev" },
|
||||
{ name: "James Park", email: "james@groombook.dev" },
|
||||
{ name: "Maria Gonzalez", email: "maria@groombook.dev" },
|
||||
{ name: "Tyler Johnson", email: "tyler@groombook.dev" },
|
||||
{ name: "Ashley Chen", email: "ashley@groombook.dev" },
|
||||
{ name: "Devon Williams", email: "devon@groombook.dev" },
|
||||
];
|
||||
|
||||
const managerStaff = staffNames.slice(0, config.staff.manager).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "manager" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
const receptionistStaff = staffNames.slice(config.staff.manager, config.staff.manager + config.staff.receptionist).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "receptionist" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
const groomers = staffNames.slice(config.staff.manager + config.staff.receptionist, config.staff.manager + config.staff.receptionist + config.staff.groomer).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
|
||||
const bathers = staffNames.slice(config.staff.manager + config.staff.receptionist + config.staff.groomer, config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather).map(
|
||||
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }),
|
||||
);
|
||||
|
||||
const totalStaff = config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather;
|
||||
console.log(`✓ Creating ${totalStaff} staff (${config.staff.manager} manager, ${config.staff.receptionist} receptionist, ${config.staff.groomer} groomers, ${config.staff.bather} bathers)`);
|
||||
|
||||
// Truncate downstream tables before staff upsert — clears stale impersonation
|
||||
// sessions from prior seed runs so the FK constraint on staff_id is never
|
||||
// violated when ON CONFLICT DO UPDATE touches staff rows that still have
|
||||
// impersonation_sessions references.
|
||||
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
|
||||
|
||||
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||||
@@ -632,10 +585,6 @@ async function seed() {
|
||||
set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
|
||||
});
|
||||
}
|
||||
const staffLabel = cfg.staffCount.bather > 0
|
||||
? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)`
|
||||
: `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`;
|
||||
console.log(`✓ Created ${staffLabel}`);
|
||||
|
||||
// ── SEED_ADMIN_EMAIL admin ──
|
||||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||||
@@ -647,8 +596,6 @@ async function seed() {
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
userId: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -660,31 +607,6 @@ async function seed() {
|
||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
|
||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
await db.insert(schema.staff)
|
||||
.values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.staff.email,
|
||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
||||
});
|
||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
||||
}
|
||||
|
||||
// ── Services ──
|
||||
// Upsert services using name as unique key. With deterministic IDs in
|
||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||
@@ -710,10 +632,10 @@ async function seed() {
|
||||
|
||||
// ── Clients & Pets ──
|
||||
const now = new Date();
|
||||
const appointmentsBackDate = new Date(now);
|
||||
appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays);
|
||||
const appointmentsForwardDate = new Date(now);
|
||||
appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays);
|
||||
const appointmentsBack = new Date(now);
|
||||
appointmentsBack.setDate(appointmentsBack.getDate() - config.appointments.daysBack);
|
||||
const appointmentsForward = new Date(now);
|
||||
appointmentsForward.setDate(appointmentsForward.getDate() + config.appointments.daysForward);
|
||||
|
||||
interface ClientRecord { id: string; name: string }
|
||||
interface PetRecord { id: string; clientId: string }
|
||||
@@ -721,9 +643,9 @@ async function seed() {
|
||||
const clientRecords: ClientRecord[] = [];
|
||||
const petRecords: PetRecord[] = [];
|
||||
|
||||
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
|
||||
// Batch insert clients and pets
|
||||
const clientBatchSize = 50;
|
||||
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
|
||||
for (let batch = 0; batch < Math.ceil(config.clients / clientBatchSize); batch++) {
|
||||
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
|
||||
const petBatch: (typeof schema.pets.$inferInsert)[] = [];
|
||||
|
||||
@@ -753,7 +675,7 @@ async function seed() {
|
||||
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
||||
for (let p = 0; p < petCount; p++) {
|
||||
const petId = uuid();
|
||||
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds);
|
||||
const breed = pick(dogBreeds);
|
||||
const dob = new Date(now);
|
||||
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
|
||||
dob.setMonth(randInt(0, 11));
|
||||
@@ -772,11 +694,9 @@ async function seed() {
|
||||
shampooPreference: pick(shampoos),
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
});
|
||||
|
||||
petRecords.push({ id: petId, clientId });
|
||||
petIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,29 +727,27 @@ async function seed() {
|
||||
shampooPreference: pet.shampooPreference,
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`);
|
||||
console.log(`✓ Created ${config.clients} clients with ${petRecords.length} pets`);
|
||||
|
||||
// ── UAT test clients (guaranteed pending invoices) ─────────────────────────────
|
||||
// These 5 clients are deterministic and documented in Shedward AGENTS.md so
|
||||
// UAT can reliably find billing test data without searching.
|
||||
if (cfg.includeUatClients) {
|
||||
interface UatClient {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
interface UatClient {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
|
||||
@@ -842,20 +760,18 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") } });
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
const svc = servicesDef[svcIdx]!;
|
||||
const completedTime = randDate(appointmentsBackDate, now);
|
||||
const completedTime = randDate(appointmentsBack, now);
|
||||
completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000);
|
||||
const uatGroomer = groomers[0]!;
|
||||
const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer;
|
||||
await db.insert(schema.appointments).values({
|
||||
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id,
|
||||
batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
|
||||
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: groomers[0]!.id,
|
||||
batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
|
||||
});
|
||||
// Create a PENDING invoice for that appointment
|
||||
const invoiceId = uuid();
|
||||
@@ -873,12 +789,17 @@ async function seed() {
|
||||
id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id,
|
||||
cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime,
|
||||
});
|
||||
}
|
||||
console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`);
|
||||
}
|
||||
console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`);
|
||||
|
||||
// ── Appointments, Invoices, Visit Logs ──
|
||||
// Generate ~5 appointments per client on average = ~2500 total
|
||||
// Calculate visit count to achieve targetInvoices based on ~65% completion rate
|
||||
const completedRatio = 0.65;
|
||||
const totalVisitsNeeded = Math.ceil(config.targetInvoices / completedRatio);
|
||||
const avgVisitsPerClient = Math.ceil(totalVisitsNeeded / clientRecords.length);
|
||||
const visitCountMin = Math.max(1, Math.floor(avgVisitsPerClient * 0.7));
|
||||
const visitCountMax = Math.max(visitCountMin + 1, Math.ceil(avgVisitsPerClient * 1.3));
|
||||
|
||||
const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [
|
||||
"completed", "completed", "completed", "completed", "completed",
|
||||
"completed", "completed", "scheduled", "confirmed", "cancelled", "no_show",
|
||||
@@ -929,8 +850,7 @@ async function seed() {
|
||||
|
||||
for (const client of clientRecords) {
|
||||
const pets = petsByClient.get(client.id) ?? [];
|
||||
// Each client visits ~3-8 times over the year
|
||||
const visitCount = randInt(3, 8);
|
||||
const visitCount = randInt(visitCountMin, visitCountMax);
|
||||
|
||||
for (let v = 0; v < visitCount; v++) {
|
||||
// Pick a random pet for this visit
|
||||
@@ -939,15 +859,15 @@ async function seed() {
|
||||
const serviceId = serviceIds[serviceIdx]!;
|
||||
const svc = servicesDef[serviceIdx]!;
|
||||
const groomer = pick(groomers);
|
||||
const bather = rand() < 0.6 ? pick(bathers) : null;
|
||||
const bather = rand() < 0.6 && bathers.length > 0 ? pick(bathers) : null;
|
||||
const status = pick(statuses);
|
||||
|
||||
// Schedule within the configured appointment window
|
||||
let startTime: Date;
|
||||
if (status === "scheduled" || status === "confirmed") {
|
||||
startTime = randDate(now, appointmentsForwardDate);
|
||||
startTime = randDate(now, appointmentsForward);
|
||||
} else {
|
||||
startTime = randDate(appointmentsBackDate, now);
|
||||
startTime = randDate(appointmentsBack, now);
|
||||
}
|
||||
// Snap to business hours (8am - 5pm)
|
||||
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
@@ -1051,93 +971,6 @@ async function seed() {
|
||||
|
||||
console.log(`✓ Created ${appointmentCount} appointments`);
|
||||
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`);
|
||||
|
||||
// ── Enforce target invoice count ───────────────────────────────────────────
|
||||
// If current invoice count is below target (due to profile having fewer
|
||||
// clients/appointments than the target ratio), generate supplemental
|
||||
// completed appointments for existing clients to fill the gap.
|
||||
if (invoiceCount < cfg.invoiceCount) {
|
||||
const additionalNeeded = cfg.invoiceCount - invoiceCount;
|
||||
console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`);
|
||||
|
||||
const existingClientIds = clientRecords.map(c => c.id);
|
||||
const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20);
|
||||
let supplementalCount = 0;
|
||||
let supplementalInvoices = 0;
|
||||
|
||||
for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) {
|
||||
const clientId = pick(existingClientIds);
|
||||
const pets = petsByClient.get(clientId) ?? [];
|
||||
if (pets.length === 0) continue;
|
||||
|
||||
const petId = pick(pets);
|
||||
const serviceIdx = randInt(0, serviceIds.length - 1);
|
||||
const serviceId = serviceIds[serviceIdx]!;
|
||||
const svc = servicesDef[serviceIdx]!;
|
||||
const groomer = pick(groomers);
|
||||
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
|
||||
|
||||
let startTime = randDate(appointmentsBackDate, now);
|
||||
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
||||
const effectivePrice = svc.price;
|
||||
|
||||
const apptId = uuid();
|
||||
apptBatch.push({
|
||||
id: apptId, clientId, petId, serviceId,
|
||||
staffId: groomer.id, batherStaffId: bather?.id ?? null,
|
||||
status: "completed", startTime, endTime, notes: null, priceCents: null,
|
||||
});
|
||||
appointmentCount++;
|
||||
supplementalCount++;
|
||||
|
||||
const invoiceId = uuid();
|
||||
const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0;
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId, appointmentId: apptId, clientId,
|
||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||
status: "paid" as const,
|
||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||
paidAt, notes: null,
|
||||
});
|
||||
lineItemBatch.push({
|
||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||
unitPriceCents: effectivePrice, totalCents: effectivePrice,
|
||||
});
|
||||
if (tipCents > 0) {
|
||||
if (bather) {
|
||||
const groomerShare = Math.round(tipCents * 0.6);
|
||||
const batherShare = tipCents - groomerShare;
|
||||
tipSplitBatch.push(
|
||||
{ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare },
|
||||
{ id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare },
|
||||
);
|
||||
} else {
|
||||
tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents });
|
||||
}
|
||||
}
|
||||
visitLogBatch.push({
|
||||
id: uuid(), petId, appointmentId: apptId, staffId: groomer.id,
|
||||
cutStyle: pick(cutStyles), productsUsed: pick(productsUsed),
|
||||
notes: pick(visitLogNotes), groomedAt: endTime,
|
||||
});
|
||||
invoiceCount++;
|
||||
supplementalInvoices++;
|
||||
visitLogCount++;
|
||||
|
||||
if (apptBatch.length >= apptBatchSize) {
|
||||
await flushBatches();
|
||||
}
|
||||
}
|
||||
|
||||
await flushBatches();
|
||||
console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`);
|
||||
console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`);
|
||||
}
|
||||
console.log(`✓ Created ${visitLogCount} grooming visit logs`);
|
||||
console.log("\nSeed complete!");
|
||||
|
||||
|
||||
@@ -40,12 +40,6 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^6.9.16
|
||||
version: 6.10.1
|
||||
stripe:
|
||||
specifier: ^22.0.0
|
||||
version: 22.0.1(@types/node@22.19.15)
|
||||
telnyx:
|
||||
specifier: ^1.23.0
|
||||
version: 1.27.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -89,17 +83,11 @@ importers:
|
||||
'@groombook/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@stripe/react-stripe-js':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@stripe/stripe-js':
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.0
|
||||
'@tailwindcss/vite':
|
||||
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
|
||||
@@ -180,7 +168,7 @@ importers:
|
||||
version: 22.19.15
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.4
|
||||
version: 0.30.4
|
||||
version: 0.30.6
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
@@ -1699,6 +1687,9 @@ packages:
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@petamoriken/float16@3.9.3':
|
||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2118,17 +2109,6 @@ packages:
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@stripe/react-stripe-js@6.1.0':
|
||||
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
|
||||
peerDependencies:
|
||||
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
|
||||
react: '>=16.8.0 <20.0.0'
|
||||
react-dom: '>=16.8.0 <20.0.0'
|
||||
|
||||
'@stripe/stripe-js@9.1.0':
|
||||
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
|
||||
engines: {node: '>=12.16'}
|
||||
|
||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||
|
||||
@@ -2830,8 +2810,8 @@ packages:
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
drizzle-kit@0.30.4:
|
||||
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
|
||||
drizzle-kit@0.30.6:
|
||||
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||
hasBin: true
|
||||
|
||||
drizzle-orm@0.38.4:
|
||||
@@ -2955,6 +2935,10 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3158,6 +3142,11 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
gel@2.2.0:
|
||||
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
generator-function@2.0.1:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3425,6 +3414,10 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isexe@3.1.5:
|
||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3606,9 +3599,6 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3618,10 +3608,6 @@ packages:
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -3713,10 +3699,6 @@ packages:
|
||||
nwsapi@2.2.23:
|
||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3834,17 +3816,10 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.1:
|
||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -3853,9 +3828,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
@@ -4040,6 +4012,10 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.8.3:
|
||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4148,15 +4124,6 @@ packages:
|
||||
strip-literal@3.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||
|
||||
@@ -4178,10 +4145,6 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
telnyx@1.27.0:
|
||||
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
|
||||
engines: {node: ^6 || >=8}
|
||||
|
||||
temp-dir@2.0.0:
|
||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4256,9 +4219,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4348,10 +4308,6 @@ packages:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
@@ -4488,6 +4444,11 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
which@4.0.0:
|
||||
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
||||
engines: {node: ^16.13.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6219,6 +6180,8 @@ snapshots:
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@petamoriken/float16@3.9.3': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -6708,15 +6671,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@stripe/stripe-js': 9.1.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@stripe/stripe-js@9.1.0': {}
|
||||
|
||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||
dependencies:
|
||||
ejs: 3.1.10
|
||||
@@ -7414,12 +7368,13 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
drizzle-kit@0.30.4:
|
||||
drizzle-kit@0.30.6:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
'@esbuild-kit/esm-loader': 2.6.5
|
||||
esbuild: 0.19.12
|
||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||
gel: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7456,6 +7411,8 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
@@ -7817,6 +7774,17 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
gel@2.2.0:
|
||||
dependencies:
|
||||
'@petamoriken/float16': 3.9.3
|
||||
debug: 4.4.3
|
||||
env-paths: 3.0.0
|
||||
semver: 7.7.4
|
||||
shell-quote: 1.8.3
|
||||
which: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
@@ -8081,6 +8049,8 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.5: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
@@ -8249,18 +8219,12 @@ snapshots:
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.sortby@4.7.0: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
@@ -8335,8 +8299,6 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
@@ -8441,18 +8403,8 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.1:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -8462,8 +8414,6 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||
@@ -8687,6 +8637,8 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.3: {}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -8822,10 +8774,6 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
stripe@22.0.1(@types/node@22.19.15):
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
strnum@2.2.1: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
@@ -8840,14 +8788,6 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
telnyx@1.27.0:
|
||||
dependencies:
|
||||
lodash.isplainobject: 4.0.6
|
||||
qs: 6.15.1
|
||||
safe-buffer: 5.2.1
|
||||
tweetnacl: 1.0.3
|
||||
uuid: 9.0.1
|
||||
|
||||
temp-dir@2.0.0: {}
|
||||
|
||||
tempy@0.6.0:
|
||||
@@ -8918,8 +8858,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -9016,8 +8954,6 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
@@ -9195,6 +9131,10 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
which@4.0.0:
|
||||
dependencies:
|
||||
isexe: 3.1.5
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
|
||||