Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9be160c1b | |||
| dcb929be5b | |||
| 0ace23de53 | |||
| 3c7820d785 | |||
| b683c57d6c | |||
| 89505a2363 | |||
| ea7bf4f49b | |||
| 6e1e51fba7 | |||
| 5a8ea2fd14 | |||
| b00d6a8ca0 | |||
| f8ea417799 | |||
| edf2ef8f7e | |||
| 8182870d38 | |||
| 5df8837b5f | |||
| 0abb79010d | |||
| eab97b2ebd | |||
| f301b1a5a0 | |||
| c786544369 | |||
| 85c76b5209 | |||
| d8dbec1be1 | |||
| 4a65c30d40 | |||
| cab17e0230 |
@@ -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.
|
||||
+5
-2
@@ -12,7 +12,8 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
RUN mkdir -p /home/node/.cache/node/corepack && \
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
COPY packages/ packages/
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
@@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \
|
||||
|
||||
# Runtime
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN corepack enable && \
|
||||
mkdir -p /home/node/.cache/node/corepack && \
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
||||
+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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ const updateInvoiceSchema = z.object({
|
||||
taxCents: z.number().int().nonnegative().optional(),
|
||||
tipCents: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
tipSplits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).optional(),
|
||||
});
|
||||
|
||||
// List invoices
|
||||
@@ -334,7 +341,30 @@ invoicesRouter.patch(
|
||||
}
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||
// Tip split validation when marking as paid with a tip
|
||||
const effectiveTipCents = body.tipCents ?? current.tipCents;
|
||||
if (body.status === "paid" && effectiveTipCents > 0) {
|
||||
if (body.tipSplits !== undefined) {
|
||||
if (body.tipSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
|
||||
}
|
||||
const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
if (totalBps !== 10000) {
|
||||
return c.json({ error: "Split percentages must sum to 100" }, 422);
|
||||
}
|
||||
} else {
|
||||
const existingSplits = await db
|
||||
.select({ id: invoiceTipSplits.id })
|
||||
.from(invoiceTipSplits)
|
||||
.where(eq(invoiceTipSplits.invoiceId, id));
|
||||
if (existingSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body;
|
||||
const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
|
||||
|
||||
// Auto-set paidAt when marking as paid
|
||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||
@@ -348,11 +378,41 @@ invoicesRouter.patch(
|
||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
const [upd] = await tx
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
// Atomically save tip splits when marking paid with provided splits
|
||||
if (
|
||||
body.status === "paid" &&
|
||||
effectiveTipCents > 0 &&
|
||||
incomingTipSplits !== undefined &&
|
||||
incomingTipSplits.length > 0
|
||||
) {
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
|
||||
let remaining = effectiveTipCents;
|
||||
const rows = incomingTipSplits.map((s, i) => {
|
||||
const isLast = i === incomingTipSplits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
|
||||
return [upd];
|
||||
});
|
||||
|
||||
const lineItems = await db
|
||||
.select()
|
||||
|
||||
@@ -40,7 +40,6 @@ portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
@@ -60,12 +59,15 @@ portalRouter.get("/appointments", async (c) => {
|
||||
|
||||
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||
const serviceIds = allAppts.map(a => a.serviceId).filter((id): id is string => id !== null);
|
||||
|
||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
|
||||
|
||||
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
|
||||
|
||||
const appts = allAppts.map(a => ({
|
||||
id: a.id,
|
||||
@@ -76,14 +78,11 @@ portalRouter.get("/appointments", async (c) => {
|
||||
customerNotes: a.customerNotes,
|
||||
notes: a.notes,
|
||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||
service: a.serviceId ? { id: a.serviceId } : null,
|
||||
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name, duration: serviceMap[a.serviceId]?.durationMinutes, price: serviceMap[a.serviceId]?.basePriceCents } : null,
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
||||
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
||||
|
||||
return c.json({ upcoming, past });
|
||||
return c.json({ appointments: appts });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
|
||||
@@ -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,36 +211,41 @@ 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 patchBody: {
|
||||
status: string;
|
||||
paymentMethod: string;
|
||||
tipCents: number;
|
||||
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
|
||||
} = { status: "paid", paymentMethod, tipCents };
|
||||
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
patchBody.tipSplits = tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
}));
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
||||
body: JSON.stringify(patchBody),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// Save tip splits if applicable and tip > 0
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
if (Math.abs(totalPct - 100) < 0.01) {
|
||||
const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
splits: tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)");
|
||||
}
|
||||
}
|
||||
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update");
|
||||
|
||||
@@ -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;
|
||||
@@ -123,7 +123,28 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const fetchedAppointments: Appointment[] = data.appointments || data || [];
|
||||
const rawAppointments: Record<string, unknown>[] = data.appointments || data || [];
|
||||
|
||||
// Transform API response (startTime) to client format (date + time)
|
||||
const fetchedAppointments: Appointment[] = rawAppointments.map((a) => {
|
||||
const start = new Date(a.startTime as string);
|
||||
const dateStr = start.toISOString().split('T')[0];
|
||||
const hours = start.getHours();
|
||||
const minutes = start.getMinutes().toString().padStart(2, '0');
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const timeStr = `${hour12}:${minutes} ${period}`;
|
||||
return {
|
||||
...a,
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
petName: (a.pet as { name?: string })?.name,
|
||||
serviceName: (a.service as { name?: string })?.name,
|
||||
groomerName: (a.staff as { name?: string })?.name,
|
||||
duration: (a.service as { duration?: number })?.duration,
|
||||
price: (a.service as { price?: number })?.price,
|
||||
} as Appointment;
|
||||
});
|
||||
|
||||
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
|
||||
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
|
||||
@@ -379,7 +400,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 +476,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 +528,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 +621,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 +805,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,
|
||||
|
||||
@@ -116,7 +116,24 @@ export function Dashboard({
|
||||
const invoicesData = await invoicesRes.json();
|
||||
const brandingData = await brandingRes.json();
|
||||
|
||||
setAppointments(appointmentsData.appointments || []);
|
||||
const rawAppointments: Record<string, unknown>[] = appointmentsData.appointments || [];
|
||||
const transformedAppointments = rawAppointments.map((a) => {
|
||||
const start = new Date(a.startTime as string);
|
||||
const dateStr = start.toISOString().split('T')[0];
|
||||
const hours = start.getHours();
|
||||
const minutes = start.getMinutes().toString().padStart(2, '0');
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const timeStr = `${hour12}:${minutes} ${period}`;
|
||||
return {
|
||||
...a,
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
petName: (a.pet as { name?: string })?.name ?? '',
|
||||
serviceName: (a.service as { name?: string })?.name ?? '',
|
||||
};
|
||||
});
|
||||
setAppointments(transformedAppointments as Appointment[]);
|
||||
setPets(petsData.pets || []);
|
||||
|
||||
// Filter for pending invoices only (not "outstanding")
|
||||
|
||||
@@ -44,8 +44,25 @@ export function ReportCards({ sessionId }: Props) {
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allAppointments: Appointment[] = data.appointments || data || [];
|
||||
const reportCardAppointments = allAppointments.filter(
|
||||
const rawAppointments: Record<string, unknown>[] = data.appointments || data || [];
|
||||
const transformed: Appointment[] = rawAppointments.map((a) => {
|
||||
const start = new Date(a.startTime as string);
|
||||
const dateStr = start.toISOString().split('T')[0];
|
||||
const hours = start.getHours();
|
||||
const minutes = start.getMinutes().toString().padStart(2, '0');
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const timeStr = `${hour12}:${minutes} ${period}`;
|
||||
return {
|
||||
...a,
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
petName: (a.pet as { name?: string })?.name,
|
||||
serviceName: (a.service as { name?: string })?.name,
|
||||
groomerName: (a.staff as { name?: string })?.name,
|
||||
} as Appointment;
|
||||
});
|
||||
const reportCardAppointments = transformed.filter(
|
||||
(appt) => appt.reportCardId
|
||||
);
|
||||
setAppointments(reportCardAppointments);
|
||||
|
||||
@@ -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