Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2e13863e | |||
| 5024cc4896 |
@@ -1,90 +0,0 @@
|
||||
# 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.
|
||||
@@ -23,7 +23,6 @@
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
"telnyx": "^1.23.0",
|
||||
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -195,11 +195,10 @@ describe("POST /clients", () => {
|
||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||
});
|
||||
|
||||
it("creates a client with name and email", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||
it("creates a client with only required name field", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]!.name).toBe("Dana");
|
||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||
});
|
||||
|
||||
it("rejects empty name", async () => {
|
||||
|
||||
@@ -204,11 +204,15 @@ export async function initAuth(): Promise<void> {
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const authzUrlObj = new URL(authzUrl);
|
||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
||||
if (authzUrlObj.hostname !== issuerHostname) {
|
||||
const tokenUrlObj = new URL(tokenUrl);
|
||||
const userInfoUrlObj = new URL(userInfoUrl);
|
||||
if (
|
||||
authzUrlObj.hostname !== issuerHostname ||
|
||||
tokenUrlObj.hostname !== issuerHostname ||
|
||||
userInfoUrlObj.hostname !== issuerHostname
|
||||
) {
|
||||
throw new Error(
|
||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||
);
|
||||
}
|
||||
oidcConfig = {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
* Server-side audit logging middleware for portal routes.
|
||||
* Applied after validatePortalSession in the middleware chain.
|
||||
*
|
||||
* After the route handler completes (await next()), inserts an audit log entry
|
||||
* into impersonationAuditLogs:
|
||||
* - sessionId: from c.get("portalSessionId")
|
||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||
* - pageVisited: c.req.path
|
||||
* - metadata: { method, statusCode: c.res.status }
|
||||
*
|
||||
* Log entries are written for both success and error responses.
|
||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||
* request is not affected.
|
||||
*/
|
||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
await next();
|
||||
|
||||
const sessionId = c.get("portalSessionId");
|
||||
if (!sessionId) return;
|
||||
|
||||
const method = c.req.method;
|
||||
const routePath = c.req.path;
|
||||
const pageVisited = c.req.path;
|
||||
const statusCode = c.res.status;
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId,
|
||||
action: `${method} ${routePath}`,
|
||||
pageVisited,
|
||||
metadata: { method, statusCode },
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
console.error("[portalAudit] Failed to write audit log:", err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
portalClientId: string;
|
||||
portalSessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||
* Must be applied to all portal routes.
|
||||
*
|
||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||
* Returns 401 if session is invalid/missing/expired.
|
||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||
*/
|
||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("portalClientId", session.clientId);
|
||||
c.set("portalSessionId", session.id);
|
||||
await next();
|
||||
};
|
||||
@@ -338,35 +338,44 @@ async function sendConfirmationEmail(
|
||||
db: ReturnType<typeof getDb>,
|
||||
appt: typeof appointments.$inferSelect
|
||||
): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(eq(appointments.id, appt.id))
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
if (!client || !client.email || client.emailOptOut) return;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, appt.petId))
|
||||
.limit(1);
|
||||
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, appt.serviceId))
|
||||
.limit(1);
|
||||
|
||||
let groomerName: string | null = null;
|
||||
if (appt.staffId) {
|
||||
const [groomer] = await db
|
||||
.select({ name: staff.name })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, appt.staffId))
|
||||
.limit(1);
|
||||
groomerName = groomer?.name ?? null;
|
||||
}
|
||||
|
||||
if (!pet || !service) return;
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildConfirmationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
buildConfirmationEmail(client.email, {
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
smsOptIn: z.boolean().optional(),
|
||||
smsConsentText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -97,7 +95,6 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
smsOptOut: z.boolean().optional(),
|
||||
});
|
||||
|
||||
clientsRouter.patch(
|
||||
@@ -110,19 +107,13 @@ clientsRouter.patch(
|
||||
|
||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||
|
||||
// When disabling, set disabledAt; when re-enabling, clear it
|
||||
if (body.status === "disabled") {
|
||||
setValues.disabledAt = now;
|
||||
} else if (body.status === "active") {
|
||||
setValues.disabledAt = null;
|
||||
}
|
||||
|
||||
if (body.smsOptOut === true) {
|
||||
setValues.smsOptIn = false;
|
||||
setValues.smsOptOutDate = now;
|
||||
delete setValues.smsOptOut;
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
|
||||
+23
-122
@@ -1,33 +1,22 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
|
||||
export const portalRouter = new Hono<AppEnv>();
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
|
||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||
if (!sessionId) return null;
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
if (!session || session.expiresAt <= new Date()) return null;
|
||||
return session.clientId;
|
||||
}
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
@@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => {
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
@@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => {
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||
@@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => {
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
@@ -148,12 +131,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -196,12 +174,7 @@ portalRouter.patch(
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -319,28 +287,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
let clientId: string | null = null;
|
||||
if (sessionId) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
clientId = session.clientId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
@@ -364,26 +311,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -392,7 +320,7 @@ portalRouter.patch(
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== session.clientId) {
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -414,26 +342,7 @@ portalRouter.patch(
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
@@ -442,7 +351,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== session.clientId) {
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -475,9 +384,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
@@ -514,9 +421,7 @@ portalRouter.post(
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
@@ -524,9 +429,7 @@ portalRouter.get("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
@@ -539,9 +442,7 @@ portalRouter.post("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@ import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
} from "./email.js";
|
||||
import { smsSend } from "./sms.js";
|
||||
|
||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||
|
||||
// How many hours before the appointment to send each reminder.
|
||||
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||
function getReminderWindows(): { label: string; hours: number }[] {
|
||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||
@@ -31,14 +30,20 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
// Checks for upcoming appointments that need reminders and sends them.
|
||||
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||
export async function runReminderCheck(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
for (const window of getReminderWindows()) {
|
||||
// Target window: appointments starting between (hours - 1) and hours from now.
|
||||
// Running every minute means we check a 1-minute slice; the 1-hour window
|
||||
// ensures we catch appointments that started between heartbeats.
|
||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||
|
||||
// Find upcoming appointments in this time window that haven't been cancelled/completed
|
||||
const upcoming = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
@@ -60,38 +65,23 @@ export async function runReminderCheck(): Promise<void> {
|
||||
);
|
||||
|
||||
for (const appt of upcoming) {
|
||||
const [emailLog] = await db
|
||||
// Check if reminder already sent (unique constraint prevents double-send)
|
||||
const existing = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
eq(reminderLogs.channel, "email")
|
||||
eq(reminderLogs.reminderType, window.label)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const [smsLog] = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
eq(reminderLogs.channel, "sms")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) continue; // already sent
|
||||
|
||||
// Fetch related records for the email
|
||||
const [client] = await db
|
||||
.select({
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
emailOptOut: clients.emailOptOut,
|
||||
smsOptIn: clients.smsOptIn,
|
||||
phone: clients.phone,
|
||||
})
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
@@ -122,6 +112,8 @@ export async function runReminderCheck(): Promise<void> {
|
||||
|
||||
if (!pet || !service) continue;
|
||||
|
||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||
let confirmationToken = appt.confirmationToken;
|
||||
if (!confirmationToken) {
|
||||
confirmationToken = randomBytes(32).toString("hex");
|
||||
@@ -131,59 +123,35 @@ export async function runReminderCheck(): Promise<void> {
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
if (!emailLog) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsLog && client.smsOptIn && client.phone) {
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||
const smsBody = [
|
||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
||||
`Confirm: ${confirmUrl}`,
|
||||
`Cancel: ${cancelUrl}`,
|
||||
TCPA_OPT_OUT,
|
||||
].join(". ");
|
||||
try {
|
||||
const smsOk = await smsSend(client.phone, smsBody);
|
||||
if (smsOk) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[reminders] SMS send failed:", err);
|
||||
}
|
||||
if (sent) {
|
||||
// Record send — ignore conflicts (race condition between instances)
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the cron scheduler. Call once at server startup.
|
||||
export function startReminderScheduler(): void {
|
||||
// Run every minute
|
||||
cron.schedule("* * * * *", () => {
|
||||
runReminderCheck().catch((err) => {
|
||||
console.error("[reminders] Error during reminder check:", err);
|
||||
@@ -195,6 +163,8 @@ export function startReminderScheduler(): void {
|
||||
console.log("[reminders] Reminder scheduler started");
|
||||
}
|
||||
|
||||
// Deletes expired sessions from the database.
|
||||
// Runs every minute alongside reminder checks.
|
||||
export async function runSessionCleanup(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||
validateWebhookSignature(req: Request): boolean;
|
||||
}
|
||||
|
||||
interface TelnyxSmsResult {
|
||||
message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function createTelnyxClient(): Telnyx | null {
|
||||
const apiKey = process.env.TELNYX_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return new Telnyx(apiKey);
|
||||
}
|
||||
|
||||
let _client: Telnyx | null | undefined;
|
||||
|
||||
function getClient(): Telnyx | null {
|
||||
if (_client === undefined) _client = createTelnyxClient();
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getFromNumber(): string | null {
|
||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
}
|
||||
|
||||
function isE164(phone: string): boolean {
|
||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||
|
||||
const from = getFromNumber();
|
||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||
|
||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
};
|
||||
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
payload.media_urls = mediaUrls;
|
||||
}
|
||||
|
||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||
return {
|
||||
messageId: smsResult.message_id,
|
||||
status: smsResult.status,
|
||||
};
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements SmsProvider {
|
||||
async sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return sendSms(to, body, mediaUrls);
|
||||
}
|
||||
|
||||
validateWebhookSignature(req: Request): boolean {
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const signature = req.headers.get("telnyx-signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
const sigByte = sigBuf[i] ?? 0;
|
||||
const expByte = expBuf[i] ?? 0;
|
||||
diff |= sigByte ^ expByte;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: SmsProvider | null | undefined;
|
||||
|
||||
export function createSmsProvider(): SmsProvider | null {
|
||||
if (_provider === undefined) {
|
||||
if (process.env.SMS_ENABLED !== "true") {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
switch (process.env.SMS_PROVIDER) {
|
||||
case "telnyx": {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
_provider = new TelnyxProvider();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_provider = null;
|
||||
}
|
||||
}
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export async function smsSend(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<boolean> {
|
||||
const provider = createSmsProvider();
|
||||
if (!provider) return false;
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
declare module "telnyx" {
|
||||
export interface MessageResult {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface MessagesCreateParams {
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
media_urls?: string[];
|
||||
}
|
||||
|
||||
export class Telnyx {
|
||||
constructor(apiKey: string);
|
||||
messages: {
|
||||
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
-- SMS opt-in fields for clients (idempotent)
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
||||
|
||||
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
||||
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
||||
|
||||
-- Drop old unique constraints if they exist (idempotent)
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
||||
|
||||
-- Add new unique constraint with channel
|
||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Migration: 0029_db_indexes_constraints.sql
|
||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
||||
|
||||
-- Backfill NULL emails before setting NOT NULL
|
||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
||||
|
||||
-- Add indexes on appointments table
|
||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||
|
||||
-- Add index on pets table
|
||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
||||
|
||||
-- Add index on clients table
|
||||
CREATE INDEX idx_clients_email ON clients(email);
|
||||
|
||||
-- Set NOT NULL on clients.email (after backfill)
|
||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
||||
@@ -197,13 +197,6 @@
|
||||
"when": 1775655267192,
|
||||
"tag": "0027_refunds",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1775741667192,
|
||||
"tag": "0028_sms_reminders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,10 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
smsOptIn: false,
|
||||
smsConsentDate: null,
|
||||
smsOptOutDate: null,
|
||||
smsConsentText: null,
|
||||
stripeCustomerId: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
|
||||
+37
-52
@@ -102,55 +102,43 @@ export const verification = pgTable("verification", {
|
||||
|
||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const clients = pgTable(
|
||||
"clients",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||
smsConsentDate: timestamp("sms_consent_date"),
|
||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||
smsConsentText: text("sms_consent_text"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_clients_email").on(t.email)]
|
||||
);
|
||||
export const clients = pgTable("clients", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const pets = pgTable(
|
||||
"pets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
||||
);
|
||||
export const pets = pgTable("pets", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const services = pgTable("services", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -333,7 +321,6 @@ export const refunds = pgTable(
|
||||
|
||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||
// reminder_type values: "confirmation", "24h", "2h"
|
||||
// channel values: "email", "sms"
|
||||
export const reminderLogs = pgTable(
|
||||
"reminder_logs",
|
||||
{
|
||||
@@ -343,11 +330,9 @@ export const reminderLogs = pgTable(
|
||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||
// "confirmation" | "24h" | "2h"
|
||||
reminderType: text("reminder_type").notNull(),
|
||||
// "email" | "sms"
|
||||
channel: text("channel").notNull().default("email"),
|
||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||
);
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -398,8 +398,6 @@ async function seedKnownUsers() {
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
userId: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -426,7 +424,6 @@ async function seedKnownUsers() {
|
||||
name: "UAT Super User",
|
||||
email: "uat-super@groombook.dev",
|
||||
oidcSub: uatSuperOidcSub,
|
||||
userId: uatSuperOidcSub,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -453,7 +450,6 @@ async function seedKnownUsers() {
|
||||
name: "UAT Staff Groomer",
|
||||
email: "uat-groomer@groombook.dev",
|
||||
oidcSub: uatStaffOidcSub,
|
||||
userId: uatStaffOidcSub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
@@ -462,37 +458,6 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
const [existingGroomer] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingGroomer) {
|
||||
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||
@@ -647,8 +612,6 @@ async function seed() {
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
userId: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -660,31 +623,6 @@ async function seed() {
|
||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
|
||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
await db.insert(schema.staff)
|
||||
.values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.staff.email,
|
||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
||||
});
|
||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
||||
}
|
||||
|
||||
// ── Services ──
|
||||
// Upsert services using name as unique key. With deterministic IDs in
|
||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||
|
||||
Generated
+53
-43
@@ -43,9 +43,6 @@ importers:
|
||||
stripe:
|
||||
specifier: ^22.0.0
|
||||
version: 22.0.1(@types/node@22.19.15)
|
||||
telnyx:
|
||||
specifier: ^1.23.0
|
||||
version: 1.27.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -180,7 +177,7 @@ importers:
|
||||
version: 22.19.15
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.4
|
||||
version: 0.30.4
|
||||
version: 0.30.6
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
@@ -1699,6 +1696,9 @@ packages:
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@petamoriken/float16@3.9.3':
|
||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2830,8 +2830,8 @@ packages:
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
drizzle-kit@0.30.4:
|
||||
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
|
||||
drizzle-kit@0.30.6:
|
||||
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||
hasBin: true
|
||||
|
||||
drizzle-orm@0.38.4:
|
||||
@@ -2955,6 +2955,10 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3158,6 +3162,11 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
gel@2.2.0:
|
||||
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
generator-function@2.0.1:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3425,6 +3434,10 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isexe@3.1.5:
|
||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3606,9 +3619,6 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3841,10 +3851,6 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.1:
|
||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -4040,6 +4046,10 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.8.3:
|
||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4178,10 +4188,6 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
telnyx@1.27.0:
|
||||
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
|
||||
engines: {node: ^6 || >=8}
|
||||
|
||||
temp-dir@2.0.0:
|
||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4256,9 +4262,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4348,10 +4351,6 @@ packages:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
@@ -4488,6 +4487,11 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
which@4.0.0:
|
||||
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
||||
engines: {node: ^16.13.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6219,6 +6223,8 @@ snapshots:
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@petamoriken/float16@3.9.3': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -7414,12 +7420,13 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
drizzle-kit@0.30.4:
|
||||
drizzle-kit@0.30.6:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
'@esbuild-kit/esm-loader': 2.6.5
|
||||
esbuild: 0.19.12
|
||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||
gel: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7456,6 +7463,8 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
@@ -7817,6 +7826,17 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
gel@2.2.0:
|
||||
dependencies:
|
||||
'@petamoriken/float16': 3.9.3
|
||||
debug: 4.4.3
|
||||
env-paths: 3.0.0
|
||||
semver: 7.7.4
|
||||
shell-quote: 1.8.3
|
||||
which: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
@@ -8081,6 +8101,8 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.5: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
@@ -8249,8 +8271,6 @@ snapshots:
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.sortby@4.7.0: {}
|
||||
@@ -8449,10 +8469,6 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.1:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -8687,6 +8703,8 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.3: {}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -8840,14 +8858,6 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
telnyx@1.27.0:
|
||||
dependencies:
|
||||
lodash.isplainobject: 4.0.6
|
||||
qs: 6.15.1
|
||||
safe-buffer: 5.2.1
|
||||
tweetnacl: 1.0.3
|
||||
uuid: 9.0.1
|
||||
|
||||
temp-dir@2.0.0: {}
|
||||
|
||||
tempy@0.6.0:
|
||||
@@ -8918,8 +8928,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -9016,8 +9024,6 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
@@ -9195,6 +9201,10 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
which@4.0.0:
|
||||
dependencies:
|
||||
isexe: 3.1.5
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
|
||||
Reference in New Issue
Block a user