Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Farhood a97ad11b8b docs(GRO-106): 10DLC pilot registration runbook
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 01:37:54 +00:00
8 changed files with 325 additions and 814 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ jobs:
name: Update Infra Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
-63
View File
@@ -1,63 +0,0 @@
# GroomBook API — UAT Playbook
This document captures user-acceptance test cases for GroomBook API features. Each section corresponds to a feature or bug-fix PR. Update this file when a PR changes user-facing behaviour.
---
## 1. Appointment Booking (`/api/appointments`)
### 1.1 Create Appointment
- [ ] POST `/api/appointments` with valid payload → 201, appointment returned with generated id
- [ ] Overlapping staff appointment → 409 conflict error returned
- [ ] `endTime` before `startTime` → 422 error
### 1.2 Update Appointment (PATCH `/api/appointments/:id`)
- [ ] Extending `endTime` on a `scheduled` appointment triggers cascade delay prevention if downstream appointments exist
- [ ] Extending `endTime` returns `cascade` object in response with shifted appointments
- [ ] Extending `endTime` sends reschedule email to each affected client
- [ ] Appointments outside business hours after shift are flagged in `cascade.flaggedForReview` instead of auto-shifted
- [ ] Only `scheduled` and `confirmed` downstream appointments are shifted; `in_progress`, `completed`, `cancelled` are skipped
- [ ] Cascade stops when a downstream appointment no longer conflicts with the shifted boundary
- [ ] Shifts are included in API response under `cascade.shifted[]`
### 1.3 Series (Recurring) Appointments
- [ ] Updating one occurrence with `cascadeMode: "this_and_future"` shifts that occurrence and all future ones
- [ ] Updating one occurrence with `cascadeMode: "all"` shifts every occurrence in the series
---
## 2. Cascade Delay Prevention
### 2.1 Basic Cascade
- [ ] When a groomer's appointment overruns, the next same-groomer `scheduled` appointment shifts forward
- [ ] Delta applied to both `startTime` and `endTime` (duration preserved)
- [ ] Cascade propagates through multiple downstream appointments
### 2.2 Buffer Time
- [ ] A configurable buffer (default 15 minutes) is added between the overrunning appointment end and the shifted start
- [ ] Cascade respects the buffer between each consecutive pair of appointments
### 2.3 Business Hours Guard
- [ ] If a proposed shift would place an appointment start or end outside business hours, it is flagged instead of shifted
- [ ] Flagged appointments are listed in `cascade.flaggedForReview[]` with reason text
### 2.4 Email Notification
- [ ] Each shifted appointment triggers a reschedule email to the client
- [ ] Email includes original time (struck through) and new time
- [ ] Email is skipped silently if SMTP is not configured
### 2.5 Status Transition Overrun
- [ ] When an `in_progress` appointment's actual end time exceeds `endTime + bufferMinutes`, the cascade is triggered using the status transition path
---
## 3. Authentication & RBAC
### 3.1 Staff Authentication
- [ ] Unauthenticated request → 401
- [ ] Groomer role can only view/edit their own appointments → 403 for others
- [ ] Manager role can view/edit all appointments
### 3.2 Client Authentication
- [ ] Clients can access their own appointments via tokenized links
- [ ] Tokenized confirm/cancel links work without authentication
-373
View File
@@ -1,373 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cascadeDelay } from "../cascade.js";
// ─── Mock the DB ───────────────────────────────────────────────────────────────
const mockDb = {
select: vi.fn(),
update: vi.fn(),
};
vi.mock("@groombook/db", () => ({
getDb: () => mockDb,
appointments: {
id: Symbol("id"),
staffId: Symbol("staffId"),
startTime: Symbol("startTime"),
endTime: Symbol("endTime"),
status: Symbol("status"),
},
clients: { id: Symbol("id"), name: Symbol("name"), email: Symbol("email") },
pets: { id: Symbol("id"), name: Symbol("name") },
services: { id: Symbol("id"), name: Symbol("name") },
staff: { id: Symbol("id"), name: Symbol("name") },
eq: (a: symbol, b: unknown) => ({ type: "eq", a, b }),
and: (...args: unknown[]) => ({ type: "and", args }),
gt: (a: symbol, b: unknown) => ({ type: "gt", a, b }),
inArray: (a: symbol, vals: unknown[]) => ({ type: "inArray", a, vals }),
asc: (a: symbol) => ({ type: "asc", a }),
}));
vi.mock("../services/email.js", () => ({
sendEmail: vi.fn().mockResolvedValue(true),
}));
const { sendEmail } = await import("../services/email.js");
const { getDb } = await import("@groombook/db");
// ─── Helpers ────────────────────────────────────────────────────────────────────
function makeAppt(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "appt-1",
staffId: "groomer-1",
startTime: new Date("2026-05-16T10:00:00Z"),
endTime: new Date("2026-05-16T11:00:00Z"),
status: "scheduled",
clientId: "client-1",
petId: "pet-1",
serviceId: "svc-1",
...overrides,
};
}
function makeEnrichedAppt(id: string, start: Date, end: Date) {
return {
id,
originalStartTime: start,
originalEndTime: end,
newStartTime: start,
newEndTime: end,
clientId: "client-1",
clientName: "Alice Smith",
clientEmail: "alice@example.com",
petName: "Buddy",
serviceName: "Full Groom",
groomerName: "Jamie",
};
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("cascadeDelay", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns early when the triggering appointment is not found", async () => {
mockDb.select.mockResolvedValueOnce([]);
const result = await cascadeDelay(
"nonexistent",
new Date("2026-05-16T12:00:00Z"),
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
expect(result.flaggedForReview).toHaveLength(0);
});
it("returns early when the appointment has no groomer assigned", async () => {
mockDb.select.mockResolvedValueOnce([{ ...makeAppt(), staffId: null }]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T12:00:00Z"),
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
});
it("returns early when newEndTime does not extend beyond originalEndTime", async () => {
mockDb.select.mockResolvedValueOnce([makeAppt()]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"), // earlier than original 11:00
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
});
it("returns early when there are no downstream appointments", async () => {
mockDb.select
.mockResolvedValueOnce([makeAppt()]) // triggering appt
.mockResolvedValueOnce([]); // no downstream
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
});
it("shifts a single downstream appointment by the correct delta", async () => {
const triggerEnd = new Date("2026-05-16T11:30:00Z"); // 30 min overrun
const originalEnd = new Date("2026-05-16T11:00:00Z");
const downstreamStart = new Date("2026-05-16T11:00:00Z");
const downstreamEnd = new Date("2026-05-16T12:00:00Z");
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({
id: "downstream-1",
startTime: downstreamStart,
endTime: downstreamEnd,
status: "scheduled",
}),
]);
const updateMock = mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "client-1",
clientName: "Alice",
clientEmail: "alice@example.com",
petName: "Buddy",
serviceName: "Full Groom",
groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
triggerEnd,
originalEnd,
15 // 15 min buffer
);
// effectiveBoundary = 11:30 + 15min = 11:45
// delta = 11:45 - 11:00 = 45 min = 2_700_000 ms
const expectedDeltaMs = 45 * 60 * 1000;
expect(result.shifted).toHaveLength(1);
expect(result.shifted[0].id).toBe("downstream-1");
expect(result.shifted[0].newStartTime.getTime() - result.shifted[0].originalStartTime.getTime())
.toBe(expectedDeltaMs);
expect(sendEmail).toHaveBeenCalledTimes(1);
});
it("cascades shifts through a chain of appointments", async () => {
const triggerEnd = new Date("2026-05-16T12:00:00Z"); // 60 min overrun
const originalEnd = new Date("2026-05-16T11:00:00Z");
// Three downstream appointments, each 1 hour
const appt1Start = new Date("2026-05-16T11:00:00Z");
const appt1End = new Date("2026-05-16T12:00:00Z");
const appt2Start = new Date("2026-05-16T12:00:00Z");
const appt2End = new Date("2026-05-16T13:00:00Z");
const appt3Start = new Date("2026-05-16T13:00:00Z");
const appt3End = new Date("2026-05-16T14:00:00Z");
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({ id: "appt-2", startTime: appt2Start, endTime: appt2End, status: "confirmed" }),
makeAppt({ id: "appt-3", startTime: appt3Start, endTime: appt3End, status: "scheduled" }),
]);
mockDb.update.mockReturnValueThis();
// Two enrich queries for the two shifted appointments
mockDb.select
.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
])
.mockResolvedValueOnce([
{
clientId: "c2", clientName: "Bob", clientEmail: "bob@test.com",
petName: "Max", serviceName: "Bath", groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
triggerEnd,
originalEnd,
15
);
// effectiveBoundary starts at 12:00 + 15 = 12:15
// appt-2: 12:00 start conflicts with 12:15 boundary → shift by 15 min → starts 12:15, ends 13:15
// new boundary: 13:15 + 15 = 13:30
// appt-3: 13:00 start conflicts with 13:30 boundary → shift by 30 min → starts 13:30, ends 14:30
expect(result.shifted).toHaveLength(2);
expect(result.shifted[0].id).toBe("appt-2");
expect(result.shifted[1].id).toBe("appt-3");
expect(mockDb.update).toHaveBeenCalledTimes(2);
expect(sendEmail).toHaveBeenCalledTimes(2);
});
it("flags but still updates boundary when shift would fall outside business hours", async () => {
const triggerEnd = new Date("2026-05-16T17:00:00Z");
const originalEnd = new Date("2026-05-16T16:00:00Z");
// Downstream appt starts at 16:00, business ends at 18:00
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({
id: "appt-late",
startTime: new Date("2026-05-16T16:00:00Z"),
endTime: new Date("2026-05-16T17:00:00Z"),
status: "scheduled",
}),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
]);
// Business hours 08:0018:00; proposed shift pushes to 17:15 start (still in hours)
// Try a late-night boundary: shift would push to 19:15 (outside 08:0018:00)
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T18:00:00Z"), // larger overrun
originalEnd,
15,
8, // business start
18 // business end — proposed 18:15 start is outside
);
// The appointment at 16:00 with buffer of 15 min after 18:00 trigger:
// effectiveBoundary = 18:00 + 15 = 18:15 → outside business hours (18:15 > 18:00)
expect(result.flaggedForReview).toHaveLength(1);
expect(result.flaggedForReview[0].id).toBe("appt-late");
expect(result.flaggedForReview[0].reason).toContain("Manual review required");
// The appointment was NOT shifted (only flagged)
expect(mockDb.update).not.toHaveBeenCalled();
});
it("skips non-active appointments", async () => {
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({ id: "in-progress-1", status: "in_progress" }),
makeAppt({ id: "cancelled-1", status: "cancelled" }),
makeAppt({ id: "scheduled-1", status: "scheduled" }),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z"),
15
);
// Only the scheduled appointment should be shifted
expect(result.shifted).toHaveLength(1);
expect(result.shifted[0].id).toBe("scheduled-1");
});
it("stops cascading when an appointment no longer conflicts", async () => {
// Three downstream: appt-2 overlaps, appt-3 does NOT overlap, appt-4 overlaps
// Cascade should stop at appt-3
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
// appt-2: starts at 11:00, ends 12:00 — overlaps boundary 11:45
makeAppt({ id: "appt-2", startTime: new Date("2026-05-16T11:00:00Z"), endTime: new Date("2026-05-16T12:00:00Z") }),
// appt-3: starts at 13:00 — already clear of shifted appt-2 (ends 12:15 + buffer)
makeAppt({ id: "appt-3", startTime: new Date("2026-05-16T13:00:00Z"), endTime: new Date("2026-05-16T14:00:00Z") }),
makeAppt({ id: "appt-4", startTime: new Date("2026-05-16T14:00:00Z"), endTime: new Date("2026-05-16T15:00:00Z") }),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z"),
15
);
// Only appt-2 was shifted (appt-3 no longer conflicts after the stop condition check)
expect(result.shifted).toHaveLength(1);
expect(result.shifted[0].id).toBe("appt-2");
});
it("sends email notification for each shifted appointment", async () => {
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({
id: "appt-email-test",
startTime: new Date("2026-05-16T11:00:00Z"),
endTime: new Date("2026-05-16T12:00:00Z"),
status: "confirmed",
}),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1",
clientName: "Carol",
clientEmail: "carol@example.com",
petName: "Luna",
serviceName: "Nail Trim",
groomerName: null,
},
]);
await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z"),
15
);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "carol@example.com",
subject: expect.stringContaining("Rescheduled"),
})
);
});
});
-341
View File
@@ -1,341 +0,0 @@
/**
* Cascade delay prevention — `apps/api/src/lib/cascade.ts`
*
* Triggered after a PATCH /appointments/:id call extends an appointment's
* endTime beyond its original value. Queries same-groomer downstream
* appointments, shifts them forward by (overrunEnd + buffer downstreamStart),
* and cascades the shift through the chain. Clients are notified by email.
*
* Guard rails:
* - Only shifts `scheduled` and `confirmed` appointments.
* - Flags out-of-business-hours shifts for manual review instead of auto-shifting.
* - Returns the full list of shifted appointments.
*/
import { eq, and, gt, lte, asc, ne, inArray } from "drizzle-orm";
import { getDb, appointments, clients, pets, services, staff } from "@groombook/db";
import { sendEmail } from "../services/email.js";
// ─── Types ──────────────────────────────────────────────────────────────────────
export interface CascadeResult {
shifted: ShiftedAppointment[];
flaggedForReview: FlaggedAppointment[];
/** Time in ms each downstream appointment was pushed forward */
cascadeLog: CascadeLogEntry[];
}
export interface ShiftedAppointment {
id: string;
originalStartTime: Date;
originalEndTime: Date;
newStartTime: Date;
newEndTime: Date;
clientId: string;
clientName: string;
clientEmail: string;
petName: string;
serviceName: string;
groomerName: string | null;
}
export interface FlaggedAppointment {
id: string;
originalStartTime: Date;
proposedStartTime: Date;
proposedEndTime: Date;
reason: string;
}
export interface CascadeLogEntry {
appointmentId: string;
deltaMs: number;
triggeredBy: string;
}
// ─── Config ───────────────────────────────────────────────────────────────────
/** Default inter-appointment buffer in minutes. Overridden by services.bufferMinutes. */
export const DEFAULT_BUFFER_MINUTES = 15;
/** Default business hours (used when no settings row exists). */
export const DEFAULT_BUSINESS_START_HOUR = 8; // 08:00
export const DEFAULT_BUSINESS_END_HOUR = 18; // 18:00
// ─── Core cascade ───────────────────────────────────────────────────────────────
/**
* Detect and cascade appointment overruns.
*
* @param triggeringAppointmentId The appointment that just overran.
* @param newEndTime The updated endTime set by the caller.
* @param originalEndTime The appointment's endTime before the update.
* @param bufferMinutes Minutes of buffer between appointments (default 15).
* @param businessStartHour Business opening hour (023, default 8).
* @param businessEndHour Business closing hour (023, default 18).
*/
export async function cascadeDelay(
triggeringAppointmentId: string,
newEndTime: Date,
originalEndTime: Date,
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
): Promise<CascadeResult> {
const db = getDb();
const bufferMs = bufferMinutes * 60_000;
const overrunEnd = newEndTime;
// ── 1. Load the triggering appointment ────────────────────────────────────────
const [triggering] = await db
.select()
.from(appointments)
.where(eq(appointments.id, triggeringAppointmentId))
.limit(1);
if (!triggering) {
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
if (!triggering.staffId) {
// Unassigned appointments cannot cascade
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
const groomerId = triggering.staffId;
// ── 2. Guard: only trigger when endTime actually extended ──────────────────────
if (overrunEnd <= originalEndTime) {
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
const result: CascadeResult = { shifted: [], flaggedForReview: [], cascadeLog: [] };
// ── 3. Fetch all downstream same-groomer active appointments ──────────────────
const downstream = await db
.select()
.from(appointments)
.where(
and(
eq(appointments.staffId, groomerId),
gt(appointments.startTime, originalEndTime),
inArray(appointments.status, ["scheduled", "confirmed"]),
)
)
.orderBy(asc(appointments.startTime));
if (downstream.length === 0) return result;
// ── 4. Cascade loop ────────────────────────────────────────────────────────────
// Keep track of current effective boundary after each shift.
// Start from the new endTime of the triggering appointment plus buffer.
let effectiveBoundary = new Date(overrunEnd.getTime() + bufferMs);
for (const appt of downstream) {
const conflictStart = appt.startTime;
const conflictEnd = appt.endTime;
const apptDurationMs = conflictEnd.getTime() - conflictStart.getTime();
// Does this appointment overlap the effective boundary?
if (effectiveBoundary.getTime() >= conflictEnd.getTime()) {
// No conflict — this appointment and all later ones are unaffected
break;
}
const proposedStart = new Date(effectiveBoundary);
const proposedEnd = new Date(proposedStart.getTime() + apptDurationMs);
// ── Business-hours guard ────────────────────────────────────────────────────
const proposedStartHour = proposedStart.getHours() + proposedStart.getMinutes() / 60;
const proposedEndHour = proposedEnd.getHours() + proposedEnd.getMinutes() / 60;
const outOfHours =
proposedStartHour < businessStartHour ||
proposedEndHour > businessEndHour;
if (outOfHours) {
result.flaggedForReview.push({
id: appt.id,
originalStartTime: appt.startTime,
proposedStartTime: proposedStart,
proposedEndTime: proposedEnd,
reason:
`Would push appointment outside business hours ` +
`(${businessStartHour}:00${businessEndHour}:00). ` +
`Manual review required.`,
});
// Update boundary anyway — later appointments may still conflict
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
continue;
}
// ── Perform the shift ──────────────────────────────────────────────────────
const deltaMs = proposedStart.getTime() - appt.startTime.getTime();
await db
.update(appointments)
.set({ startTime: proposedStart, endTime: proposedEnd, updatedAt: new Date() })
.where(eq(appointments.id, appt.id));
result.cascadeLog.push({
appointmentId: appt.id,
deltaMs,
triggeredBy: triggeringAppointmentId,
});
// ── Load client/pet/service info for notification ──────────────────────────
const enriched = await enrichAppointment(appt.id);
if (enriched) {
result.shifted.push({
id: appt.id,
originalStartTime: appt.startTime,
originalEndTime: appt.endTime,
newStartTime: proposedStart,
newEndTime: proposedEnd,
...enriched,
});
}
// Advance boundary to the end of this shifted appointment plus buffer
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
}
// ── 5. Send notifications ────────────────────────────────────────────────────
for (const shifted of result.shifted) {
await sendRescheduleNotification(shifted).catch((err) =>
console.error(`[cascade] Failed to send notification for ${shifted.id}:`, err)
);
}
return result;
}
/**
* Shortcut for status-transition overruns (current time > endTime + bufferMinutes).
* Delegates to `cascadeDelay` using the current appointment data.
*/
export async function cascadeOnStatusOverrun(
appointmentId: string,
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
): Promise<CascadeResult> {
const db = getDb();
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, appointmentId))
.limit(1);
if (!appt) return { shifted: [], flaggedForReview: [], cascadeLog: [] };
const now = new Date();
const bufferMs = bufferMinutes * 60_000;
if (now.getTime() <= appt.endTime.getTime() + bufferMs) {
// Not actually in overrun
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
// Use current time as the new endTime (the appointment is already running over)
return cascadeDelay(
appointmentId,
now,
appt.endTime,
bufferMinutes,
businessStartHour,
businessEndHour
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
interface EnrichedFields {
clientId: string;
clientName: string;
clientEmail: string;
petName: string;
serviceName: string;
groomerName: string | null;
}
async function enrichAppointment(
apptId: string
): Promise<EnrichedFields | null> {
const db = getDb();
const [row] = await db
.select({
clientId: appointments.clientId,
clientName: clients.name,
clientEmail: clients.email,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, apptId))
.limit(1);
if (!row) return null;
return {
clientId: row.clientId,
clientName: row.clientName,
clientEmail: row.clientEmail,
petName: row.petName,
serviceName: row.serviceName,
groomerName: row.groomerName,
};
}
async function sendRescheduleNotification(
shifted: ShiftedAppointment
): Promise<void> {
const time = formatDateTime(shifted.newStartTime);
const original = formatDateTime(shifted.originalStartTime);
const groomer = shifted.groomerName ? ` with ${shifted.groomerName}` : "";
await sendEmail({
to: shifted.clientEmail,
subject: `Appointment Rescheduled — ${shifted.petName}`,
text: [
`Hi ${shifted.clientName},`,
``,
`Your appointment for ${shifted.petName} has been rescheduled.`,
``,
` Was: ${original}${groomer}`,
` Now: ${time}${groomer}`,
``,
`We apologize for any inconvenience. If this new time doesn't work for you, please contact us as soon as possible.`,
``,
`— Groom Book`,
].join("\n"),
html: `<p>Hi ${shifted.clientName},</p>
<p>Your appointment for <strong>${shifted.petName}</strong> has been rescheduled.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Previous time</td><td style="text-decoration:line-through;color:#9ca3af">${original}${groomer}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">New time</td><td>${time}${groomer}</td></tr>
</table>
<p>If this new time doesn't work for you, please contact us as soon as possible.</p>
<p>— Groom Book</p>`,
});
console.info(
`[cascade] Notified ${shifted.clientEmail} of reschedule for ${shifted.petName} ` +
`(${shifted.id}): ${original}${time}`
);
}
function formatDateTime(d: Date): string {
return d.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
+1 -27
View File
@@ -20,7 +20,6 @@ import {
staff,
} from "@groombook/db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { cascadeDelay } from "../lib/cascade.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
@@ -581,15 +580,6 @@ appointmentsRouter.patch(
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
if (needsConflictCheck) {
// Capture original endTime before the transaction so we can detect an
// overrun and trigger cascade delay prevention after the update.
const [preUpdate] = await db
.select({ originalEndTime: appointments.endTime })
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
const originalEndTime = preUpdate?.originalEndTime ?? null;
// Wrap conflict check + update in a transaction to prevent race conditions
// (fixes #18). Also falls back to the existing staffId when staffId is
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
@@ -694,23 +684,7 @@ appointmentsRouter.patch(
}
if (!row) return c.json({ error: "Not found" }, 404);
// Cascade delay prevention: detect if endTime was extended and cascade
// downstream appointments if so. Runs after the main update commits.
const cascadeResult =
updateFields.endTime &&
originalEndTime &&
new Date(updateFields.endTime) > originalEndTime
? await cascadeDelay(id, new Date(updateFields.endTime), originalEndTime)
: { shifted: [], flaggedForReview: [], cascadeLog: [] };
return c.json({
...row,
cascade:
cascadeResult.shifted.length > 0 || cascadeResult.flaggedForReview.length > 0
? cascadeResult
: undefined,
});
return c.json(row);
}
const [row] = await db
+309
View File
@@ -0,0 +1,309 @@
# 10DLC Pilot Tenant Registration Runbook
Authored for [GRO-106](/GRO/issues/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": "+13125551000"
},
"website": "https://www.example.com",
"business_vertical": "FINANCE_INSURANCE_BANKING"
}'
```
**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**: 13 business days after submission.
- **Carrier (T-Mobile/AT&T/Verizon) review**: 25 business days after Telnyx approval.
- Total end-to-end: **38 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](/GRO/issues/GRO-981) lands, record the following against the business record:
### SQL Path (when GRO-981 is complete)
```sql
UPDATE businesses
SET
messaging_phone_number = '+13125551000',
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 2448 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](/GRO/issues/GRO-106) |
---
_Last updated: 2026-05-04_
+11
View File
@@ -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._
+3 -9
View File
@@ -883,7 +883,6 @@ async function seed() {
let appointmentCount = 0;
let invoiceCount = 0;
let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable
const apptBatchSize = 100;
@@ -978,11 +977,8 @@ async function seed() {
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;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
invoiceBatch.push({
id: invoiceId,
appointmentId: apptId,
@@ -1098,16 +1094,14 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
paidInvoiceCounter++;
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
paidAt, stripePaymentIntentId, notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,