Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67552197ed | |||
| a7838b3785 | |||
| a70dbbd2c1 | |||
| a61614c4a9 | |||
| 28a78a79d5 | |||
| 35c72a6c4b | |||
| 2d88f18f75 | |||
| 9363929f32 | |||
| 2c2a69f20b | |||
| e52d561454 | |||
| 49dd698d22 | |||
| 305394baaf | |||
| 706c91b3ac | |||
| 39f5c83049 | |||
| 6c0cdb33fe | |||
| 2134676f10 | |||
| dec4112ee5 | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| aa5686bed1 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| fe2e093b92 | |||
| 2af1671891 | |||
| ad80722eee | |||
| c811b58c62 | |||
| 1dfcdcc2cb | |||
| f74e034495 | |||
| 4c46cec4e3 | |||
| 251b36b863 | |||
| 3c366ccc46 | |||
| ff149f75dc | |||
| 03bd2d0235 | |||
| 10ad5e7b04 | |||
| 4f85a4a432 | |||
| 560d33edf8 | |||
| 50e9e70935 | |||
| d59cb1ab1d | |||
| 740e46baf2 | |||
| b1b89966d9 | |||
| 25fd3308e0 | |||
| be07c8b758 | |||
| ff2851eda2 | |||
| 460ba78112 | |||
| ffe8aef035 | |||
| 2153505875 | |||
| 4aaf2a3b3f | |||
| 20ca93b36d | |||
| 9793283021 | |||
| 1cc6d53546 | |||
| bfe099deda | |||
| 47ccd1395c | |||
| ef79ac748c | |||
| 06846952a1 | |||
| d72485c08a | |||
| 4001691ae7 | |||
| b980e4177c | |||
| 6141dcb77d | |||
| 8ecbfbeee4 | |||
| 1da61fb466 | |||
| 77971a1ac9 | |||
| e539b6c904 | |||
| b797ac3ab1 | |||
| 6bddd6203d | |||
| 3c7820d785 | |||
| 9eb86004fc | |||
| 6046594a15 | |||
| b683c57d6c | |||
| 89505a2363 | |||
| 8e1e51be59 | |||
| ea7bf4f49b | |||
| 6e1e51fba7 | |||
| 5a8ea2fd14 | |||
| b00d6a8ca0 | |||
| f8ea417799 | |||
| 772f4df62f | |||
| edf2ef8f7e | |||
| 8182870d38 | |||
| 7f715ecdfc | |||
| 5df8837b5f | |||
| 0abb79010d | |||
| eab97b2ebd | |||
| f301b1a5a0 | |||
| c786544369 | |||
| 85c76b5209 | |||
| d8dbec1be1 | |||
| 4a65c30d40 | |||
| cab17e0230 |
@@ -11,6 +11,10 @@ AUTH_DISABLED=false
|
|||||||
OIDC_ISSUER=https://authentik.example.com
|
OIDC_ISSUER=https://authentik.example.com
|
||||||
OIDC_AUDIENCE=groombook
|
OIDC_AUDIENCE=groombook
|
||||||
|
|
||||||
|
# ── Webhooks ─────────────────────────────────────────────────────────────────
|
||||||
|
# Telnyx webhook secret for validating inbound message webhooks.
|
||||||
|
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
|
||||||
|
|
||||||
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
||||||
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
||||||
# super user exists in the database. Useful in dev/test environments where the
|
# super user exists in the database. Useful in dev/test environments where the
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
|
|||||||
+13
@@ -8,3 +8,16 @@ dist/
|
|||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
minimax-output/
|
minimax-output/
|
||||||
|
|
||||||
|
# Agent runtime artifacts — never commit
|
||||||
|
.gh-token
|
||||||
|
*.gh-token
|
||||||
|
.config/gh/
|
||||||
|
**/.config/gh/
|
||||||
|
infra-repo
|
||||||
|
infra-repo/
|
||||||
|
**/instructions/.gh-token
|
||||||
|
**/AGENT_HOME/**
|
||||||
|
$AGENT_HOME/**
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -24,13 +24,14 @@
|
|||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
"telnyx": "^1.23.0",
|
"telnyx": "^1.23.0",
|
||||||
|
"uuid": "^11.1.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
export const mockRows: Record<string, unknown[]> = {};
|
||||||
|
|
||||||
|
export function resetMock() {
|
||||||
|
Object.keys(mockRows).forEach((key) => {
|
||||||
|
mockRows[key] = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChainable(data: unknown[]): unknown {
|
||||||
|
const arr = [...data];
|
||||||
|
const chain = new Proxy(arr, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (
|
||||||
|
prop === "where" ||
|
||||||
|
prop === "orderBy" ||
|
||||||
|
prop === "limit" ||
|
||||||
|
prop === "leftJoin" ||
|
||||||
|
prop === "rightJoin" ||
|
||||||
|
prop === "innerJoin"
|
||||||
|
) {
|
||||||
|
return () => chain;
|
||||||
|
}
|
||||||
|
return target[prop as keyof typeof target];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTableProxy(tableName: string): unknown {
|
||||||
|
return new Proxy(
|
||||||
|
{ _name: tableName },
|
||||||
|
{
|
||||||
|
get: (target, prop) =>
|
||||||
|
prop === "_name" ? tableName : { table: tableName, column: prop },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
"user",
|
||||||
|
"session",
|
||||||
|
"account",
|
||||||
|
"verification",
|
||||||
|
"clients",
|
||||||
|
"pets",
|
||||||
|
"services",
|
||||||
|
"staff",
|
||||||
|
"recurringSeries",
|
||||||
|
"appointmentGroups",
|
||||||
|
"appointments",
|
||||||
|
"invoices",
|
||||||
|
"invoiceLineItems",
|
||||||
|
"invoiceTipSplits",
|
||||||
|
"refunds",
|
||||||
|
"reminderLogs",
|
||||||
|
"impersonationSessions",
|
||||||
|
"impersonationAuditLogs",
|
||||||
|
"conversations",
|
||||||
|
"messages",
|
||||||
|
"messageAttachments",
|
||||||
|
"messageConsentEvents",
|
||||||
|
"businessSettings",
|
||||||
|
"groomingVisitLogs",
|
||||||
|
"waitlistEntries",
|
||||||
|
"authProviderConfig",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TableName = (typeof tables)[number];
|
||||||
|
|
||||||
|
const tableProxies: Record<TableName, unknown> = {} as Record<TableName, unknown>;
|
||||||
|
|
||||||
|
tables.forEach((table) => {
|
||||||
|
tableProxies[table] = createTableProxy(table);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@groombook/db", () => ({
|
||||||
|
getDb: () => ({
|
||||||
|
select: () => ({
|
||||||
|
from: (table: { _name: string }) => {
|
||||||
|
const tableName = table._name as TableName;
|
||||||
|
const rows = mockRows[tableName] || [];
|
||||||
|
return makeChainable(rows);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: (vals: Record<string, unknown>) => ({
|
||||||
|
returning: () => [{ ...vals, id: "mock-id" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: () => ({
|
||||||
|
set: (vals: Record<string, unknown>) => ({
|
||||||
|
where: () => ({
|
||||||
|
returning: () => [{ ...vals, id: "mock-id" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
delete: () => ({
|
||||||
|
where: () => ({
|
||||||
|
returning: () => [{ id: "mock-id" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
...tableProxies,
|
||||||
|
eq: vi.fn(),
|
||||||
|
and: vi.fn(),
|
||||||
|
or: vi.fn(),
|
||||||
|
ne: vi.fn(),
|
||||||
|
gt: vi.fn(),
|
||||||
|
gte: vi.fn(),
|
||||||
|
lt: vi.fn(),
|
||||||
|
lte: vi.fn(),
|
||||||
|
inArray: vi.fn(),
|
||||||
|
isNull: vi.fn(),
|
||||||
|
ilike: vi.fn(),
|
||||||
|
sql: vi.fn(),
|
||||||
|
exists: vi.fn(),
|
||||||
|
desc: vi.fn(),
|
||||||
|
asc: vi.fn(),
|
||||||
|
encryptSecret: vi.fn(),
|
||||||
|
decryptSecret: vi.fn(),
|
||||||
|
appointmentStatusEnum: ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"],
|
||||||
|
staffRoleEnum: ["groomer", "receptionist", "manager"],
|
||||||
|
invoiceStatusEnum: ["draft", "pending", "paid", "void"],
|
||||||
|
paymentMethodEnum: ["cash", "card", "check", "other"],
|
||||||
|
clientStatusEnum: ["active", "disabled"],
|
||||||
|
messagingChannelEnum: ["sms", "mms"],
|
||||||
|
messageDirectionEnum: ["inbound", "outbound"],
|
||||||
|
messageStatusEnum: ["queued", "sent", "delivered", "failed"],
|
||||||
|
}));
|
||||||
@@ -41,12 +41,14 @@ let selectRows: Record<string, unknown>[] = [];
|
|||||||
let selectSessionRow: Record<string, unknown> | null = null;
|
let selectSessionRow: Record<string, unknown> | null = null;
|
||||||
let insertedValues: Record<string, unknown>[] = [];
|
let insertedValues: Record<string, unknown>[] = [];
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
|
let insertedAuditLogs: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
selectSessionRow = null;
|
selectSessionRow = null;
|
||||||
insertedValues = [];
|
insertedValues = [];
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
|
insertedAuditLogs = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
vi.mock("@groombook/db", () => {
|
||||||
@@ -94,6 +96,11 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const impersonationAuditLogs = new Proxy(
|
||||||
|
{ _name: "impersonationAuditLogs" },
|
||||||
|
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@@ -109,9 +116,18 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
insert: () => ({
|
insert: () => ({
|
||||||
values: (vals: Record<string, unknown>) => {
|
values: (vals: Record<string, unknown>) => {
|
||||||
insertedValues.push(vals);
|
// Only count waitlist entry inserts, not audit log inserts from portalAudit middleware
|
||||||
|
if (vals.petId || vals.serviceId || vals.status !== undefined) {
|
||||||
|
insertedValues.push(vals);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
returning: () => {
|
||||||
|
if (vals.sessionId && !vals.petId) {
|
||||||
|
insertedAuditLogs.push(vals);
|
||||||
|
return [{ ...vals, id: "audit-log-uuid", createdAt: new Date() }];
|
||||||
|
}
|
||||||
|
return [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -139,6 +155,7 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
waitlistEntries,
|
waitlistEntries,
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
|
impersonationAuditLogs,
|
||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
services,
|
services,
|
||||||
|
|||||||
+87
-12
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { authProviderRouter } from "./routes/authProvider.js";
|
import { authProviderRouter } from "./routes/authProvider.js";
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { getPresignedGetUrl } from "./lib/s3.js";
|
import { getObject } from "./lib/s3.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
import { setupRouter } from "./routes/setup.js";
|
import { setupRouter } from "./routes/setup.js";
|
||||||
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||||
@@ -29,6 +29,7 @@ import { devRouter } from "./routes/dev.js";
|
|||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
|
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -69,31 +70,105 @@ app.route("/api/portal", portalRouter);
|
|||||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||||
|
|
||||||
|
// Public Telnyx messaging webhook — signature-verified, no auth required
|
||||||
|
app.route("/api/webhooks/telnyx", telnyxWebhooksRouter);
|
||||||
|
|
||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
// 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 logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
|
||||||
|
app.get("/api/branding/logo", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [row] = await db.select().from(businessSettings).limit(1);
|
||||||
|
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||||
|
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||||
|
|
||||||
|
const { body, contentType } = await getObject(row.logoKey);
|
||||||
|
return new Response(Buffer.from(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||||
app.get("/api/branding", async (c) => {
|
app.get("/api/branding", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [row] = await db.select().from(businessSettings).limit(1);
|
const [row] = await db.select().from(businessSettings).limit(1);
|
||||||
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
||||||
|
|
||||||
let logoUrl: string | null = null;
|
// Return the public proxy path so browser never sees a raw S3 URL
|
||||||
if (settings.logoKey) {
|
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
|
||||||
try {
|
|
||||||
logoUrl = await getPresignedGetUrl(settings.logoKey);
|
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||||
} catch {
|
// via the legacy base64 logo fields
|
||||||
// If S3 URL generation fails, fall back to legacy base64
|
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({
|
return c.json({
|
||||||
businessName: settings.businessName,
|
businessName: settings.businessName,
|
||||||
primaryColor: settings.primaryColor,
|
primaryColor: settings.primaryColor,
|
||||||
accentColor: settings.accentColor,
|
accentColor: settings.accentColor,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
logoBase64: settings.logoBase64,
|
logoBase64: safeLogoBase64,
|
||||||
logoMimeType: settings.logoMimeType,
|
logoMimeType: safeLogoMimeType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +217,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
|
|||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
|
|
||||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||||
|
|||||||
@@ -93,9 +93,15 @@ export async function initAuth(): Promise<void> {
|
|||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 100,
|
||||||
window: 60,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
|
customRules: {
|
||||||
|
"/sign-in/social": { max: 10, window: 60 },
|
||||||
|
"/sign-in/email": { max: 10, window: 60 },
|
||||||
|
"/sign-up/email": { max: 5, window: 60 },
|
||||||
|
"/get-session": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
@@ -240,9 +246,15 @@ export async function initAuth(): Promise<void> {
|
|||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 100,
|
||||||
window: 60,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
|
customRules: {
|
||||||
|
"/sign-in/social": { max: 10, window: 60 },
|
||||||
|
"/sign-in/email": { max: 10, window: 60 },
|
||||||
|
"/sign-up/email": { max: 5, window: 60 },
|
||||||
|
"/get-session": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
|
|||||||
@@ -67,3 +67,41 @@ export async function deleteObject(key: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read an object from S3 and return its body buffer and content type. */
|
||||||
|
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
|
||||||
|
const client = getS3Client();
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: getBucket(),
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
// response.Body is a Readable stream; collect chunks into a buffer
|
||||||
|
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const contentType = response.ContentType ?? "application/octet-stream";
|
||||||
|
return { body, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
|
||||||
|
export async function putObject(
|
||||||
|
key: string,
|
||||||
|
body: Buffer | Uint8Array | string,
|
||||||
|
contentType: string,
|
||||||
|
contentLength: number
|
||||||
|
): Promise<void> {
|
||||||
|
const client = getS3Client();
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: getBucket(),
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+162
-16
@@ -14,10 +14,19 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
sql,
|
sql,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv, StaffRole } from "../middleware/rbac.js";
|
||||||
|
import { requireRole } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const invoicesRouter = new Hono<AppEnv>();
|
export const invoicesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// Convert Zod validation errors from 422 to 400
|
||||||
|
invoicesRouter.onError((err, c) => {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
appointmentId: z.string().uuid().optional(),
|
appointmentId: z.string().uuid().optional(),
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
@@ -42,6 +51,13 @@ const updateInvoiceSchema = z.object({
|
|||||||
taxCents: z.number().int().nonnegative().optional(),
|
taxCents: z.number().int().nonnegative().optional(),
|
||||||
tipCents: z.number().int().nonnegative().optional(),
|
tipCents: z.number().int().nonnegative().optional(),
|
||||||
notes: z.string().max(2000).nullable().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
|
// List invoices
|
||||||
@@ -86,6 +102,8 @@ invoicesRouter.get(
|
|||||||
paymentMethod: invoices.paymentMethod,
|
paymentMethod: invoices.paymentMethod,
|
||||||
paidAt: invoices.paidAt,
|
paidAt: invoices.paidAt,
|
||||||
notes: invoices.notes,
|
notes: invoices.notes,
|
||||||
|
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoices.stripeRefundId,
|
||||||
createdAt: invoices.createdAt,
|
createdAt: invoices.createdAt,
|
||||||
updatedAt: invoices.updatedAt,
|
updatedAt: invoices.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -113,7 +131,17 @@ invoicesRouter.get("/:id", async (c) => {
|
|||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ ...invoice, lineItems, tipSplits });
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save tip splits for an invoice (replaces existing splits)
|
// Save tip splits for an invoice (replaces existing splits)
|
||||||
@@ -334,7 +362,23 @@ invoicesRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
const tipCents = body.tipCents ?? current.tipCents;
|
||||||
|
|
||||||
|
// Validate tip splits when marking invoice as paid
|
||||||
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
|
if (body.tipSplits.length === 0) {
|
||||||
|
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||||
|
}
|
||||||
|
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||||
|
if (Math.abs(totalPct - 100) > 0.01) {
|
||||||
|
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||||
|
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||||
|
|
||||||
// Auto-set paidAt when marking as paid
|
// Auto-set paidAt when marking as paid
|
||||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||||
@@ -348,16 +392,42 @@ invoicesRouter.patch(
|
|||||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||||
.update(invoices)
|
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||||
.set(update)
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
.where(eq(invoices.id, id))
|
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||||
.returning();
|
const splits = body.tipSplits;
|
||||||
|
if (splits.length > 0) {
|
||||||
|
let remaining = tipCents;
|
||||||
|
const rows = splits.map((s, i) => {
|
||||||
|
const isLast = i === splits.length - 1;
|
||||||
|
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lineItems = await db
|
const [updatedInvoice] = await tx
|
||||||
.select()
|
.update(invoices)
|
||||||
.from(invoiceLineItems)
|
.set(update)
|
||||||
.where(eq(invoiceLineItems.invoiceId, id));
|
.where(eq(invoices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const lineItems = await tx
|
||||||
|
.select()
|
||||||
|
.from(invoiceLineItems)
|
||||||
|
.where(eq(invoiceLineItems.invoiceId, id));
|
||||||
|
|
||||||
|
return [updatedInvoice, lineItems];
|
||||||
|
});
|
||||||
|
|
||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
@@ -365,7 +435,7 @@ invoicesRouter.patch(
|
|||||||
|
|
||||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { processRefund } from "../services/payment.js";
|
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||||
|
|
||||||
const refundSchema = z.object({
|
const refundSchema = z.object({
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
@@ -392,7 +462,7 @@ invoicesRouter.post(
|
|||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||||
}
|
}
|
||||||
if (!invoice.stripePaymentIntentId) {
|
if (!invoice.stripePaymentIntentId) {
|
||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
return c.json({ error: "Invoice has no Stripe payment intent" }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
@@ -408,15 +478,91 @@ invoicesRouter.post(
|
|||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
const result = await processRefund(id, body.amountCents);
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
const refundId = result.refundId;
|
||||||
|
|
||||||
await tx.insert(refunds).values({
|
await tx.insert(refunds).values({
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
stripeRefundId: result.refundId,
|
stripeRefundId: refundId,
|
||||||
idempotencyKey: body.idempotencyKey ?? null,
|
idempotencyKey: body.idempotencyKey ?? null,
|
||||||
amountCents: body.amountCents ?? null,
|
amountCents: body.amountCents ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Payment stats for admin dashboard
|
||||||
|
invoicesRouter.get("/stats/summary", requireRole("manager" as StaffRole), async (c) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const [revenueResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||||
|
|
||||||
|
const [outstandingResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
|
const [refundsResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||||
|
.from(refunds)
|
||||||
|
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||||
|
|
||||||
|
const methodBreakdown = await db
|
||||||
|
.select({
|
||||||
|
method: invoices.paymentMethod,
|
||||||
|
total: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||||
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
|
methodBreakdown,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stats/summary error:", err);
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||||
|
invoicesRouter.get("/:id/stripe-details", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
stripePaymentIntentId: invoice.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoice.stripeRefundId,
|
||||||
|
cardLast4,
|
||||||
|
paymentStatus,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -213,7 +213,11 @@ petsRouter.post(
|
|||||||
|
|
||||||
// Delete the previous photo from storage to avoid orphaned objects
|
// Delete the previous photo from storage to avoid orphaned objects
|
||||||
if (pet.photoKey) {
|
if (pet.photoKey) {
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => {
|
|||||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||||
|
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(pets)
|
.update(pets)
|
||||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||||
|
|||||||
@@ -9,6 +9,68 @@ import type { PortalEnv } from "../middleware/portalSession.js";
|
|||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
export const portalRouter = new Hono<PortalEnv>();
|
||||||
|
|
||||||
|
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||||
|
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||||
|
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||||
|
const devSessionSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post(
|
||||||
|
"/dev-session",
|
||||||
|
zValidator("json", devSessionSchema),
|
||||||
|
async (c) => {
|
||||||
|
if (process.env.AUTH_DISABLED !== "true") {
|
||||||
|
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, body.clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) {
|
||||||
|
return c.json({ error: "Client not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
let staffId = DEMO_STAFF_ID;
|
||||||
|
const [demoStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!demoStaff) {
|
||||||
|
const [firstStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.active, true))
|
||||||
|
.limit(1);
|
||||||
|
if (!firstStaff) {
|
||||||
|
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||||
|
}
|
||||||
|
staffId = firstStaff.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.insert(impersonationSessions)
|
||||||
|
.values({
|
||||||
|
staffId,
|
||||||
|
clientId: body.clientId,
|
||||||
|
reason: "dev-mode-client-portal",
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(session, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// Apply middleware to all portal routes
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
@@ -40,7 +102,6 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
.select({
|
.select({
|
||||||
id: appointments.id,
|
id: appointments.id,
|
||||||
@@ -80,10 +141,7 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : 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");
|
return c.json({ appointments: appts });
|
||||||
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
|
||||||
|
|
||||||
return c.json({ upcoming, past });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
@@ -91,7 +149,7 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
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 })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
@@ -460,73 +518,4 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
|
||||||
|
|
||||||
const devSessionSchema = z.object({
|
|
||||||
clientId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/dev-session",
|
|
||||||
zValidator("json", devSessionSchema),
|
|
||||||
async (c) => {
|
|
||||||
if (process.env.AUTH_DISABLED !== "true") {
|
|
||||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
// Verify client exists
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, body.clientId))
|
|
||||||
.limit(1);
|
|
||||||
if (!client) {
|
|
||||||
return c.json({ error: "Client not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a staff record to associate with the dev impersonation session.
|
|
||||||
// Use the demo-manager if it exists (created by seed with known ID),
|
|
||||||
// otherwise fall back to the first active staff record.
|
|
||||||
// This avoids hardcoding a UUID that may not exist in all environments.
|
|
||||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
let staffId = DEMO_STAFF_ID;
|
|
||||||
const [demoStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!demoStaff) {
|
|
||||||
// Fall back to any active staff member
|
|
||||||
const [firstStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.active, true))
|
|
||||||
.limit(1);
|
|
||||||
if (!firstStaff) {
|
|
||||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
|
||||||
}
|
|
||||||
staffId = firstStaff.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.insert(impersonationSessions)
|
|
||||||
.values({
|
|
||||||
staffId,
|
|
||||||
clientId: body.clientId,
|
|
||||||
reason: "dev-mode-client-portal",
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return c.json(session, 201);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -2,7 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||||
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
|
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||||
import { requireSuperUser } from "../middleware/rbac.js";
|
import { requireSuperUser } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const settingsRouter = new Hono();
|
export const settingsRouter = new Hono();
|
||||||
@@ -100,6 +100,77 @@ settingsRouter.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/settings/logo/upload
|
||||||
|
* Proxy upload through the API server to avoid mixed-content issues with
|
||||||
|
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
|
||||||
|
* directly to S3 from the server using the internal endpoint.
|
||||||
|
*/
|
||||||
|
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Parse multipart form data (file field)
|
||||||
|
const body = await c.req.parseBody({ all: true });
|
||||||
|
const file = body["file"];
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ error: "No file provided" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = file.type;
|
||||||
|
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSizeBytes = file.size;
|
||||||
|
if (fileSizeBytes > MAX_LOGO_SIZE) {
|
||||||
|
return c.json({ error: "File must not exceed 512 KB" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.select().from(businessSettings).limit(1);
|
||||||
|
if (!rows[0]) {
|
||||||
|
return c.json({ error: "Settings not found" }, 404);
|
||||||
|
}
|
||||||
|
const settingsId = rows[0].id;
|
||||||
|
|
||||||
|
const ext = contentType.split("/")[1] ?? "png";
|
||||||
|
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
await putObject(key, buffer, contentType, fileSizeBytes);
|
||||||
|
|
||||||
|
// Delete previous S3 object if any
|
||||||
|
if (rows[0].logoKey) {
|
||||||
|
await deleteObject(rows[0].logoKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database with new logo key
|
||||||
|
const [updated] = await db
|
||||||
|
.update(businessSettings)
|
||||||
|
.set({
|
||||||
|
logoKey: key,
|
||||||
|
logoBase64: null,
|
||||||
|
logoMimeType: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(businessSettings.id, settingsId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return c.json({ error: "Settings not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, logoKey: updated.logoKey });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/settings/logo/confirm
|
* POST /api/admin/settings/logo/confirm
|
||||||
* Called after the client has successfully uploaded to the presigned URL.
|
* Called after the client has successfully uploaded to the presigned URL.
|
||||||
@@ -144,7 +215,8 @@ settingsRouter.post(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/settings/logo
|
* GET /api/admin/settings/logo
|
||||||
* Returns a presigned GET URL for the logo.
|
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
||||||
|
* Returns the image bytes with proper Content-Type.
|
||||||
*/
|
*/
|
||||||
settingsRouter.get("/logo", async (c) => {
|
settingsRouter.get("/logo", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -153,8 +225,14 @@ settingsRouter.get("/logo", async (c) => {
|
|||||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||||
|
|
||||||
const url = await getPresignedGetUrl(row.logoKey);
|
const { body, contentType } = await getObject(row.logoKey);
|
||||||
return c.json({ url, logoKey: row.logoKey });
|
return new Response(Buffer.from(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
|
|||||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
||||||
const now = Date.now();
|
|
||||||
const entry = rateLimitMap.get(ip);
|
const entry = rateLimitMap.get(ip);
|
||||||
|
const now = Date.now();
|
||||||
if (!entry || now > entry.resetAt) {
|
if (!entry || now > entry.resetAt) {
|
||||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { validateTelnyxSignature } from "../../services/sms.js";
|
||||||
|
import {
|
||||||
|
handleMessageReceived,
|
||||||
|
handleMessageFinalized,
|
||||||
|
TelnyxMessageReceivedPayload,
|
||||||
|
} from "../../services/messaging/inbound.js";
|
||||||
|
|
||||||
|
export const telnyxWebhooksRouter = new Hono();
|
||||||
|
|
||||||
|
telnyxWebhooksRouter.post("/messaging", async (c) => {
|
||||||
|
const signature = c.req.header("telnyx-signature");
|
||||||
|
|
||||||
|
let rawBody: string;
|
||||||
|
try {
|
||||||
|
rawBody = await c.req.text();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Could not read body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateTelnyxSignature(rawBody, signature)) {
|
||||||
|
return c.json({ error: "Invalid signature" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: TelnyxMessageReceivedPayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload;
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = payload.data?.event_type;
|
||||||
|
if (!eventType) {
|
||||||
|
return c.json({ error: "Missing event_type" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "message.received") {
|
||||||
|
try {
|
||||||
|
await handleMessageReceived(payload);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
if (msg.startsWith("No business owns")) {
|
||||||
|
return c.json({ error: "Unknown messaging number" }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ error: msg }, 500);
|
||||||
|
}
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "message.finalized") {
|
||||||
|
const result = await handleMessageFinalized(payload);
|
||||||
|
if (result) {
|
||||||
|
return c.json({ received: true, messageId: result.messageId, status: result.newStatus });
|
||||||
|
}
|
||||||
|
return c.json({ received: true, messageId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { detectKeyword } from "../consent.js";
|
||||||
|
|
||||||
|
vi.mock("@groombook/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
clients: {},
|
||||||
|
messageConsentEvents: {},
|
||||||
|
businessSettings: {},
|
||||||
|
eq: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { handleConsentKeyword } = await import("../consent.js");
|
||||||
|
const { db } = await import("@groombook/db");
|
||||||
|
|
||||||
|
describe("detectKeyword", () => {
|
||||||
|
it.each([
|
||||||
|
["STOP", "opt_out"],
|
||||||
|
["STOPALL", "opt_out"],
|
||||||
|
["UNSUBSCRIBE", "opt_out"],
|
||||||
|
["CANCEL", "opt_out"],
|
||||||
|
["END", "opt_out"],
|
||||||
|
["QUIT", "opt_out"],
|
||||||
|
])("opt-out keyword %s → opt_out", (keyword, expected) => {
|
||||||
|
expect(detectKeyword(keyword)).toEqual({ kind: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["START", "opt_in"],
|
||||||
|
["UNSTOP", "opt_in"],
|
||||||
|
["YES", "opt_in"],
|
||||||
|
["SUBSCRIBE", "opt_in"],
|
||||||
|
])("opt-in keyword %s → opt_in", (keyword, expected) => {
|
||||||
|
expect(detectKeyword(keyword)).toEqual({ kind: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["HELP", "help"],
|
||||||
|
["INFO", "help"],
|
||||||
|
])("help keyword %s → help", (keyword, expected) => {
|
||||||
|
expect(detectKeyword(keyword)).toEqual({ kind: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case insensitive", () => {
|
||||||
|
expect(detectKeyword("stop")).toEqual({ kind: "opt_out" });
|
||||||
|
expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" });
|
||||||
|
expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" });
|
||||||
|
expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-keyword messages", () => {
|
||||||
|
expect(detectKeyword("hello")).toBeNull();
|
||||||
|
expect(detectKeyword("STOP IT")).toBeNull();
|
||||||
|
expect(detectKeyword("help me")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleConsentKeyword", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
db.insert.mockReturnValue({
|
||||||
|
values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
|
||||||
|
} as any);
|
||||||
|
db.update.mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseOpts = {
|
||||||
|
clientId: "client-1",
|
||||||
|
businessId: "biz-1",
|
||||||
|
db: db as unknown as typeof import("@groombook/db").db,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("opt_out", () => {
|
||||||
|
it("inserts consent event with sms_keyword source", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||||
|
|
||||||
|
expect(db.insert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||||
|
|
||||||
|
expect(db.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — second opt-out logs event but skips client update", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||||
|
|
||||||
|
expect(db.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unsubscribe reply text", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||||
|
expect(result.replyText).toBe(
|
||||||
|
"You have been unsubscribed and will no longer receive messages. Reply START to resubscribe."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("opt_in", () => {
|
||||||
|
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||||
|
|
||||||
|
expect(db.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears smsOptOutDate on opt-in after opt-out", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||||
|
|
||||||
|
expect(db.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — second opt-in skips client update", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||||
|
|
||||||
|
expect(db.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns resubscribe reply text", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||||
|
expect(result.replyText).toBe(
|
||||||
|
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("help", () => {
|
||||||
|
it("does not call update — opt-in state unchanged", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ messagingHelpReply: null }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
|
||||||
|
|
||||||
|
expect(db.update).not.toHaveBeenCalled();
|
||||||
|
expect(result.replyText).toBe(
|
||||||
|
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses business messagingHelpReply when configured", async () => {
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ messagingHelpReply: "Custom help text." }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
|
||||||
|
expect(result.replyText).toBe("Custom help text.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
findOrCreateConversation,
|
||||||
|
upsertMessage,
|
||||||
|
handleMessageReceived,
|
||||||
|
handleMessageFinalized,
|
||||||
|
TelnyxMessageReceivedPayload,
|
||||||
|
} from "../inbound.js";
|
||||||
|
import * as schema from "@groombook/db";
|
||||||
|
|
||||||
|
vi.mock("@groombook/db", () => ({
|
||||||
|
getDb: vi.fn(),
|
||||||
|
conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null },
|
||||||
|
messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null },
|
||||||
|
businessSettings: { id: "", messagingPhoneNumber: "" },
|
||||||
|
clients: { id: "", name: "", email: "", phone: "", status: "" },
|
||||||
|
eq: vi.fn(),
|
||||||
|
and: vi.fn(),
|
||||||
|
sql: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
select: vi.fn().mockReturnThis(),
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
insert: vi.fn().mockReturnThis(),
|
||||||
|
update: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType<typeof schema.getDb>);
|
||||||
|
|
||||||
|
const makePayload = (
|
||||||
|
eventType: "message.received" | "message.sent" | "message.finalized",
|
||||||
|
messageId: string,
|
||||||
|
fromPhone: string,
|
||||||
|
toPhone: string,
|
||||||
|
body = "Hello"
|
||||||
|
): TelnyxMessageReceivedPayload => ({
|
||||||
|
data: {
|
||||||
|
id: "evt-1",
|
||||||
|
event_type: eventType,
|
||||||
|
payload: {
|
||||||
|
message: {
|
||||||
|
id: messageId,
|
||||||
|
from: { phone: fromPhone, carrier: "carrier" },
|
||||||
|
to: [{ phone: toPhone }],
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signature validation via route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when telnyx-signature header is missing", async () => {
|
||||||
|
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
||||||
|
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
||||||
|
const req = new Request("http://localhost/messaging", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
const res = await telnyxWebhooksRouter.fetch(req);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when signature does not match", async () => {
|
||||||
|
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
|
||||||
|
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
||||||
|
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
||||||
|
const req = new Request("http://localhost/messaging", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"telnyx-signature": "sha256=bad",
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
const res = await telnyxWebhooksRouter.fetch(req);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOrCreateConversation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDb.select.mockReset();
|
||||||
|
mockDb.from.mockReset();
|
||||||
|
mockDb.where.mockReset();
|
||||||
|
mockDb.limit.mockReset();
|
||||||
|
mockDb.insert.mockReset();
|
||||||
|
mockDb.update.mockReset();
|
||||||
|
mockDb.returning.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns existing conversation when found", async () => {
|
||||||
|
mockDb.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
||||||
|
expect(result.id).toBe("conv-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates new conversation when none exists", async () => {
|
||||||
|
mockDb.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.insert.mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
||||||
|
expect(result.id).toBe("conv-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates placeholder client for unknown phone then creates conversation", async () => {
|
||||||
|
mockDb.select
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.insert.mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
||||||
|
expect(result.id).toBe("conv-3");
|
||||||
|
expect(result.clientId).toBe("client-3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertMessage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns isNew=false when message with providerMessageId already exists", async () => {
|
||||||
|
mockDb.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received");
|
||||||
|
expect(result.isNew).toBe(false);
|
||||||
|
expect(result.id).toBe("msg-existing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts new message and returns isNew=true", async () => {
|
||||||
|
mockDb.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.insert.mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued");
|
||||||
|
expect(result.isNew).toBe(true);
|
||||||
|
expect(result.id).toBe("msg-new");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessageReceived", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDb.select.mockReset();
|
||||||
|
mockDb.from.mockReset();
|
||||||
|
mockDb.where.mockReset();
|
||||||
|
mockDb.limit.mockReset();
|
||||||
|
mockDb.insert.mockReset();
|
||||||
|
mockDb.update.mockReset();
|
||||||
|
mockDb.returning.mockReset();
|
||||||
|
mockDb.select.mockImplementation(() => ({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when no business owns the to number", async () => {
|
||||||
|
const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000");
|
||||||
|
await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates conversation and message for valid inbound", async () => {
|
||||||
|
mockDb.select
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "biz-1" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.insert
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "client-new" }]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.update.mockReturnValueOnce({
|
||||||
|
set: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.insert.mockReturnValueOnce({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message");
|
||||||
|
const result = await handleMessageReceived(payload);
|
||||||
|
expect(result.messageId).toBe("msg-new");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessageFinalized", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDb.select.mockReset();
|
||||||
|
mockDb.from.mockReset();
|
||||||
|
mockDb.where.mockReset();
|
||||||
|
mockDb.limit.mockReset();
|
||||||
|
mockDb.insert.mockReset();
|
||||||
|
mockDb.update.mockReset();
|
||||||
|
mockDb.returning.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when message not found", async () => {
|
||||||
|
mockDb.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222");
|
||||||
|
const result = await handleMessageFinalized(payload);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates status to delivered for finalized inbound", async () => {
|
||||||
|
mockDb.select.mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockDb.update.mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "msg-1" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222");
|
||||||
|
const result = await handleMessageFinalized(payload);
|
||||||
|
expect(result?.newStatus).toBe("delivered");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockSendSms = vi.fn();
|
||||||
|
const mockGetDb = vi.fn();
|
||||||
|
const mockUuidv4 = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../sms.js", () => ({
|
||||||
|
sendSms: mockSendSms,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@groombook/db", () => ({
|
||||||
|
getDb: () => mockGetDb(),
|
||||||
|
conversations: {},
|
||||||
|
messages: {},
|
||||||
|
clients: {},
|
||||||
|
businessSettings: {},
|
||||||
|
eq: vi.fn((a, b) => [a, b]),
|
||||||
|
and: vi.fn((...args) => args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("uuid", () => ({
|
||||||
|
v4: () => mockUuidv4(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.js");
|
||||||
|
|
||||||
|
describe("sendMessage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUuidv4.mockReturnValue("test-uuid");
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildSelectMock(results: unknown[]) {
|
||||||
|
return vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue(results),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns suppressed=true when client has no phone", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
select: buildSelectMock([{ phone: null, smsOptIn: true }]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
businessId: "biz-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
body: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ suppressed: true });
|
||||||
|
expect(mockSendSms).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns suppressed=true when client has opted out of SMS", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
businessId: "biz-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
body: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ suppressed: true });
|
||||||
|
expect(mockSendSms).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
select: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
|
||||||
|
).rejects.toThrow(MissingTenantPhoneNumberError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists provider message id on success", async () => {
|
||||||
|
const messageId = "msg-1";
|
||||||
|
const conversationId = "conv-1";
|
||||||
|
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
select: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ id: conversationId }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" });
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
businessId: "biz-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
body: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
messageId,
|
||||||
|
providerMessageId: "provider-msg-1",
|
||||||
|
status: "sent",
|
||||||
|
suppressed: false,
|
||||||
|
});
|
||||||
|
expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists error on Telnyx failure", async () => {
|
||||||
|
const messageId = "msg-1";
|
||||||
|
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
select: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSendSms.mockRejectedValue(new Error("Telnyx API error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
|
||||||
|
).rejects.toThrow("Telnyx API error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { db, clients, messageConsentEvents, businessSettings, eq } from "@groombook/db";
|
||||||
|
|
||||||
|
export type KeywordKind = "opt_in" | "opt_out" | "help";
|
||||||
|
|
||||||
|
const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
|
||||||
|
const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]);
|
||||||
|
const HELP_KEYWORDS = new Set(["HELP", "INFO"]);
|
||||||
|
|
||||||
|
export function detectKeyword(body: string): { kind: KeywordKind } | null {
|
||||||
|
const normalized = body.trim().toUpperCase();
|
||||||
|
if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" };
|
||||||
|
if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" };
|
||||||
|
if (HELP_KEYWORDS.has(normalized)) return { kind: "help" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleConsentKeyword(opts: {
|
||||||
|
clientId: string;
|
||||||
|
businessId: string;
|
||||||
|
kind: KeywordKind;
|
||||||
|
db: typeof import("@groombook/db").db;
|
||||||
|
}): Promise<{ replyText: string }> {
|
||||||
|
const { clientId, businessId, kind, db: database } = opts;
|
||||||
|
|
||||||
|
await database.insert(messageConsentEvents).values({
|
||||||
|
clientId,
|
||||||
|
businessId,
|
||||||
|
kind,
|
||||||
|
source: "sms_keyword",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kind === "opt_out") {
|
||||||
|
const [existing] = await database
|
||||||
|
.select({ smsOptIn: clients.smsOptIn })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing?.smsOptIn !== false) {
|
||||||
|
await database
|
||||||
|
.update(clients)
|
||||||
|
.set({ smsOptIn: false, smsOptOutDate: new Date() })
|
||||||
|
.where(eq(clients.id, clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "opt_in") {
|
||||||
|
const [existing] = await database
|
||||||
|
.select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing?.smsOptIn !== true) {
|
||||||
|
await database
|
||||||
|
.update(clients)
|
||||||
|
.set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null })
|
||||||
|
.where(eq(clients.id, clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replyText:
|
||||||
|
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// kind === "help"
|
||||||
|
const [settings] = await database
|
||||||
|
.select({ messagingHelpReply: businessSettings.messagingHelpReply })
|
||||||
|
.from(businessSettings)
|
||||||
|
.where(eq(businessSettings.id, businessId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const replyText =
|
||||||
|
settings?.messagingHelpReply ??
|
||||||
|
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
|
||||||
|
|
||||||
|
return { replyText };
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { detectKeyword, handleConsentKeyword } from "./consent.js";
|
||||||
|
import { sendMessage } from "./outbound.js";
|
||||||
|
|
||||||
|
export interface TelnyxMessageReceivedPayload {
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
event_type: "message.received" | "message.sent" | "message.finalized";
|
||||||
|
payload: {
|
||||||
|
message: {
|
||||||
|
id: string;
|
||||||
|
from: { phone: string; carrier?: string };
|
||||||
|
to: { phone: string }[];
|
||||||
|
body: string;
|
||||||
|
media?: Array<{ type: string; url: string }>;
|
||||||
|
};
|
||||||
|
recording?: unknown;
|
||||||
|
leg_count?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOrCreateConversation(
|
||||||
|
businessId: string,
|
||||||
|
clientPhone: string,
|
||||||
|
businessNumber: string
|
||||||
|
): Promise<{ id: string; clientId: string }> {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: conversations.id, clientId: conversations.clientId })
|
||||||
|
.from(conversations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(conversations.businessId, businessId),
|
||||||
|
eq(conversations.externalNumber, clientPhone),
|
||||||
|
eq(conversations.businessNumber, businessNumber)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { id: existing.id, clientId: existing.clientId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingClient] = await db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.phone, clientPhone))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const clientId = existingClient?.id ?? uuidv4();
|
||||||
|
|
||||||
|
if (!existingClient) {
|
||||||
|
await db.insert(clients).values({
|
||||||
|
id: clientId,
|
||||||
|
name: clientPhone,
|
||||||
|
email: `sms-${uuidv4()}@placeholder.local`,
|
||||||
|
phone: clientPhone,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(conversations)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
businessId,
|
||||||
|
clientId,
|
||||||
|
channel: "sms",
|
||||||
|
externalNumber: clientPhone,
|
||||||
|
businessNumber,
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
status: "active",
|
||||||
|
})
|
||||||
|
.returning({ id: conversations.id, clientId: conversations.clientId });
|
||||||
|
|
||||||
|
if (!created) throw new Error("Failed to create conversation");
|
||||||
|
|
||||||
|
return { id: created.id, clientId: created.clientId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMessage(
|
||||||
|
providerMessageId: string,
|
||||||
|
conversationId: string,
|
||||||
|
direction: "inbound" | "outbound",
|
||||||
|
body: string,
|
||||||
|
status: "queued" | "sent" | "delivered" | "failed" | "received",
|
||||||
|
sentByStaffId?: string
|
||||||
|
): Promise<{ id: string; isNew: boolean }> {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: messages.id })
|
||||||
|
.from(messages)
|
||||||
|
.where(eq(messages.providerMessageId, providerMessageId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { id: existing.id, isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(messages)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
conversationId,
|
||||||
|
direction,
|
||||||
|
body,
|
||||||
|
status,
|
||||||
|
providerMessageId,
|
||||||
|
sentByStaffId: sentByStaffId ?? null,
|
||||||
|
})
|
||||||
|
.returning({ id: messages.id });
|
||||||
|
|
||||||
|
if (!inserted) throw new Error("Failed to insert message");
|
||||||
|
return { id: inserted.id, isNew: true };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes("unique")) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: messages.id })
|
||||||
|
.from(messages)
|
||||||
|
.where(eq(messages.providerMessageId, providerMessageId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) return { id: existing.id, isNew: false };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
|
||||||
|
const db = getDb();
|
||||||
|
const [settings] = await db
|
||||||
|
.select({ id: businessSettings.id })
|
||||||
|
.from(businessSettings)
|
||||||
|
.where(eq(businessSettings.messagingPhoneNumber, toNumber))
|
||||||
|
.limit(1);
|
||||||
|
return settings?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> {
|
||||||
|
const { message } = payload.data.payload;
|
||||||
|
const fromPhone = message.from.phone;
|
||||||
|
const toPhone = message.to[0]?.phone;
|
||||||
|
|
||||||
|
if (!toPhone) {
|
||||||
|
throw new Error("No recipient phone in payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = await resolveBusinessIdByMessagingNumber(toPhone);
|
||||||
|
if (!businessId) {
|
||||||
|
throw new Error(`No business owns messaging number: ${toPhone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
|
||||||
|
|
||||||
|
await getDb()
|
||||||
|
.update(conversations)
|
||||||
|
.set({ lastMessageAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(conversations.id, conversationId));
|
||||||
|
|
||||||
|
const { id: messageId } = await upsertMessage(
|
||||||
|
message.id,
|
||||||
|
conversationId,
|
||||||
|
"inbound",
|
||||||
|
message.body,
|
||||||
|
"received"
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyword = detectKeyword(message.body ?? "");
|
||||||
|
if (keyword) {
|
||||||
|
const { replyText } = await handleConsentKeyword({
|
||||||
|
clientId,
|
||||||
|
businessId,
|
||||||
|
kind: keyword.kind,
|
||||||
|
db: getDb(),
|
||||||
|
});
|
||||||
|
await sendMessage({
|
||||||
|
businessId,
|
||||||
|
clientId,
|
||||||
|
body: replyText,
|
||||||
|
staffId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conversationId, messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> {
|
||||||
|
const { message } = payload.data.payload;
|
||||||
|
|
||||||
|
if (!message.id) return null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: messages.id, status: messages.status })
|
||||||
|
.from(messages)
|
||||||
|
.where(eq(messages.providerMessageId, message.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
let newStatus = existing.status;
|
||||||
|
if (payload.data.event_type === "message.finalized") {
|
||||||
|
newStatus = "delivered";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus !== existing.status) {
|
||||||
|
await db
|
||||||
|
.update(messages)
|
||||||
|
.set({ status: newStatus, deliveredAt: new Date() })
|
||||||
|
.where(eq(messages.id, existing.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messageId: existing.id, newStatus };
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { sendSms } from "../sms.js";
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
businessId: string;
|
||||||
|
clientId: string;
|
||||||
|
body: string;
|
||||||
|
sentByStaffId?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResult {
|
||||||
|
messageId: string;
|
||||||
|
providerMessageId: string;
|
||||||
|
status: string;
|
||||||
|
suppressed: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageSuppressed {
|
||||||
|
suppressed: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendMessageResponse = SendMessageResult | SendMessageSuppressed;
|
||||||
|
|
||||||
|
export class MissingTenantPhoneNumberError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Tenant messagingPhoneNumber is not configured");
|
||||||
|
this.name = "MissingTenantPhoneNumberError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateConversation(
|
||||||
|
businessId: string,
|
||||||
|
clientId: string,
|
||||||
|
externalNumber: string,
|
||||||
|
businessNumber: string
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: conversations.id })
|
||||||
|
.from(conversations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(conversations.businessId, businessId),
|
||||||
|
eq(conversations.externalNumber, externalNumber),
|
||||||
|
eq(conversations.businessNumber, businessNumber)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) return { id: existing.id };
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(conversations)
|
||||||
|
.values({
|
||||||
|
id: uuidv4(),
|
||||||
|
businessId,
|
||||||
|
clientId,
|
||||||
|
channel: "sms",
|
||||||
|
externalNumber,
|
||||||
|
businessNumber,
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
status: "active",
|
||||||
|
})
|
||||||
|
.returning({ id: conversations.id });
|
||||||
|
|
||||||
|
if (!created) throw new Error("Failed to create conversation");
|
||||||
|
|
||||||
|
return { id: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFromNumber(businessId: string): Promise<string | null> {
|
||||||
|
const db = getDb();
|
||||||
|
const [settings] = await db
|
||||||
|
.select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber })
|
||||||
|
.from(businessSettings)
|
||||||
|
.where(eq(businessSettings.id, businessId))
|
||||||
|
.limit(1);
|
||||||
|
return settings?.messagingPhoneNumber ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(opts: SendMessageOptions): Promise<SendMessageResponse> {
|
||||||
|
const db = getDb();
|
||||||
|
const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select({ phone: clients.phone, smsOptIn: clients.smsOptIn })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client?.phone) {
|
||||||
|
return { suppressed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.smsOptIn) {
|
||||||
|
return { suppressed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = await resolveFromNumber(businessId);
|
||||||
|
if (!from) throw new MissingTenantPhoneNumberError();
|
||||||
|
|
||||||
|
const to = client.phone;
|
||||||
|
const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id;
|
||||||
|
|
||||||
|
const [queuedMessage] = await db
|
||||||
|
.insert(messages)
|
||||||
|
.values({
|
||||||
|
id: uuidv4(),
|
||||||
|
conversationId,
|
||||||
|
direction: "outbound",
|
||||||
|
body,
|
||||||
|
status: "queued",
|
||||||
|
sentByStaffId: sentByStaffId ?? null,
|
||||||
|
})
|
||||||
|
.returning({ id: messages.id });
|
||||||
|
|
||||||
|
if (!queuedMessage) throw new Error("Failed to insert queued message");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendSms(to, body, mediaUrls);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(messages)
|
||||||
|
.set({
|
||||||
|
status: "sent",
|
||||||
|
providerMessageId: result.messageId,
|
||||||
|
})
|
||||||
|
.where(eq(messages.id, queuedMessage.id));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(conversations)
|
||||||
|
.set({ lastMessageAt: new Date() })
|
||||||
|
.where(eq(conversations.id, conversationId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: queuedMessage.id,
|
||||||
|
providerMessageId: result.messageId,
|
||||||
|
status: result.status,
|
||||||
|
suppressed: false,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const errorCode = err instanceof Error ? err.name : "UNKNOWN";
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(messages)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
})
|
||||||
|
.where(eq(messages.id, queuedMessage.id));
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
|
|||||||
|
|
||||||
return { clientSecret: setupIntent.client_secret! };
|
return { clientSecret: setupIntent.client_secret! };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPaymentIntentDetails(
|
||||||
|
paymentIntentId: string
|
||||||
|
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
|
||||||
|
const cardLast4 = pi.payment_method
|
||||||
|
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
cardLast4,
|
||||||
|
paymentStatus: pi.status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
|
inArray,
|
||||||
lt,
|
lt,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
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) {
|
for (const appt of upcoming) {
|
||||||
const [emailLog] = await db
|
const joined = appointmentMap.get(appt.id as string);
|
||||||
.select({ id: reminderLogs.id })
|
if (!joined) continue;
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "email")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [smsLog] = await db
|
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||||
.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 [client] = await db
|
if (!clientEmail || clientEmailOptOut) continue;
|
||||||
.select({
|
if (!petName || !serviceName) continue;
|
||||||
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 (!client || !client.email || client.emailOptOut) continue;
|
const emailSent = sentEmail.has(appt.id as string);
|
||||||
|
const smsSent = sentSms.has(appt.id as string);
|
||||||
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;
|
|
||||||
|
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
@@ -131,15 +141,15 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailLog) {
|
if (!emailSent) {
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
clientEmail,
|
||||||
{
|
{
|
||||||
clientName: client.name,
|
clientName,
|
||||||
petName: pet.name,
|
petName,
|
||||||
serviceName: service.name,
|
serviceName,
|
||||||
groomerName,
|
groomerName: staffName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
},
|
},
|
||||||
window.hours,
|
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 apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||||
const smsBody = [
|
const smsBody = [
|
||||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||||
`Confirm: ${confirmUrl}`,
|
`Confirm: ${confirmUrl}`,
|
||||||
`Cancel: ${cancelUrl}`,
|
`Cancel: ${cancelUrl}`,
|
||||||
TCPA_OPT_OUT,
|
TCPA_OPT_OUT,
|
||||||
].join(". ");
|
].join(". ");
|
||||||
try {
|
try {
|
||||||
const smsOk = await smsSend(client.phone, smsBody);
|
const smsOk = await smsSend(clientPhone, smsBody);
|
||||||
if (smsOk) {
|
if (smsOk) {
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
|
|||||||
@@ -32,6 +32,35 @@ function isE164(phone: string): boolean {
|
|||||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateTelnyxSignature(
|
||||||
|
rawBody: string,
|
||||||
|
signature: string | undefined | null
|
||||||
|
): boolean {
|
||||||
|
if (!signature) return false;
|
||||||
|
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hmac = createHmac("sha256", secret);
|
||||||
|
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
|
||||||
|
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (sigBuf.length !== expBuf.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < sigBuf.length; i++) {
|
||||||
|
const sigByte = sigBuf[i] ?? 0;
|
||||||
|
const expByte = expBuf[i] ?? 0;
|
||||||
|
diff |= sigByte ^ expByte;
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendSms(
|
export async function sendSms(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -74,33 +103,7 @@ export class TelnyxProvider implements SmsProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateWebhookSignature(req: Request): boolean {
|
validateWebhookSignature(req: Request): boolean {
|
||||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature"));
|
||||||
if (!secret) return false;
|
|
||||||
|
|
||||||
const signature = req.headers.get("telnyx-signature");
|
|
||||||
if (!signature) return false;
|
|
||||||
|
|
||||||
const payload = JSON.stringify(req.body);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hmac = createHmac("sha256", secret);
|
|
||||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
|
||||||
|
|
||||||
const sigBuf = Buffer.from(signature);
|
|
||||||
const expBuf = Buffer.from(expected);
|
|
||||||
|
|
||||||
if (sigBuf.length !== expBuf.length) return false;
|
|
||||||
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < sigBuf.length; i++) {
|
|
||||||
const sigByte = sigBuf[i] ?? 0;
|
|
||||||
const expByte = expBuf[i] ?? 0;
|
|
||||||
diff |= sigByte ^ expByte;
|
|
||||||
}
|
|
||||||
return diff === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,52 @@ test("clicking a client shows their details", async ({ page }) => {
|
|||||||
// Email appears in both the list row and the detail panel once selected
|
// Email appears in both the list row and the detail panel once selected
|
||||||
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => {
|
||||||
|
// Mock individual client fetch for direct navigation
|
||||||
|
await page.route("/api/clients/client-1", (route) =>
|
||||||
|
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||||
|
);
|
||||||
|
// Mock pets for this client
|
||||||
|
await page.route("/api/pets**", (route) =>
|
||||||
|
route.fulfill({ json: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/clients/client-1");
|
||||||
|
// Client name must be visible without any clicking
|
||||||
|
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||||
|
// Should show back to list link
|
||||||
|
await expect(page.getByText("← Back to list")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation shows loading then client", async ({ page }) => {
|
||||||
|
let resolvePets: (value: unknown) => void;
|
||||||
|
const petsPromise = new Promise((resolve) => { resolvePets = resolve; });
|
||||||
|
|
||||||
|
await page.route("/api/clients/client-1", (route) =>
|
||||||
|
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||||
|
);
|
||||||
|
await page.route("/api/pets**", async (route) => {
|
||||||
|
await petsPromise;
|
||||||
|
await route.fulfill({ json: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationPromise = page.goto("/admin/clients/client-1");
|
||||||
|
// Should show loading state briefly
|
||||||
|
await expect(page.getByText("Loading client…")).toBeVisible();
|
||||||
|
// Resolve pets and wait for navigation
|
||||||
|
resolvePets!();
|
||||||
|
await navigationPromise;
|
||||||
|
// After data loads, client name is shown
|
||||||
|
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation shows error state on failure", async ({ page }) => {
|
||||||
|
await page.route("/api/clients/nonexistent", (route) =>
|
||||||
|
route.fulfill({ status: 404, json: { error: "Client not found" } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/clients/nonexistent");
|
||||||
|
await expect(page.getByText(/client not found/i)).toBeVisible();
|
||||||
|
await expect(page.getByText("← Back to clients")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ test.beforeEach(async ({ page }) => {
|
|||||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (url.includes("/api/invoices/stats/summary")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (url.includes("/api/invoices")) {
|
if (url.includes("/api/invoices")) {
|
||||||
return route.fulfill({ json: { data: [], total: 0 } });
|
return route.fulfill({ json: { data: [], total: 0 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,9 +72,15 @@ test.describe("Portal Data Integrity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("billing section renders without JS errors", async ({ page }) => {
|
test("billing section renders without JS errors", async ({ page }) => {
|
||||||
// Mock billing endpoint
|
// Mock portal billing endpoints
|
||||||
await page.route("**/api/billing**", (route) =>
|
await page.route("**/api/portal/config**", (route) =>
|
||||||
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
|
route.fulfill({ json: { stripePublishableKey: "" } })
|
||||||
|
);
|
||||||
|
await page.route("**/api/portal/invoices**", (route) =>
|
||||||
|
route.fulfill({ json: [] })
|
||||||
|
);
|
||||||
|
await page.route("**/api/portal/payment-methods**", (route) =>
|
||||||
|
route.fulfill({ json: [] })
|
||||||
);
|
);
|
||||||
|
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
/**
|
/**
|
||||||
* Playwright configuration for GroomBook Web E2E tests.
|
* 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.
|
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
||||||
*
|
*
|
||||||
* Run locally:
|
* Run locally:
|
||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: "https://groombook.dev.farh.net",
|
baseURL: "https://dev.groombook.dev",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
|
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||||
import { ServicesPage } from "./pages/Services.js";
|
import { ServicesPage } from "./pages/Services.js";
|
||||||
import { StaffPage } from "./pages/Staff.js";
|
import { StaffPage } from "./pages/Staff.js";
|
||||||
import { InvoicesPage } from "./pages/Invoices.js";
|
import { InvoicesPage } from "./pages/Invoices.js";
|
||||||
@@ -12,7 +13,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.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 { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -296,6 +297,7 @@ function AdminLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppointmentsPage />} />
|
<Route path="/" element={<AppointmentsPage />} />
|
||||||
<Route path="/clients" element={<ClientsPage />} />
|
<Route path="/clients" element={<ClientsPage />} />
|
||||||
|
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/invoices" element={<InvoicesPage />} />
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: 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",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: 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 [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResults | null>(null);
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,15 +46,18 @@ export function GlobalSearch() {
|
|||||||
|
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: SearchResults = await res.json();
|
const data: SearchResults = await res.json();
|
||||||
setResults(data);
|
setResults(data);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
} else {
|
||||||
|
setError("Search failed. Please try again.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.warn("GlobalSearch: fetch error", err);
|
setError("Search failed. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,13 @@ export function GlobalSearch() {
|
|||||||
</div>
|
</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" }}>
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||||
No results found
|
No results found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
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)) {
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -82,3 +82,13 @@ input:focus, select:focus, textarea:focus {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #94a3b8;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar hide utility ─── */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const weekEnd = addDays(weekStart, 6);
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadAppointments = useCallback(() => {
|
const loadAppointments = useCallback(() => {
|
||||||
const from = weekStart.toISOString();
|
const from = weekStart.toISOString();
|
||||||
const to = addDays(weekStart, 7).toISOString();
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
@@ -273,7 +281,15 @@ export function AppointmentsPage() {
|
|||||||
cascade !== "this_only"
|
cascade !== "this_only"
|
||||||
? `/api/appointments/${id}?cascade=${cascade}`
|
? `/api/appointments/${id}?cascade=${cascade}`
|
||||||
: `/api/appointments/${id}`;
|
: `/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);
|
setSelectedAppt(null);
|
||||||
await loadAppointments();
|
await loadAppointments();
|
||||||
}
|
}
|
||||||
@@ -306,6 +322,24 @@ export function AppointmentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Stats Summary */}
|
||||||
|
{paymentStats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── View Mode + Groomer Filters ── */}
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
@@ -819,8 +853,49 @@ function AppointmentDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -833,6 +908,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={modalRef}
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
|
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||||
|
|
||||||
|
export function ClientDetailPage() {
|
||||||
|
const { clientId } = useParams<{ clientId: string }>();
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
|
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
|
||||||
|
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const handlePhotoUploaded = useCallback((petId: string) => {
|
||||||
|
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId) {
|
||||||
|
setError("No client ID provided");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const id = clientId!;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [clientRes, petsRes] = await Promise.all([
|
||||||
|
fetch(`/api/clients/${encodeURIComponent(id)}`),
|
||||||
|
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!clientRes.ok) {
|
||||||
|
const err = await clientRes.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
|
||||||
|
}
|
||||||
|
if (!petsRes.ok) {
|
||||||
|
throw new Error(`Pets fetch failed: ${petsRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setClient(await clientRes.json() as Client);
|
||||||
|
setPets(await petsRes.json() as Pet[]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load client");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
async function loadVisitLogs(petId: string) {
|
||||||
|
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
|
||||||
|
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
|
||||||
|
if (r.ok) {
|
||||||
|
const logs = await r.json() as GroomingVisitLog[];
|
||||||
|
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
|
||||||
|
}
|
||||||
|
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
Loading client…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !client) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}>← Back to clients</Link>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
|
||||||
|
{error ?? "Client not found"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
|
||||||
|
{client.status === "disabled" && (
|
||||||
|
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
|
||||||
|
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
|
||||||
|
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
|
||||||
|
{client.notes && (
|
||||||
|
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||||
|
{client.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/admin/clients"
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#fff",
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back to list
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pets */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pets.length === 0 ? (
|
||||||
|
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
|
||||||
|
{pets.map((p) => (
|
||||||
|
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||||
|
{/* Photo + header */}
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
|
||||||
|
<PetPhotoDisplay
|
||||||
|
petId={p.id}
|
||||||
|
size={56}
|
||||||
|
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
|
||||||
|
{p.species}{p.breed ? ` · ${p.breed}` : ""}
|
||||||
|
</div>
|
||||||
|
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
|
||||||
|
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
|
||||||
|
<div style={{ marginTop: "0.3rem" }}>
|
||||||
|
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.healthAlerts && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>⚠ Health alerts:</span> {p.healthAlerts}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grooming preferences */}
|
||||||
|
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
|
||||||
|
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||||
|
{p.cutStyle && (
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.shampooPreference && (
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.specialCareNotes && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.groomingNotes && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visit history */}
|
||||||
|
{(() => {
|
||||||
|
const logs = visitLogs[p.id];
|
||||||
|
const loadingLogs = logsLoading[p.id];
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
|
||||||
|
{!logs && !loadingLogs && (
|
||||||
|
<button
|
||||||
|
onClick={() => { void loadVisitLogs(p.id); }}
|
||||||
|
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||||
|
>
|
||||||
|
Load history
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading…</div>}
|
||||||
|
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
|
||||||
|
{logs && logs.length > 0 && (
|
||||||
|
<>
|
||||||
|
{logs.slice(0, 3).map((log) => (
|
||||||
|
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
|
||||||
|
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
|
||||||
|
{log.cutStyle && <span> · {log.cutStyle}</span>}
|
||||||
|
{log.notes && <span> · {log.notes}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{logs.length > 3 && (
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef, useId } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
@@ -647,8 +647,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Client modal ── */}
|
{/* ── Client modal ── */}
|
||||||
{showClientForm && (
|
{showClientForm && (
|
||||||
<Modal onClose={() => setShowClientForm(false)}>
|
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
|
||||||
<form onSubmit={submitClient}>
|
<form onSubmit={submitClient}>
|
||||||
<Field label="Full name">
|
<Field label="Full name">
|
||||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -678,8 +677,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Pet modal ── */}
|
{/* ── Pet modal ── */}
|
||||||
{showPetForm && (
|
{showPetForm && (
|
||||||
<Modal onClose={() => setShowPetForm(false)}>
|
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
|
||||||
<form onSubmit={submitPet}>
|
<form onSubmit={submitPet}>
|
||||||
<Field label="Pet name">
|
<Field label="Pet name">
|
||||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -753,8 +751,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Visit log modal ── */}
|
{/* ── Visit log modal ── */}
|
||||||
{showLogForm && logPetId && (
|
{showLogForm && logPetId && (
|
||||||
<Modal onClose={() => setShowLogForm(false)}>
|
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
|
|
||||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
@@ -817,8 +814,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Delete confirmation modal ── */}
|
{/* ── Delete confirmation modal ── */}
|
||||||
{showDeleteConfirm && selectedClient && (
|
{showDeleteConfirm && selectedClient && (
|
||||||
<Modal onClose={() => setShowDeleteConfirm(false)}>
|
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||||
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
|
|
||||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
@@ -856,13 +852,60 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
|
||||||
|
const titleId = useId();
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||||
|
>
|
||||||
|
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+223
-29
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -173,6 +173,21 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
|
const [refundError, setRefundError] = useState<string | null>(null);
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
|
||||||
|
// Fetch current staff role to determine manager access
|
||||||
|
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/staff/me")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setStaffMe(d))
|
||||||
|
.catch(() => setStaffMe(null));
|
||||||
|
}, []);
|
||||||
|
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -211,36 +226,41 @@ function InvoiceDetailModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
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 {
|
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}`, {
|
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
body: JSON.stringify(patchBody),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = (await res.json()) as { error?: string };
|
const err = (await res.json()) as { error?: string };
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
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();
|
onUpdated();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to update");
|
setError(e instanceof Error ? e.message : "Failed to update");
|
||||||
@@ -330,6 +350,19 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
|
{invoice.stripePaymentIntentId && (
|
||||||
|
<>
|
||||||
|
{invoice.cardLast4 && (
|
||||||
|
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
|
||||||
|
)}
|
||||||
|
{invoice.paymentStatus && (
|
||||||
|
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
|
||||||
|
)}
|
||||||
|
{invoice.stripeRefundId && (
|
||||||
|
<SummaryRow label="Refund" value="Refunded" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
@@ -447,11 +480,92 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
{invoice.stripeRefundId && (
|
||||||
|
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
||||||
|
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
|
||||||
|
{showRefundDialog && (
|
||||||
|
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
||||||
|
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
|
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
||||||
|
Full refund
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
|
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
||||||
|
Partial refund
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{refundType === "partial" && (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Amount ($)"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRefunding(true);
|
||||||
|
setRefundError(null);
|
||||||
|
try {
|
||||||
|
if (refundType === "partial") {
|
||||||
|
const parsed = parseFloat(refundAmount);
|
||||||
|
if (isNaN(parsed) || parsed <= 0) {
|
||||||
|
setRefundError("Please enter a valid amount greater than zero.");
|
||||||
|
setRefunding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowRefundDialog(false);
|
||||||
|
onUpdated();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={refunding}
|
||||||
|
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
||||||
|
>
|
||||||
|
{refunding ? "Processing…" : "Process Refund"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +606,17 @@ export function InvoicesPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function loadInvoices(newOffset: number) {
|
async function loadInvoices(newOffset: number) {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
@@ -573,6 +695,34 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Stats Summary */}
|
||||||
|
{paymentStats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
|
||||||
|
</div>
|
||||||
|
{paymentStats.methodBreakdown.length > 0 && (
|
||||||
|
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#64748b" }}>
|
||||||
|
{paymentStats.methodBreakdown.map((b) => (
|
||||||
|
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invoiceList.length === 0 ? (
|
{invoiceList.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280" }}>
|
<p style={{ color: "#6b7280" }}>
|
||||||
No invoices yet. Create one from a completed appointment.
|
No invoices yet. Create one from a completed appointment.
|
||||||
@@ -677,19 +827,63 @@ export function InvoicesPage() {
|
|||||||
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
ref={modalRef}
|
||||||
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
style={{
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||||
}}>
|
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -199,11 +199,11 @@ export function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||||
summRes.json() as Promise<Summary>,
|
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||||
clientRes.json() as Promise<ClientReport>,
|
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSummary(summData);
|
setSummary(summData);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface AuthProviderForm {
|
|||||||
|
|
||||||
const REDACTED = "••••••••";
|
const REDACTED = "••••••••";
|
||||||
|
|
||||||
|
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
interface CurrentUser {
|
interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,24 +89,14 @@ export function SettingsPage() {
|
|||||||
fetch("/api/admin/settings")
|
fetch("/api/admin/settings")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(async (data) => {
|
.then(async (data) => {
|
||||||
let logoUrl: string | null = null;
|
// The logo is now proxied through the API server so the browser
|
||||||
if (data.logoKey) {
|
// never receives an S3 URL — use the proxy path directly as the src.
|
||||||
try {
|
|
||||||
const logoRes = await fetch("/api/admin/settings/logo");
|
|
||||||
if (logoRes.ok) {
|
|
||||||
const logoData = await logoRes.json();
|
|
||||||
logoUrl = logoData.url;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm({
|
setForm({
|
||||||
businessName: data.businessName ?? "GroomBook",
|
businessName: data.businessName ?? "GroomBook",
|
||||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||||
accentColor: data.accentColor ?? "#8b7355",
|
accentColor: data.accentColor ?? "#8b7355",
|
||||||
logoKey: data.logoKey ?? null,
|
logoKey: data.logoKey ?? null,
|
||||||
logoUrl,
|
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||||
logoBase64: data.logoBase64 ?? null,
|
logoBase64: data.logoBase64 ?? null,
|
||||||
logoMimeType: data.logoMimeType ?? null,
|
logoMimeType: data.logoMimeType ?? null,
|
||||||
});
|
});
|
||||||
@@ -149,54 +141,28 @@ export function SettingsPage() {
|
|||||||
return;
|
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)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get presigned upload URL
|
// Upload directly through the API server to avoid mixed-content issues
|
||||||
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
|
// with pre-signed URLs that use the internal HTTP endpoint
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: formData,
|
||||||
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
|
|
||||||
});
|
});
|
||||||
if (!uploadRes.ok) {
|
if (!uploadRes.ok) {
|
||||||
const err = await uploadRes.json().catch(() => null);
|
const err = await uploadRes.json().catch(() => null);
|
||||||
throw new Error(err?.error ?? "Failed to get upload URL");
|
throw new Error(err?.error ?? "Failed to upload logo");
|
||||||
}
|
|
||||||
const { uploadUrl, key } = await uploadRes.json();
|
|
||||||
|
|
||||||
// Step 2: PUT the file directly to S3
|
|
||||||
const putRes = await fetch(uploadUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": file.type },
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
if (!putRes.ok) {
|
|
||||||
throw new Error("Failed to upload logo to storage");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Confirm the upload
|
|
||||||
const confirmRes = await fetch("/api/admin/settings/logo/confirm", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
if (!confirmRes.ok) {
|
|
||||||
const err = await confirmRes.json().catch(() => null);
|
|
||||||
throw new Error(err?.error ?? "Failed to confirm logo upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Fetch the presigned GET URL for display
|
|
||||||
const logoRes = await fetch("/api/admin/settings/logo");
|
|
||||||
if (logoRes.ok) {
|
|
||||||
const logoData = await logoRes.json();
|
|
||||||
setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
|
|
||||||
} else {
|
|
||||||
setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null }));
|
|
||||||
}
|
}
|
||||||
|
const { logoKey } = await uploadRes.json();
|
||||||
|
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
|
||||||
setMessage({ type: "success", text: "Logo uploaded." });
|
setMessage({ type: "success", text: "Logo uploaded." });
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -326,7 +292,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
|
|
||||||
if (!loaded) return <p>Loading settings...</p>;
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 600 }}>
|
||||||
@@ -393,7 +359,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
onChange={handleLogoChange}
|
onChange={handleLogoChange}
|
||||||
style={{ display: "none" }}
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
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 navigate = useNavigate();
|
||||||
const { refresh: refreshBranding } = useBranding();
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
|
||||||
// Fetch setup status to determine if auth provider step is needed
|
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||||
const [setupStatus, setSetupStatus] = useState(null); // null = loading
|
|
||||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||||
|
|
||||||
// Auth provider form state
|
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||||
const [authForm, setAuthForm] = useState({
|
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
issuerUrl: "",
|
issuerUrl: "",
|
||||||
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: "openid profile email",
|
scopes: "openid profile email",
|
||||||
});
|
});
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
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 [step, setStep] = useState(0);
|
||||||
const [businessName, setBusinessName] = useState("");
|
const [businessName, setBusinessName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/setup/status")
|
fetch("/api/setup/status")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json() as Promise<SetupStatus>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSetupStatus(data);
|
setSetupStatus(data);
|
||||||
setLoadingStatus(false);
|
setLoadingStatus(false);
|
||||||
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build steps dynamically based on setup status
|
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||||
const STEPS = setupStatus?.showAuthProviderStep
|
|
||||||
? [
|
? [
|
||||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
{ 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." },
|
{ 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 isFirst = step === 0;
|
||||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
|
||||||
// Determine if we can proceed - depends on which step we're on
|
|
||||||
const canGoNext = (() => {
|
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 === "business") return businessName.trim().length > 0;
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
return (
|
return (
|
||||||
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: authForm.scopes,
|
scopes: authForm.scopes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = (await res.json()) as TestResult;
|
||||||
setTestResult(data);
|
setTestResult(data);
|
||||||
} catch (e) {
|
} catch {
|
||||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnection(false);
|
setTestingConnection(false);
|
||||||
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (step === STEPS.length - 1) {
|
if (step === STEPS.length - 1) {
|
||||||
// Done - redirect to admin
|
|
||||||
navigate("/admin");
|
navigate("/admin");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit auth provider config
|
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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.");
|
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit business name and complete setup
|
|
||||||
if (current?.id === "business" && businessName.trim()) {
|
if (current?.id === "business" && businessName.trim()) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Setup failed. Please try again.");
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Refresh branding so the nav bar shows the new business name
|
|
||||||
refreshBranding();
|
refreshBranding();
|
||||||
// Clear needsSetup state in App so the redirect to /admin sticks
|
|
||||||
if (onSetupComplete) onSetupComplete();
|
if (onSetupComplete) onSetupComplete();
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.6rem 0.85rem",
|
padding: "0.6rem 0.85rem",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}>
|
}}>
|
||||||
{/* Progress dots */}
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||||
{STEPS.map((_, i) => (
|
{STEPS.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step indicator */}
|
|
||||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
Step {step + 1} of {STEPS.length}
|
Step {step + 1} of {STEPS.length}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
{current?.title}
|
{current?.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
{current?.description}
|
{current?.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step: Business name input */}
|
|
||||||
{current?.id === "business" && (
|
{current?.id === "business" && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Happy Paws Grooming"
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
value={businessName}
|
value={businessName}
|
||||||
onChange={(e) => setBusinessName(e.target.value)}
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Auth provider config form */}
|
|
||||||
{current?.id === "auth" && (
|
{current?.id === "auth" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||||
{/* Provider ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Provider ID
|
Provider ID
|
||||||
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Display Name
|
Display Name
|
||||||
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Issuer URL */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Issuer URL
|
Issuer URL
|
||||||
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Internal Base URL (optional) */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||||
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client ID
|
Client ID
|
||||||
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client Secret */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client Secret
|
Client Secret
|
||||||
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scopes */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Scopes
|
Scopes
|
||||||
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test Connection button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
onClick={() => { void handleTestConnection(); }}
|
||||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.45rem 0.85rem",
|
padding: "0.45rem 0.85rem",
|
||||||
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
{testingConnection ? "Testing..." : "Test Connection"}
|
{testingConnection ? "Testing..." : "Test Connection"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Test result */}
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Super user info */}
|
|
||||||
{current?.id === "superuser" && (
|
{current?.id === "superuser" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#f0fdf4",
|
background: "#f0fdf4",
|
||||||
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Second admin info */}
|
|
||||||
{current?.id === "admin" && (
|
{current?.id === "admin" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#fffbeb",
|
background: "#fffbeb",
|
||||||
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={() => { void handleNext(); }}
|
||||||
disabled={(!canGoNext && !isLast) || loading}
|
disabled={(!canGoNext && !isLast) || loading}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.55rem 1.25rem",
|
padding: "0.55rem 1.25rem",
|
||||||
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
|
|||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function CustomerPortal() {
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showReschedule, setShowReschedule] = 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 [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
const [clientName, setClientName] = useState<string>("");
|
const [clientName, setClientName] = useState<string>("");
|
||||||
@@ -149,7 +150,7 @@ export function CustomerPortal() {
|
|||||||
const handleReschedule = useCallback((appointmentId: string) => {
|
const handleReschedule = useCallback((appointmentId: string) => {
|
||||||
// Look up the full appointment from Dashboard's displayed data
|
// 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
|
// 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);
|
setShowReschedule(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ export function CustomerPortal() {
|
|||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
sessionId={session?.id ?? null}
|
sessionId={session?.id ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -325,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 min-h-screen">
|
<main className="flex-1 min-h-screen overflow-hidden">
|
||||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
@@ -339,7 +340,7 @@ export function CustomerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 md:p-8 max-w-6xl">
|
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
||||||
{renderSection()}
|
{renderSection()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Appointment {
|
export interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -379,7 +379,7 @@ export function ConfirmationSection({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -455,7 +455,7 @@ function CancelAppointmentButton({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -507,7 +507,7 @@ export function CustomerNotesSection({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -600,7 +600,7 @@ export function RescheduleFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
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`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${sessionId}`,
|
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
petId: selectedPet.id,
|
petId: selectedPet.id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||||
@@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const completeModalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paymentModalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Focus trap + Escape-to-close for both inline modals
|
||||||
|
useEffect(() => {
|
||||||
|
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
|
||||||
|
if (!modalRef) return;
|
||||||
|
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !modalRef) return;
|
||||||
|
const focusables = modalRef.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();
|
||||||
|
};
|
||||||
|
}, [isComplete, onClose]);
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
@@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
<div ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
<div ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ interface Appointment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AppointmentsResponse {
|
interface AppointmentsResponse {
|
||||||
upcoming: Appointment[];
|
appointments: Appointment[];
|
||||||
past: Appointment[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
|
|||||||
|
|
||||||
export function PetProfiles({ sessionId, readOnly }: Props) {
|
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
|
||||||
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||||
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
|
||||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||||
|
|
||||||
function handlePetSave(updatedPet: Pet) {
|
function handlePetSave(updatedPet: Pet) {
|
||||||
@@ -183,7 +182,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide">
|
||||||
{([
|
{([
|
||||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||||
{ id: "medical", label: "Medical", icon: Heart },
|
{ id: "medical", label: "Medical", icon: Heart },
|
||||||
|
|||||||
@@ -119,3 +119,10 @@ uri
|
|||||||
database-url
|
database-url
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Auth secret name — always use groombook-auth (sealed secret name)
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.authSecretName" -}}
|
||||||
|
{{- printf "%s" "groombook-auth" }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ spec:
|
|||||||
- name: OIDC_AUDIENCE
|
- name: OIDC_AUDIENCE
|
||||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.api.env.internalBaseUrl }}
|
||||||
|
- name: OIDC_INTERNAL_BASE
|
||||||
|
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: BETTER_AUTH_URL
|
||||||
|
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
||||||
|
- name: OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_ID
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_SECRET
|
||||||
|
- name: BETTER_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: BETTER_AUTH_SECRET
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ api:
|
|||||||
corsOrigin: ""
|
corsOrigin: ""
|
||||||
oidcIssuer: ""
|
oidcIssuer: ""
|
||||||
oidcAudience: groombook
|
oidcAudience: groombook
|
||||||
|
betterAuthUrl: ""
|
||||||
|
internalBaseUrl: ""
|
||||||
port: "3000"
|
port: "3000"
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# 10DLC Pilot Tenant Registration Runbook
|
||||||
|
|
||||||
|
Authored for GRO-106 Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Flight Checklist
|
||||||
|
|
||||||
|
Before starting Telnyx registration, collect the following:
|
||||||
|
|
||||||
|
| Item | Details |
|
||||||
|
|------|---------|
|
||||||
|
| Legal business name | Exact name on EIN / business registration |
|
||||||
|
| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX |
|
||||||
|
| Business type | Sole Proprietor / LLC / Corporation |
|
||||||
|
| Primary contact email | General contact address (postmaster@, info@, etc.) |
|
||||||
|
| Primary contact phone | Direct line for carrier verification |
|
||||||
|
| Website URL | Must be live and contain privacy policy |
|
||||||
|
| Sample message templates | See [Sample Templates](#sample-message-templates) below |
|
||||||
|
| Messaging use case | Customer Care / Account Notification |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Telnyx Account Requirements
|
||||||
|
|
||||||
|
- Active Telnyx account with billing configured.
|
||||||
|
- Role required: **Admin** or **Super User** to register brands and campaigns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Brand Registration
|
||||||
|
|
||||||
|
### Via Telnyx Console
|
||||||
|
|
||||||
|
1. Log in to [Telnyx Portal](https://portal.telnyx.com).
|
||||||
|
2. Navigate to **Messaging → A2P 10DLC → Brands**.
|
||||||
|
3. Click **Register Brand**.
|
||||||
|
4. Fill in:
|
||||||
|
- **Brand Name**: Legal business name
|
||||||
|
- **Legal Company Name**: Exact EIN name
|
||||||
|
- **Company Type**: Select from dropdown
|
||||||
|
- **EIN**: XX-XXXXXXX
|
||||||
|
- **Primary Contact**: Name, email, phone
|
||||||
|
- **Website**: Must be accessible
|
||||||
|
- **BusinessVertical**: Select appropriate vertical
|
||||||
|
5. Acknowledge the **Terms of Service**.
|
||||||
|
6. Submit.
|
||||||
|
|
||||||
|
### Via API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.telnyx.com/v2/10dlc/brands \
|
||||||
|
-H "Authorization: Bearer $TELNYX_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Your Legal Business Name",
|
||||||
|
"legal_company_name": "Your Legal Business Name",
|
||||||
|
"company_type": "llc",
|
||||||
|
"ein": "XX-XXXXXXX",
|
||||||
|
"primary_contact": {
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"email": "compliance@example.com",
|
||||||
|
"phone": "+1XXXXXXXXXX"
|
||||||
|
},
|
||||||
|
"website": "https://www.example.com",
|
||||||
|
"business_vertical": "PROFESSIONAL_SERVICES"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response fields to record:**
|
||||||
|
- `brand_id` — required for campaign registration
|
||||||
|
- `brand_score` — affects campaign vetting speed
|
||||||
|
|
||||||
|
### Expected Fees
|
||||||
|
|
||||||
|
| Fee Type | Amount |
|
||||||
|
|----------|--------|
|
||||||
|
| Brand registration fee | ~$0 (no direct fee from Telnyx) |
|
||||||
|
| Campaign registration fee | ~$15–$25 per campaign (Telnyx fee, subject to change) |
|
||||||
|
| Carrier fees | Passed through from T-Mobile/AT&T/Verizon |
|
||||||
|
|
||||||
|
### Expected Approval Window
|
||||||
|
|
||||||
|
- **Vetting by Telnyx**: 1–3 business days after submission.
|
||||||
|
- **Carrier (T-Mobile/AT&T/Verizon) review**: 2–5 business days after Telnyx approval.
|
||||||
|
- Total end-to-end: **3–8 business days**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Campaign Registration
|
||||||
|
|
||||||
|
### Use Case Selection
|
||||||
|
|
||||||
|
- **Primary**: Customer Care
|
||||||
|
- **Secondary**: Account Notification
|
||||||
|
|
||||||
|
### Via Telnyx Console
|
||||||
|
|
||||||
|
1. Navigate to **Messaging → A2P 10DLC → Campaigns**.
|
||||||
|
2. Click **Register Campaign**.
|
||||||
|
3. Select **Brand** (use the brand registered in Step 2).
|
||||||
|
4. Fill in:
|
||||||
|
- **Campaign Name**: e.g., `groombook-pilot-customer-care`
|
||||||
|
- **Use Case**: Customer Care / Account Notification
|
||||||
|
- **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below.
|
||||||
|
- **Description**: Brief description of messaging program
|
||||||
|
- **Estimated Volume**: Enter monthly estimate (e.g., 500)
|
||||||
|
5. Submit.
|
||||||
|
|
||||||
|
### Via API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \
|
||||||
|
-H "Authorization: Bearer $TELNYX_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"brand_id": "YOUR_BRAND_ID",
|
||||||
|
"name": "groombook-pilot-customer-care",
|
||||||
|
"use_case": "CUSTOMER_CARE",
|
||||||
|
"sample_messages": [
|
||||||
|
"Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.",
|
||||||
|
"Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}."
|
||||||
|
],
|
||||||
|
"description": "Appointment reminders and account notifications for grooming clients",
|
||||||
|
"estimated_monthly_volume": 500
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response fields to record:**
|
||||||
|
- `campaign_id` — required for messaging profile
|
||||||
|
- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval
|
||||||
|
|
||||||
|
### Campaign Vetting — STOP/HELP Language Requirements
|
||||||
|
|
||||||
|
Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service:
|
||||||
|
|
||||||
|
- **STOP**: Users can text `STOP` to opt out of all messages.
|
||||||
|
- **HELP**: Users can text `HELP` to receive contact information.
|
||||||
|
|
||||||
|
Example STOP/HELP block:
|
||||||
|
|
||||||
|
```
|
||||||
|
Text STOP to opt out. Text HELP for help. Msg & data rates may apply.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Messaging Profile + Phone Number Provisioning
|
||||||
|
|
||||||
|
### Create Messaging Profile
|
||||||
|
|
||||||
|
1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**.
|
||||||
|
2. Click **Create Messaging Profile**.
|
||||||
|
3. Name it (e.g., `groombook-pilot-prod`).
|
||||||
|
4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB.
|
||||||
|
|
||||||
|
### Provision a 10DLC Phone Number
|
||||||
|
|
||||||
|
1. Navigate to **Messaging → Phone Numbers**.
|
||||||
|
2. Search for a number in your desired area code.
|
||||||
|
3. Confirm the number is 10DLC-capable.
|
||||||
|
4. Purchase the number.
|
||||||
|
|
||||||
|
### Associate Number with Messaging Profile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Assign number to messaging profile
|
||||||
|
curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \
|
||||||
|
-H "Authorization: Bearer $TELNYX_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Record in Database
|
||||||
|
|
||||||
|
Once GRO-981 lands, record the following against the business record:
|
||||||
|
|
||||||
|
### SQL Path (when GRO-981 is complete)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE businesses
|
||||||
|
SET
|
||||||
|
messaging_phone_number = '+1XXXXXXXXXX',
|
||||||
|
telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID',
|
||||||
|
telnyx_brand_id = 'YOUR_BRAND_ID',
|
||||||
|
telnyx_campaign_id = 'YOUR_CAMPAIGN_ID',
|
||||||
|
telnyx_brand_status = 'APPROVED',
|
||||||
|
telnyx_campaign_status = 'ACTIVE',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = 'pilot_business_id';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Admin Path (before GRO-981)
|
||||||
|
|
||||||
|
Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `messagingPhoneNumber` | +1XXXXXXXXXX |
|
||||||
|
| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
|
||||||
|
| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
|
||||||
|
| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
|
||||||
|
| `brandStatus` | APPROVED / PENDING |
|
||||||
|
| `campaignStatus` | ACTIVE / PENDING |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sample Message Templates
|
||||||
|
|
||||||
|
These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic.
|
||||||
|
|
||||||
|
### Transactional Appointment Reminder
|
||||||
|
|
||||||
|
```
|
||||||
|
Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Staff Message
|
||||||
|
|
||||||
|
```
|
||||||
|
Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure Modes + Retry Guidance
|
||||||
|
|
||||||
|
### Vetting Rejection — Brand
|
||||||
|
|
||||||
|
| Rejection Reason | Common Fix |
|
||||||
|
|-----------------|------------|
|
||||||
|
| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly |
|
||||||
|
| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting |
|
||||||
|
| Incomplete primary contact | Provide direct phone and real email (no noreply) |
|
||||||
|
| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting |
|
||||||
|
|
||||||
|
### Campaign Rejection
|
||||||
|
|
||||||
|
| Rejection Reason | Common Fix |
|
||||||
|
|-----------------|------------|
|
||||||
|
| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends |
|
||||||
|
| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages |
|
||||||
|
| Volume estimate too low/high | Revise estimate to be realistic |
|
||||||
|
| Use case mismatch | Re-select use case that matches actual messaging |
|
||||||
|
|
||||||
|
### Re-submission
|
||||||
|
|
||||||
|
After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 24–48 hours).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Summary
|
||||||
|
|
||||||
|
### Telnyx Fees (as of 2026)
|
||||||
|
|
||||||
|
| Fee Type | Amount | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| 10DLC number (monthly) | ~$1.00–$2.50/number | Varies by type and area code |
|
||||||
|
| Outbound message | $0.005–$0.015/message | Depends on destination carrier |
|
||||||
|
| Inbound message | Included | No charge for received messages |
|
||||||
|
| Campaign registration | ~$15–$25 one-time | Per campaign, subject to change |
|
||||||
|
|
||||||
|
### Carrier Fees (T-Mobile / AT&T / Verizon)
|
||||||
|
|
||||||
|
| Carrier | Outbound Fee | Notes |
|
||||||
|
|---------|-------------|-------|
|
||||||
|
| T-Mobile | ~$0.005–$0.01/message | Varies by message size (segment) |
|
||||||
|
| AT&T | ~$0.005–$0.015/message | Varies by message size (segment) |
|
||||||
|
| Verizon | ~$0.005–$0.01/message | Varies by message size (segment) |
|
||||||
|
|
||||||
|
**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates.
|
||||||
|
|
||||||
|
### Example Monthly Cost (Pilot — 500 messages/month)
|
||||||
|
|
||||||
|
| Line Item | Cost |
|
||||||
|
|-----------|------|
|
||||||
|
| 1x 10DLC number | ~$2.00 |
|
||||||
|
| 500 outbound messages | ~$5.00–$7.50 |
|
||||||
|
| Carrier pass-through | ~$2.50–$7.50 |
|
||||||
|
| **Estimated Monthly Total** | **~$9.50–$17.00** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback / De-provisioning
|
||||||
|
|
||||||
|
If the pilot tenant must be de-provisioned:
|
||||||
|
|
||||||
|
1. Release the phone number: Telnyx Portal → Phone Numbers → Release.
|
||||||
|
2. Archive the campaign: set status to `INACTIVE` via API or console.
|
||||||
|
3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record.
|
||||||
|
4. Brand can remain registered (no harm) but will not be used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
|
||||||
|
| Resource | Contact |
|
||||||
|
|----------|---------|
|
||||||
|
| Telnyx Support | support@telnyx.com |
|
||||||
|
| Telnyx Dashboard | portal.telnyx.com |
|
||||||
|
| Internal Engineering | Raise issue in GRO-106 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Owner: Engineering · Last updated: 2026-05-04_
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# GroomBook Runbooks
|
||||||
|
|
||||||
|
Operational runbooks for GroomBook staff and operators.
|
||||||
|
|
||||||
|
| Runbook | Description | Status |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_To add a runbook, create a markdown file in this directory and update this table._
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Migration: 0030_messaging.sql
|
||||||
|
-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings
|
||||||
|
|
||||||
|
-- ─── Enums ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms');
|
||||||
|
CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound');
|
||||||
|
CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received');
|
||||||
|
CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help');
|
||||||
|
|
||||||
|
-- ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE "conversations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"business_id" uuid NOT NULL,
|
||||||
|
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
|
||||||
|
"channel" "messaging_channel" NOT NULL,
|
||||||
|
"external_number" text NOT NULL,
|
||||||
|
"business_number" text NOT NULL,
|
||||||
|
"last_message_at" timestamp,
|
||||||
|
"status" text NOT NULL DEFAULT 'active',
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC);
|
||||||
|
CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number");
|
||||||
|
|
||||||
|
CREATE TABLE "messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE,
|
||||||
|
"direction" "message_direction" NOT NULL,
|
||||||
|
"body" text,
|
||||||
|
"status" "message_status" NOT NULL DEFAULT 'queued',
|
||||||
|
"provider_message_id" text,
|
||||||
|
"error_code" text,
|
||||||
|
"error_message" text,
|
||||||
|
"sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now(),
|
||||||
|
"delivered_at" timestamp,
|
||||||
|
"read_by_client_at" timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC);
|
||||||
|
CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id");
|
||||||
|
|
||||||
|
CREATE TABLE "message_attachments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||||
|
"content_type" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"size" integer NOT NULL,
|
||||||
|
"provider_media_id" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id");
|
||||||
|
|
||||||
|
CREATE TABLE "message_consent_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
|
||||||
|
"business_id" uuid NOT NULL,
|
||||||
|
"kind" "message_consent_kind" NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id");
|
||||||
|
|
||||||
|
-- ─── Business Settings extensions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text;
|
||||||
|
ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text;
|
||||||
@@ -204,6 +204,20 @@
|
|||||||
"when": 1775741667192,
|
"when": 1775741667192,
|
||||||
"tag": "0028_sms_reminders",
|
"tag": "0028_sms_reminders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775784467192,
|
||||||
|
"tag": "0029_db_indexes_constraints",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775828067192,
|
||||||
|
"tag": "0030_messaging",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+167
-45
@@ -200,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appointments = pgTable("appointments", {
|
export const appointments = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"appointments",
|
||||||
clientId: uuid("client_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
.references(() => clients.id, { onDelete: "restrict" }),
|
clientId: uuid("client_id")
|
||||||
petId: uuid("pet_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => clients.id, { onDelete: "restrict" }),
|
||||||
.references(() => pets.id, { onDelete: "restrict" }),
|
petId: uuid("pet_id")
|
||||||
serviceId: uuid("service_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => pets.id, { onDelete: "restrict" }),
|
||||||
.references(() => services.id, { onDelete: "restrict" }),
|
serviceId: uuid("service_id")
|
||||||
staffId: uuid("staff_id").references(() => staff.id, {
|
.notNull()
|
||||||
onDelete: "set null",
|
.references(() => services.id, { onDelete: "restrict" }),
|
||||||
}),
|
staffId: uuid("staff_id").references(() => staff.id, {
|
||||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
onDelete: "set null",
|
||||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
}),
|
||||||
onDelete: "set null",
|
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||||
}),
|
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
onDelete: "set null",
|
||||||
startTime: timestamp("start_time").notNull(),
|
}),
|
||||||
endTime: timestamp("end_time").notNull(),
|
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||||
notes: text("notes"),
|
startTime: timestamp("start_time").notNull(),
|
||||||
// Override price at time of booking (null = use service base price)
|
endTime: timestamp("end_time").notNull(),
|
||||||
priceCents: integer("price_cents"),
|
notes: text("notes"),
|
||||||
// Recurring series support
|
// Override price at time of booking (null = use service base price)
|
||||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
priceCents: integer("price_cents"),
|
||||||
onDelete: "set null",
|
// Recurring series support
|
||||||
}),
|
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||||
seriesIndex: integer("series_index"),
|
onDelete: "set null",
|
||||||
// Multi-pet group booking: links this appointment to others in the same visit
|
}),
|
||||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
seriesIndex: integer("series_index"),
|
||||||
onDelete: "set null",
|
// Multi-pet group booking: links this appointment to others in the same visit
|
||||||
}),
|
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||||
// Customer confirmation/cancellation tracking
|
onDelete: "set null",
|
||||||
// Values: "pending" | "confirmed" | "cancelled"
|
}),
|
||||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
// Customer confirmation/cancellation tracking
|
||||||
confirmedAt: timestamp("confirmed_at"),
|
// Values: "pending" | "confirmed" | "cancelled"
|
||||||
cancelledAt: timestamp("cancelled_at"),
|
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||||
// Token for tokenized email confirm/cancel links (no auth required)
|
confirmedAt: timestamp("confirmed_at"),
|
||||||
confirmationToken: text("confirmation_token").unique(),
|
cancelledAt: timestamp("cancelled_at"),
|
||||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
// Token for tokenized email confirm/cancel links (no auth required)
|
||||||
customerNotes: text("customer_notes"),
|
confirmationToken: text("confirmation_token").unique(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
customerNotes: text("customer_notes"),
|
||||||
});
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_appointments_client_id").on(t.clientId),
|
||||||
|
index("idx_appointments_staff_id").on(t.staffId),
|
||||||
|
index("idx_appointments_start_time").on(t.startTime),
|
||||||
|
index("idx_appointments_status").on(t.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const invoices = pgTable(
|
export const invoices = pgTable(
|
||||||
"invoices",
|
"invoices",
|
||||||
@@ -397,6 +406,117 @@ export const impersonationAuditLogs = pgTable(
|
|||||||
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
|
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Messaging ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]);
|
||||||
|
|
||||||
|
export const messageDirectionEnum = pgEnum("message_direction", [
|
||||||
|
"inbound",
|
||||||
|
"outbound",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const messageStatusEnum = pgEnum("message_status", [
|
||||||
|
"queued",
|
||||||
|
"sent",
|
||||||
|
"delivered",
|
||||||
|
"failed",
|
||||||
|
"received",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const messageConsentKindEnum = pgEnum("message_consent_kind", [
|
||||||
|
"opt_in",
|
||||||
|
"opt_out",
|
||||||
|
"help",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const conversations = pgTable(
|
||||||
|
"conversations",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
businessId: uuid("business_id").notNull(),
|
||||||
|
clientId: uuid("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
channel: messagingChannelEnum("channel").notNull(),
|
||||||
|
externalNumber: text("external_number").notNull(),
|
||||||
|
businessNumber: text("business_number").notNull(),
|
||||||
|
lastMessageAt: timestamp("last_message_at"),
|
||||||
|
status: text("status").notNull().default("active"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_conversations_business_id_last_message_at").on(
|
||||||
|
t.businessId,
|
||||||
|
t.lastMessageAt.desc()
|
||||||
|
),
|
||||||
|
unique("uq_conversations_business_client_number").on(
|
||||||
|
t.businessId,
|
||||||
|
t.clientId,
|
||||||
|
t.businessNumber
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messages = pgTable(
|
||||||
|
"messages",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
conversationId: uuid("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||||
|
direction: messageDirectionEnum("direction").notNull(),
|
||||||
|
body: text("body"),
|
||||||
|
status: messageStatusEnum("status").notNull().default("queued"),
|
||||||
|
providerMessageId: text("provider_message_id"),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
deliveredAt: timestamp("delivered_at"),
|
||||||
|
readByClientAt: timestamp("read_by_client_at"),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_messages_conversation_id_created_at").on(
|
||||||
|
t.conversationId,
|
||||||
|
t.createdAt.desc()
|
||||||
|
),
|
||||||
|
unique("uq_messages_provider_message_id").on(t.providerMessageId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messageAttachments = pgTable(
|
||||||
|
"message_attachments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
messageId: uuid("message_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => messages.id, { onDelete: "cascade" }),
|
||||||
|
contentType: text("content_type").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
size: integer("size").notNull(),
|
||||||
|
providerMediaId: text("provider_media_id"),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_message_attachments_message_id").on(t.messageId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messageConsentEvents = pgTable(
|
||||||
|
"message_consent_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
clientId: uuid("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
businessId: uuid("business_id").notNull(),
|
||||||
|
kind: messageConsentKindEnum("kind").notNull(),
|
||||||
|
source: text("source"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_message_consent_events_client_id").on(t.clientId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const businessSettings = pgTable("business_settings", {
|
export const businessSettings = pgTable("business_settings", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
businessName: text("business_name").notNull().default("GroomBook"),
|
businessName: text("business_name").notNull().default("GroomBook"),
|
||||||
@@ -405,6 +525,8 @@ export const businessSettings = pgTable("business_settings", {
|
|||||||
logoKey: text("logo_key"),
|
logoKey: text("logo_key"),
|
||||||
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
||||||
accentColor: text("accent_color").notNull().default("#8b7355"),
|
accentColor: text("accent_color").notNull().default("#8b7355"),
|
||||||
|
messagingPhoneNumber: text("messaging_phone_number"),
|
||||||
|
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -399,7 +399,6 @@ async function seedKnownUsers() {
|
|||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
oidcSub: adminEmail,
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -426,7 +425,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Super User",
|
name: "UAT Super User",
|
||||||
email: "uat-super@groombook.dev",
|
email: "uat-super@groombook.dev",
|
||||||
oidcSub: uatSuperOidcSub,
|
oidcSub: uatSuperOidcSub,
|
||||||
userId: uatSuperOidcSub,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -453,7 +451,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Staff Groomer",
|
name: "UAT Staff Groomer",
|
||||||
email: "uat-groomer@groombook.dev",
|
email: "uat-groomer@groombook.dev",
|
||||||
oidcSub: uatStaffOidcSub,
|
oidcSub: uatStaffOidcSub,
|
||||||
userId: uatStaffOidcSub,
|
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -648,7 +645,6 @@ async function seed() {
|
|||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
oidcSub: adminEmail,
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -982,6 +978,7 @@ async function seed() {
|
|||||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
|
|
||||||
|
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
appointmentId: apptId,
|
appointmentId: apptId,
|
||||||
@@ -993,6 +990,7 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
stripePaymentIntentId,
|
||||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1096,13 +1094,14 @@ async function seed() {
|
|||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||||
|
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId, appointmentId: apptId, clientId,
|
id: invoiceId, appointmentId: apptId, clientId,
|
||||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||||
status: "paid" as const,
|
status: "paid" as const,
|
||||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||||
paidAt, notes: null,
|
paidAt, stripePaymentIntentId, notes: null,
|
||||||
});
|
});
|
||||||
lineItemBatch.push({
|
lineItemBatch.push({
|
||||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface Pet {
|
|||||||
shampooPreference: string | null;
|
shampooPreference: string | null;
|
||||||
specialCareNotes: string | null;
|
specialCareNotes: string | null;
|
||||||
customFields: Record<string, string>;
|
customFields: Record<string, string>;
|
||||||
|
photoKey?: string;
|
||||||
|
photoUploadedAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -150,10 +152,16 @@ export interface Invoice {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
paymentMethod: PaymentMethod | null;
|
paymentMethod: PaymentMethod | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
|
stripePaymentIntentId: string | null;
|
||||||
|
stripeRefundId: string | null;
|
||||||
|
paymentFailureReason: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
|
// Transient fields populated from Stripe API (not stored in DB)
|
||||||
|
cardLast4?: string | null;
|
||||||
|
paymentStatus?: string | null;
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+19
@@ -46,6 +46,9 @@ importers:
|
|||||||
telnyx:
|
telnyx:
|
||||||
specifier: ^1.23.0
|
specifier: ^1.23.0
|
||||||
version: 1.27.0
|
version: 1.27.0
|
||||||
|
uuid:
|
||||||
|
specifier: ^11.1.1
|
||||||
|
version: 11.1.1
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -59,6 +62,9 @@ importers:
|
|||||||
'@types/nodemailer':
|
'@types/nodemailer':
|
||||||
specifier: ^6.4.17
|
specifier: ^6.4.17
|
||||||
version: 6.4.23
|
version: 6.4.23
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
@@ -2334,6 +2340,9 @@ packages:
|
|||||||
'@types/use-sync-external-store@0.0.6':
|
'@types/use-sync-external-store@0.0.6':
|
||||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
|
'@types/uuid@10.0.0':
|
||||||
|
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.57.1':
|
'@typescript-eslint/eslint-plugin@8.57.1':
|
||||||
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -4344,12 +4353,18 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
uuid@11.1.1:
|
||||||
|
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
uuid@8.3.2:
|
uuid@8.3.2:
|
||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@9.0.1:
|
uuid@9.0.1:
|
||||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
@@ -6910,6 +6925,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/use-sync-external-store@0.0.6': {}
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
|
'@types/uuid@10.0.0': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -9014,6 +9031,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
uuid@11.1.1: {}
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
uuid@9.0.1: {}
|
uuid@9.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user