Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8dbec1be1 | |||
| 4a65c30d40 | |||
| cab17e0230 | |||
| b904418628 | |||
| 5ff54ce8f9 | |||
| a2cfdfef74 | |||
| ab9384d38e | |||
| 6ba6da08b2 | |||
| 29a726fa3d | |||
| cdf4d6c4b1 | |||
| 376180ab9d | |||
| da16ac8ac2 |
@@ -0,0 +1,90 @@
|
|||||||
|
# Contributing to GroomBook
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
GroomBook uses a three-branch GitOps model:
|
||||||
|
|
||||||
|
| Branch | Environment | Purpose |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
|
||||||
|
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
|
||||||
|
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
|
||||||
|
|
||||||
|
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
|
||||||
|
|
||||||
|
## Developer Workflow
|
||||||
|
|
||||||
|
1. **Branch from `dev`** — create a feature or fix branch:
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
git pull origin dev
|
||||||
|
git checkout -b feat/my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
|
||||||
|
```bash
|
||||||
|
gh pr create --base dev --title "feat: description (GRO-NNN)" \
|
||||||
|
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Pipeline gates before merge to `dev`:**
|
||||||
|
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
|
||||||
|
- CTO (The Dogfather) reviews second — architecture and final approval
|
||||||
|
- Both must approve; 2 approving reviews required by branch protection
|
||||||
|
|
||||||
|
## Promotion Flow
|
||||||
|
|
||||||
|
### Dev → UAT
|
||||||
|
|
||||||
|
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base uat --head dev \
|
||||||
|
--title "chore: promote dev to uat (YYYY.MM.DD)" \
|
||||||
|
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gates:
|
||||||
|
- Shedward Scissorhands runs regression/acceptance tests
|
||||||
|
- Barkley Trimsworth performs security review
|
||||||
|
- CTO approves and merges (1 approving review required)
|
||||||
|
|
||||||
|
### UAT → Main (Production)
|
||||||
|
|
||||||
|
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base main --head uat \
|
||||||
|
--title "chore: promote uat to main (YYYY.MM.DD)" \
|
||||||
|
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gates:
|
||||||
|
- CEO (Scrubs McBarkley) reviews for business alignment and merges
|
||||||
|
- 1 approving review required; triggers auto-deploy to Production
|
||||||
|
|
||||||
|
## Branch Protection Summary
|
||||||
|
|
||||||
|
| Branch | Required Approvals | Who approves |
|
||||||
|
|--------|--------------------|-------------|
|
||||||
|
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
|
||||||
|
| `uat` | 1 | CTO (The Dogfather) |
|
||||||
|
| `main` | 1 | CEO (Scrubs McBarkley) |
|
||||||
|
|
||||||
|
Force-pushes and branch deletions are disabled on all three branches.
|
||||||
|
|
||||||
|
## Commit Style
|
||||||
|
|
||||||
|
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
- `feat:` — new feature
|
||||||
|
- `fix:` — bug fix
|
||||||
|
- `chore:` — maintenance (dependency updates, build config, promotions)
|
||||||
|
- `docs:` — documentation only
|
||||||
|
- `ci:` — CI/CD changes
|
||||||
|
- `refactor:` — code restructure without behaviour change
|
||||||
|
|
||||||
|
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a Paperclip issue in the GRO project or ask in the team channel.
|
||||||
@@ -195,10 +195,11 @@ describe("POST /clients", () => {
|
|||||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a client with only required name field", async () => {
|
it("creates a client with name and email", async () => {
|
||||||
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(insertedValues[0]!.name).toBe("Dana");
|
expect(insertedValues[0]!.name).toBe("Dana");
|
||||||
|
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", async () => {
|
it("rejects empty name", async () => {
|
||||||
|
|||||||
@@ -204,15 +204,11 @@ export async function initAuth(): Promise<void> {
|
|||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
const authzUrlObj = new URL(authzUrl);
|
const authzUrlObj = new URL(authzUrl);
|
||||||
const tokenUrlObj = new URL(tokenUrl);
|
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
||||||
const userInfoUrlObj = new URL(userInfoUrl);
|
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
||||||
if (
|
if (authzUrlObj.hostname !== issuerHostname) {
|
||||||
authzUrlObj.hostname !== issuerHostname ||
|
|
||||||
tokenUrlObj.hostname !== issuerHostname ||
|
|
||||||
userInfoUrlObj.hostname !== issuerHostname
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[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.`
|
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
oidcConfig = {
|
oidcConfig = {
|
||||||
|
|||||||
@@ -338,44 +338,35 @@ async function sendConfirmationEmail(
|
|||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
appt: typeof appointments.$inferSelect
|
appt: typeof appointments.$inferSelect
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [client] = await db
|
const [row] = await db
|
||||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
.select({
|
||||||
.from(clients)
|
clientName: clients.name,
|
||||||
.where(eq(clients.id, appt.clientId))
|
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))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!client || !client.email || client.emailOptOut) return;
|
if (!row) return;
|
||||||
|
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||||
|
|
||||||
const [pet] = await db
|
if (!clientEmail || clientEmailOptOut) return;
|
||||||
.select({ name: pets.name })
|
if (!petName || !serviceName) return;
|
||||||
.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(
|
const sent = await sendEmail(
|
||||||
buildConfirmationEmail(client.email, {
|
buildConfirmationEmail(clientEmail, {
|
||||||
clientName: client.name,
|
clientName,
|
||||||
petName: pet.name,
|
petName,
|
||||||
serviceName: service.name,
|
serviceName,
|
||||||
groomerName,
|
groomerName: groomerName ?? null,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const clientsRouter = new Hono<AppEnv>();
|
|||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().email().optional(),
|
email: z.string().email(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 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;
|
||||||
+48
-40
@@ -102,47 +102,55 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable("clients", {
|
export const clients = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"clients",
|
||||||
name: text("name").notNull(),
|
{
|
||||||
email: text("email"),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
phone: text("phone"),
|
name: text("name").notNull(),
|
||||||
address: text("address"),
|
email: text("email").notNull(),
|
||||||
notes: text("notes"),
|
phone: text("phone"),
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
address: text("address"),
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
notes: text("notes"),
|
||||||
smsConsentDate: timestamp("sms_consent_date"),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||||
smsConsentText: text("sms_consent_text"),
|
smsConsentDate: timestamp("sms_consent_date"),
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
smsConsentText: text("sms_consent_text"),
|
||||||
disabledAt: timestamp("disabled_at"),
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
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 pets = pgTable("pets", {
|
export const pets = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"pets",
|
||||||
clientId: uuid("client_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
clientId: uuid("client_id")
|
||||||
name: text("name").notNull(),
|
.notNull()
|
||||||
species: text("species").notNull(),
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
breed: text("breed"),
|
name: text("name").notNull(),
|
||||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
species: text("species").notNull(),
|
||||||
dateOfBirth: timestamp("date_of_birth"),
|
breed: text("breed"),
|
||||||
healthAlerts: text("health_alerts"),
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||||
groomingNotes: text("grooming_notes"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
cutStyle: text("cut_style"),
|
healthAlerts: text("health_alerts"),
|
||||||
shampooPreference: text("shampoo_preference"),
|
groomingNotes: text("grooming_notes"),
|
||||||
specialCareNotes: text("special_care_notes"),
|
cutStyle: text("cut_style"),
|
||||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
shampooPreference: text("shampoo_preference"),
|
||||||
photoKey: text("photo_key"),
|
specialCareNotes: text("special_care_notes"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||||
image: text("image"),
|
photoKey: text("photo_key"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
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 services = pgTable("services", {
|
export const services = pgTable("services", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|||||||
@@ -462,6 +462,37 @@ 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 ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
@@ -629,6 +660,31 @@ async function seed() {
|
|||||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
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 ──
|
// ── Services ──
|
||||||
// Upsert services using name as unique key. With deterministic IDs in
|
// Upsert services using name as unique key. With deterministic IDs in
|
||||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||||
|
|||||||
Reference in New Issue
Block a user