chore(uat): promote dev → uat (GRO-628 + batched) #317
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Contributing to GroomBook
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
GroomBook uses a three-branch GitOps model:
|
||||
|
||||
| Branch | Environment | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
|
||||
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
|
||||
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
|
||||
|
||||
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
|
||||
|
||||
## Developer Workflow
|
||||
|
||||
1. **Branch from `dev`** — create a feature or fix branch:
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git checkout -b feat/my-feature
|
||||
```
|
||||
|
||||
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
|
||||
```bash
|
||||
gh pr create --base dev --title "feat: description (GRO-NNN)" \
|
||||
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
|
||||
```
|
||||
|
||||
3. **Pipeline gates before merge to `dev`:**
|
||||
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
|
||||
- CTO (The Dogfather) reviews second — architecture and final approval
|
||||
- Both must approve; 2 approving reviews required by branch protection
|
||||
|
||||
## Promotion Flow
|
||||
|
||||
### Dev → UAT
|
||||
|
||||
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
|
||||
|
||||
```bash
|
||||
gh pr create --base uat --head dev \
|
||||
--title "chore: promote dev to uat (YYYY.MM.DD)" \
|
||||
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
|
||||
```
|
||||
|
||||
Gates:
|
||||
- Shedward Scissorhands runs regression/acceptance tests
|
||||
- Barkley Trimsworth performs security review
|
||||
- CTO approves and merges (1 approving review required)
|
||||
|
||||
### UAT → Main (Production)
|
||||
|
||||
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --head uat \
|
||||
--title "chore: promote uat to main (YYYY.MM.DD)" \
|
||||
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
|
||||
```
|
||||
|
||||
Gates:
|
||||
- CEO (Scrubs McBarkley) reviews for business alignment and merges
|
||||
- 1 approving review required; triggers auto-deploy to Production
|
||||
|
||||
## Branch Protection Summary
|
||||
|
||||
| Branch | Required Approvals | Who approves |
|
||||
|--------|--------------------|-------------|
|
||||
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
|
||||
| `uat` | 1 | CTO (The Dogfather) |
|
||||
| `main` | 1 | CEO (Scrubs McBarkley) |
|
||||
|
||||
Force-pushes and branch deletions are disabled on all three branches.
|
||||
|
||||
## Commit Style
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
- `feat:` — new feature
|
||||
- `fix:` — bug fix
|
||||
- `chore:` — maintenance (dependency updates, build config, promotions)
|
||||
- `docs:` — documentation only
|
||||
- `ci:` — CI/CD changes
|
||||
- `refactor:` — code restructure without behaviour change
|
||||
|
||||
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open a Paperclip issue in the GRO project or ask in the team channel.
|
||||
+62
-2
@@ -72,6 +72,60 @@ app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
// Magic bytes for allowed image types
|
||||
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
|
||||
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
|
||||
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
|
||||
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the given base64 content matches the declared MIME type
|
||||
* by checking magic bytes. Returns null if valid, or the field to clear if not.
|
||||
*/
|
||||
function validateLogoMagicBytes(
|
||||
logoBase64: string | null,
|
||||
logoMimeType: string | null
|
||||
): "logoBase64" | "logoMimeType" | null {
|
||||
if (!logoBase64 || !logoMimeType) return null;
|
||||
|
||||
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
|
||||
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
|
||||
|
||||
try {
|
||||
const binary = Buffer.from(logoBase64, "base64");
|
||||
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
|
||||
if (logoMimeType === "image/webp") {
|
||||
if (binary.length < 12) return "logoBase64";
|
||||
const webpMagic = binary.slice(0, 4);
|
||||
const webpSig = binary.slice(8, 12);
|
||||
if (
|
||||
webpMagic[0] !== 0x52 ||
|
||||
webpMagic[1] !== 0x49 ||
|
||||
webpMagic[2] !== 0x46 ||
|
||||
webpMagic[3] !== 0x46 ||
|
||||
webpSig[0] !== 0x57 ||
|
||||
webpSig[1] !== 0x45 ||
|
||||
webpSig[2] !== 0x42 ||
|
||||
webpSig[3] !== 0x50
|
||||
) {
|
||||
return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// All other types: check prefix
|
||||
if (binary.length < expectedMagic.length) return "logoBase64";
|
||||
for (let i = 0; i < expectedMagic.length; i++) {
|
||||
if (binary[i] !== expectedMagic[i]) return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "logoBase64";
|
||||
}
|
||||
}
|
||||
|
||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||
app.get("/api/branding", async (c) => {
|
||||
const db = getDb();
|
||||
@@ -87,13 +141,19 @@ app.get("/api/branding", async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||
// via the legacy base64 logo fields
|
||||
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||
|
||||
return c.json({
|
||||
businessName: settings.businessName,
|
||||
primaryColor: settings.primaryColor,
|
||||
accentColor: settings.accentColor,
|
||||
logoUrl,
|
||||
logoBase64: settings.logoBase64,
|
||||
logoMimeType: settings.logoMimeType,
|
||||
logoBase64: safeLogoBase64,
|
||||
logoMimeType: safeLogoMimeType,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
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();
|
||||
};
|
||||
+23
-122
@@ -1,33 +1,22 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
|
||||
export const portalRouter = new Hono<AppEnv>();
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
@@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => {
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
@@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => {
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
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 })));
|
||||
@@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => {
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
@@ -148,12 +131,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -196,12 +174,7 @@ portalRouter.patch(
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -319,28 +287,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
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 clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
@@ -364,26 +311,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
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 clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -392,7 +320,7 @@ portalRouter.patch(
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== session.clientId) {
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -414,26 +342,7 @@ portalRouter.patch(
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
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 clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
@@ -442,7 +351,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== session.clientId) {
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -475,9 +384,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
@@ -514,9 +421,7 @@ portalRouter.post(
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
@@ -524,9 +429,7 @@ portalRouter.get("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
@@ -539,9 +442,7 @@ portalRouter.post("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
inArray,
|
||||
lt,
|
||||
appointments,
|
||||
clients,
|
||||
@@ -59,68 +60,77 @@ export async function runReminderCheck(): Promise<void> {
|
||||
)
|
||||
);
|
||||
|
||||
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||
if (appointmentIds.length === 0) continue;
|
||||
|
||||
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||
const sentRows = await db
|
||||
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
appointmentIds.length === 1
|
||||
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||
)
|
||||
);
|
||||
|
||||
const sentEmail = new Set(
|
||||
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||
);
|
||||
const sentSms = new Set(
|
||||
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||
);
|
||||
|
||||
// Bulk JOIN: fetch all client/pet/service/staff data in one query
|
||||
const joinedRows = await db
|
||||
.select({
|
||||
appointmentId: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
clientSmsOptIn: clients.smsOptIn,
|
||||
clientPhone: clients.phone,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
);
|
||||
|
||||
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||
for (const row of joinedRows) {
|
||||
appointmentMap.set(row.appointmentId, row);
|
||||
}
|
||||
|
||||
for (const appt of upcoming) {
|
||||
const [emailLog] = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
eq(reminderLogs.channel, "email")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const joined = appointmentMap.get(appt.id as string);
|
||||
if (!joined) continue;
|
||||
|
||||
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);
|
||||
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||
|
||||
const [client] = await db
|
||||
.select({
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
emailOptOut: clients.emailOptOut,
|
||||
smsOptIn: clients.smsOptIn,
|
||||
phone: clients.phone,
|
||||
})
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
if (!clientEmail || clientEmailOptOut) continue;
|
||||
if (!petName || !serviceName) continue;
|
||||
|
||||
if (!client || !client.email || client.emailOptOut) continue;
|
||||
|
||||
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) continue;
|
||||
const emailSent = sentEmail.has(appt.id as string);
|
||||
const smsSent = sentSms.has(appt.id as string);
|
||||
|
||||
let confirmationToken = appt.confirmationToken;
|
||||
if (!confirmationToken) {
|
||||
@@ -131,15 +141,15 @@ export async function runReminderCheck(): Promise<void> {
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
if (!emailLog) {
|
||||
if (!emailSent) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
clientEmail,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: staffName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
@@ -155,20 +165,20 @@ export async function runReminderCheck(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsLog && client.smsOptIn && client.phone) {
|
||||
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||
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}` : ""}`,
|
||||
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||
`Confirm: ${confirmUrl}`,
|
||||
`Cancel: ${cancelUrl}`,
|
||||
TCPA_OPT_OUT,
|
||||
].join(". ");
|
||||
try {
|
||||
const smsOk = await smsSend(client.phone, smsBody);
|
||||
const smsOk = await smsSend(clientPhone, smsBody);
|
||||
if (smsOk) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
/**
|
||||
* Playwright configuration for GroomBook Web E2E tests.
|
||||
*
|
||||
* Targets the deployed dev environment at groombook.dev.farh.net.
|
||||
* Targets the deployed dev environment at dev.groombook.dev.
|
||||
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
||||
*
|
||||
* Run locally:
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
|
||||
use: {
|
||||
baseURL: "https://groombook.dev.farh.net",
|
||||
baseURL: "https://dev.groombook.dev",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
serviceWorkers: "block",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
||||
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer test-session-id",
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -269,7 +269,7 @@ describe("ConfirmationSection", () => {
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer test-session-id",
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,15 +46,18 @@ export function GlobalSearch() {
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||
if (res.ok) {
|
||||
const data: SearchResults = await res.json();
|
||||
setResults(data);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setError("Search failed. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("GlobalSearch: fetch error", err);
|
||||
} catch {
|
||||
setError("Search failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,7 +164,13 @@ export function GlobalSearch() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasResults && (
|
||||
{!loading && error && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !hasResults && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||
No results found
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -273,7 +273,15 @@ export function AppointmentsPage() {
|
||||
cascade !== "this_only"
|
||||
? `/api/appointments/${id}?cascade=${cascade}`
|
||||
: `/api/appointments/${id}`;
|
||||
await fetch(url, { method: "DELETE" });
|
||||
try {
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||
}
|
||||
setSelectedAppt(null);
|
||||
await loadAppointments();
|
||||
}
|
||||
@@ -819,8 +827,49 @@ function AppointmentDetail({
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
@@ -833,6 +882,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -211,6 +211,15 @@ function InvoiceDetailModal({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||
// Real-time validation: prevent submit if tip splits don't sum to 100%
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
if (Math.abs(totalPct - 100) >= 0.01) {
|
||||
setError("Tip split percentages must sum to 100%");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -199,11 +199,11 @@ export function ReportsPage() {
|
||||
}
|
||||
|
||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||
summRes.json() as Promise<Summary>,
|
||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
||||
clientRes.json() as Promise<ClientReport>,
|
||||
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||
]);
|
||||
|
||||
setSummary(summData);
|
||||
|
||||
@@ -27,6 +27,8 @@ interface AuthProviderForm {
|
||||
|
||||
const REDACTED = "••••••••";
|
||||
|
||||
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -149,9 +151,9 @@ export function SettingsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
|
||||
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
|
||||
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,7 +328,7 @@ issuerUrl: authForm.issuerUrl,
|
||||
|
||||
if (!loaded) return <p>Loading settings...</p>;
|
||||
|
||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
@@ -393,7 +395,7 @@ issuerUrl: authForm.issuerUrl,
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleLogoChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export { SetupWizard } from "./SetupWizard.jsx";
|
||||
export { SetupWizard } from "./SetupWizard.tsx";
|
||||
|
||||
@@ -2,16 +2,39 @@ import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
|
||||
export function SetupWizard({ onSetupComplete }) {
|
||||
interface SetupStatus {
|
||||
showAuthProviderStep?: boolean;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AuthFormState {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const { refresh: refreshBranding } = useBranding();
|
||||
|
||||
// Fetch setup status to determine if auth provider step is needed
|
||||
const [setupStatus, setSetupStatus] = useState(null); // null = loading
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
|
||||
// Auth provider form state
|
||||
const [authForm, setAuthForm] = useState({
|
||||
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||
providerId: "authentik",
|
||||
displayName: "",
|
||||
issuerUrl: "",
|
||||
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
scopes: "openid profile email",
|
||||
});
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/setup/status")
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.json() as Promise<SetupStatus>)
|
||||
.then((data) => {
|
||||
setSetupStatus(data);
|
||||
setLoadingStatus(false);
|
||||
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build steps dynamically based on setup status
|
||||
const STEPS = setupStatus?.showAuthProviderStep
|
||||
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||
? [
|
||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
||||
@@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
const isFirst = step === 0;
|
||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||
|
||||
// Determine if we can proceed - depends on which step we're on
|
||||
const canGoNext = (() => {
|
||||
if (step === STEPS.length - 1) return true; // done step
|
||||
if (step === STEPS.length - 1) return true;
|
||||
if (current?.id === "business") return businessName.trim().length > 0;
|
||||
if (current?.id === "auth") {
|
||||
return (
|
||||
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
scopes: authForm.scopes,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as TestResult;
|
||||
setTestResult(data);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === STEPS.length - 1) {
|
||||
// Done - redirect to admin
|
||||
navigate("/admin");
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit auth provider config
|
||||
if (current?.id === "auth") {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as { error?: string };
|
||||
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Submit business name and complete setup
|
||||
if (current?.id === "business" && businessName.trim()) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as { error?: string };
|
||||
setError(data.error || "Setup failed. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Refresh branding so the nav bar shows the new business name
|
||||
refreshBranding();
|
||||
// Clear needsSetup state in App so the redirect to /admin sticks
|
||||
if (onSetupComplete) onSetupComplete();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.6rem 0.85rem",
|
||||
borderRadius: 8,
|
||||
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
maxWidth: 480,
|
||||
width: "100%",
|
||||
}}>
|
||||
{/* Progress dots */}
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||
Step {step + 1} of {STEPS.length}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||
{current?.title}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||
{current?.description}
|
||||
</p>
|
||||
|
||||
{/* Step: Business name input */}
|
||||
{current?.id === "business" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Happy Paws Grooming"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step: Auth provider config form */}
|
||||
{current?.id === "auth" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||
{/* Provider ID */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Provider ID
|
||||
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Display Name
|
||||
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Issuer URL
|
||||
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal Base URL (optional) */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Client ID
|
||||
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Client Secret
|
||||
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scopes */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Scopes
|
||||
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
onClick={() => { void handleTestConnection(); }}
|
||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||
style={{
|
||||
padding: "0.45rem 0.85rem",
|
||||
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
{testingConnection ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Super user info */}
|
||||
{current?.id === "superuser" && (
|
||||
<div style={{
|
||||
background: "#f0fdf4",
|
||||
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Second admin info */}
|
||||
{current?.id === "admin" && (
|
||||
<div style={{
|
||||
background: "#fffbeb",
|
||||
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p style={{
|
||||
margin: "0.5rem 0 0",
|
||||
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
onClick={() => { void handleNext(); }}
|
||||
disabled={(!canGoNext && !isLast) || loading}
|
||||
style={{
|
||||
padding: "0.55rem 1.25rem",
|
||||
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||
|
||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||
|
||||
@@ -34,7 +35,7 @@ export function CustomerPortal() {
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [showReschedule, setShowReschedule] = useState(false);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||
const [sessionExtended, setSessionExtended] = useState(false);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
@@ -149,7 +150,7 @@ export function CustomerPortal() {
|
||||
const handleReschedule = useCallback((appointmentId: string) => {
|
||||
// Look up the full appointment from Dashboard's displayed data
|
||||
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
||||
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
|
||||
setShowReschedule(true);
|
||||
}, []);
|
||||
|
||||
@@ -227,7 +228,7 @@ export function CustomerPortal() {
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment as any}
|
||||
appointment={rescheduleAppointment}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Appointment {
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
@@ -379,7 +379,7 @@ export function ConfirmationSection({
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) {
|
||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
}
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
||||
method: 'POST',
|
||||
@@ -455,7 +455,7 @@ function CancelAppointmentButton({
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) {
|
||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
}
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
||||
method: 'POST',
|
||||
@@ -507,7 +507,7 @@ export function CustomerNotesSection({
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) {
|
||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
}
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
||||
method: 'PATCH',
|
||||
@@ -600,7 +600,7 @@ export function RescheduleFlow({
|
||||
setError(null);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${sessionId}`,
|
||||
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
petId: selectedPet.id,
|
||||
|
||||
@@ -399,7 +399,6 @@ async function seedKnownUsers() {
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
userId: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -426,7 +425,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 +451,6 @@ async function seedKnownUsers() {
|
||||
name: "UAT Staff Groomer",
|
||||
email: "uat-groomer@groombook.dev",
|
||||
oidcSub: uatStaffOidcSub,
|
||||
userId: uatStaffOidcSub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
@@ -648,7 +645,6 @@ async function seed() {
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
userId: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface Pet {
|
||||
shampooPreference: string | null;
|
||||
specialCareNotes: string | null;
|
||||
customFields: Record<string, string>;
|
||||
photoKey?: string;
|
||||
photoUploadedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user