Compare commits

...

7 Commits

Author SHA1 Message Date
Flea Flicker 964c63bbdf GRO-1636: fix scrypt keylen=64 and add email+password UAT test cases
CI / Test (pull_request) Successful in 25s
CI / E2E Tests (pull_request) Failing after 48s
CI / Build (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
1. Fix scrypt keylen: positional arg is output key length, not N cost.
   Correct call: scrypt(pass, salt, 64, {N:16384, r:8, p:1})
   This produces a 64-byte key matching Better Auth's expected format.

2. Update UAT_PLAYBOOK.md §4.1 with 6 new email+password login test
   cases covering all 4 UAT personas (super, groomer, customer, tester),
   renumbered session/logout/RBAC tests, and a reset-cycle survival test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 20:41:30 +00:00
Barcode Betty 4ec2885b09 GRO-1636: seed.ts creates Better Auth credential accounts for UAT personas
CI / Lint & Typecheck (pull_request) Successful in 22s
CI / Test (pull_request) Successful in 24s
CI / Build (pull_request) Successful in 22s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / E2E Tests (pull_request) Failing after 40s
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
CI / Web E2E (Dev) (pull_request) Has been cancelled
After creating staff table records for UAT personas, seedKnownUsers() now
reads SEED_UAT_*_PASSWORD env vars and creates Better Auth user + account
rows so personas can email+password login. Uses the same scrypt hash format
(N=16384, r=8, p=1, dkLen=64) as better-auth.

For uat-super and uat-groomer, the staff record is linked to the Better Auth
user via userId field.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 20:23:35 +00:00
Flea Flicker 82f1e3856f fix(e2e): respect PLAYWRIGHT_BASE_URL env var and add host.docker.internal resolution
CI / Test (pull_request) Successful in 28s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / E2E Tests (pull_request) Successful in 1m32s
CI / Build (pull_request) Successful in 2m32s
CI / Build & Push Docker Images (pull_request) Successful in 35s
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
The Playwright config hardcoded localhost:8080 as baseURL, ignoring
the PLAYWRIGHT_BASE_URL env var set in CI. Docker Compose was also
missing extra_hosts to resolve host.docker.internal on Gitea Actions
runners (which use DIND).

Fixes GRO-1496.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:53:30 +00:00
Flea Flicker 526251b63a fix: resolve lint errors and xlarge mismatch for dev→uat promotion
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 27s
CI / E2E Tests (push) Failing after 3m27s
CI / Update Infra Image Tags (push) Has been skipped
CI / Build (push) Successful in 24s
CI / Build & Push Docker Images (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
- Remove unused gte/lt/ne imports from cascade.ts
- Prefix unused params originalEndTime, originalStartTime, newStartTime
  with underscore in cascade.ts and appointments.ts callers
- Remove unused petCoatType query param from book.ts availability route
- Align xlarge value: Book.tsx now uses "xlarge" (no hyphen) everywhere
  to match the Zod booking schema

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:28:43 +00:00
The Dogfather 3aa7631519 Merge pull request 'fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types' (#427) from fix/gro-1369-types-sync into dev
CI / Test (pull_request) Successful in 25s
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (pull_request) Failing after 23s
CI / E2E Tests (pull_request) Has been skipped
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Lint & Typecheck (push) Failing after 22s
CI / E2E Tests (push) Has been skipped
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Merge PR #427: fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types

Approved by CTO (review #3463) and QA (review #3469).
Resolves GRO-1369.
2026-05-21 20:00:40 +00:00
The Dogfather 511bdf0d7d Merge pull request 'fix(GRO-1368): remove unused getDb import from consent.ts' (#426) from fix/gro-1368-consent-ts into dev
CI / Lint & Typecheck (push) Failing after 22s
CI / E2E Tests (push) Has been skipped
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (push) Successful in 23s
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
feat(GRO-106): STOP/HELP compliance + consent log (#426)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:51:09 +00:00
Chris Farhood cc0259975b fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types
CI / Lint & Typecheck (pull_request) Successful in 22s
CI / Test (pull_request) Successful in 23s
CI / Build (pull_request) Successful in 23s
CI / E2E Tests (pull_request) Failing after 3m25s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Pet interface: added sizeCategory and coatType (nullable strings).
Service interface: added defaultBufferMinutes.
Appointment interface: added bufferMinutes.

These fields are referenced by Book.tsx, cascade.ts, buffer.ts, appointment
routes, and other type-annotated consuming code. Without them, any file that
imports these interfaces and accesses the fields causes a TypeScript error.

cc @cpfarhood

Co-Authored-By: Flea Flicker <noreply@paperclip.ing>
2026-05-20 15:44:08 +00:00
9 changed files with 104 additions and 19 deletions
+11 -6
View File
@@ -35,12 +35,17 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.1.1 | OIDC login | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
| TC-APP-4.1.2 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
| TC-APP-4.1.3 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
| TC-APP-4.1.4 | RBAC - Manager access | 1. Log in as Manager<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
| TC-APP-4.1.5 | RBAC - Staff access | 1. Log in as Staff<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
| TC-APP-4.1.6 | RBAC - Client access | 1. Log in as Client<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
| TC-APP-4.1.1 | OIDC login (Authentik) | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
| TC-APP-4.1.2 | Email + password login (UAT Super) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-super@groombook.dev` and UAT super password<br>4. Submit | User is logged in and redirected to dashboard with manager access |
| TC-APP-4.1.3 | Email + password login (UAT Groomer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-groomer@groombook.dev` and UAT groomer password<br>4. Submit | User is logged in and redirected to dashboard with staff/groomer access |
| TC-APP-4.1.4 | Email + password login (UAT Customer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-customer@groombook.dev` and UAT customer password<br>4. Submit | User is logged in with client portal access |
| TC-APP-4.1.5 | Email + password login (UAT Tester) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-tester@groombook.dev` and UAT tester password<br>4. Submit | User is logged in with staff/tester access |
| TC-APP-4.1.6 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
| TC-APP-4.1.7 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
| TC-APP-4.1.8 | RBAC - Manager access | 1. Log in as Manager (OIDC or email+password)<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
| TC-APP-4.1.9 | RBAC - Staff access | 1. Log in as Staff (OIDC or email+password)<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
| TC-APP-4.1.10 | RBAC - Client access | 1. Log in as Client (email+password)<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
| TC-APP-4.1.11 | Login after hourly reset | 1. Wait for or trigger `reset-demo-data` CronJob to run<br>2. Attempt email+password login as any UAT persona | Login succeeds — Better Auth credential accounts survive the reset cycle |
### 4.2 Setup Wizard / OOBE
+7 -7
View File
@@ -1,4 +1,4 @@
import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db";
import { eq, and, gt, or, asc } from "@groombook/db";
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
import { resolveBufferMinutes } from "./buffer.js";
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
@@ -53,12 +53,12 @@ export async function detectAndCascadeOverrun({
db,
overrunningAppointmentId,
newEndTime,
originalEndTime,
_originalEndTime,
}: {
db: Db;
overrunningAppointmentId: string;
newEndTime: Date;
originalEndTime: Date;
_originalEndTime: Date;
}): Promise<CascadeResult> {
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
@@ -178,16 +178,16 @@ export async function detectAndCascadeOverrun({
export function isOverrun({
originalEndTime,
newEndTime,
originalStartTime,
newStartTime,
_originalStartTime,
_newStartTime,
status,
currentTime,
bufferMinutes,
}: {
originalEndTime: Date;
newEndTime: Date;
originalStartTime: Date;
newStartTime?: Date;
_originalStartTime: Date;
_newStartTime?: Date;
status: string;
currentTime: Date;
bufferMinutes: number;
+2 -2
View File
@@ -700,7 +700,7 @@ appointmentsRouter.patch(
isOverrun({
originalEndTime,
newEndTime: new Date(updateFields.endTime),
originalStartTime: row.startTime,
_originalStartTime: row.startTime,
status: row.status,
currentTime: new Date(),
bufferMinutes: row.bufferMinutes ?? 0,
@@ -710,7 +710,7 @@ appointmentsRouter.patch(
db,
overrunningAppointmentId: id,
newEndTime: new Date(updateFields.endTime),
originalEndTime,
_originalEndTime: originalEndTime,
});
return c.json({ ...row, cascade: cascadeResult });
}
-1
View File
@@ -44,7 +44,6 @@ bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
const petCoatType = c.req.query("petCoatType") ?? undefined;
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:8080",
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
+2 -2
View File
@@ -515,7 +515,7 @@ export function BookPage() {
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
<option value="xlarge">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
@@ -568,7 +568,7 @@ export function BookPage() {
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "xlarge") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
+2
View File
@@ -50,6 +50,8 @@ services:
dockerfile: apps/web/Dockerfile
ports:
- "8080:80"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
+76
View File
@@ -20,6 +20,7 @@ import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js";
import { randomBytes, scrypt } from "node:crypto";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -509,6 +510,81 @@ async function seedKnownUsers() {
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
// ── Better Auth credential accounts for UAT personas ─────────────────────
// Creates user + account rows so UAT personas can email+password login.
// Uses the same scrypt config as better-auth (keylen=64, N=16384, r=8, p=1).
const uatCredAccounts: Array<{ email: string; passwordEnvKey: string; staffId: string }> = [
{ email: "uat-super@groombook.dev", passwordEnvKey: "SEED_UAT_SUPER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000003" },
{ email: "uat-groomer@groombook.dev", passwordEnvKey: "SEED_UAT_GROOMER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000004" },
{ email: "uat-customer@groombook.dev", passwordEnvKey: "SEED_UAT_CUSTOMER_PASSWORD", staffId: "" },
{ email: "uat-tester@groombook.dev", passwordEnvKey: "SEED_UAT_TESTER_PASSWORD", staffId: "" },
];
for (const acct of uatCredAccounts) {
const password = process.env[acct.passwordEnvKey];
if (!password) {
console.log(`⊘ No ${acct.passwordEnvKey} set — skipping Better Auth account for ${acct.email}`);
continue;
}
// Check if user already exists
const [existingUser] = await db
.select()
.from(schema.user)
.where(eq(schema.user.email, acct.email))
.limit(1);
let userId: string;
if (existingUser) {
userId = existingUser.id;
console.log(`✓ Better Auth user '${acct.email}' already exists — skipping`);
} else {
// Hash with same scrypt params as better-auth: keylen=64, N=16384, r=8, p=1
// Use Promise-based scrypt API (callback pattern, wrapped in Promise)
const salt = randomBytes(16);
const key = await new Promise<Buffer>((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scrypt(password.normalize("NFKC"), salt, 64, { N: 16384, r: 8, p: 1 } as any, (err: Error | null, derivedKey: Buffer) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
const passwordHash = `${salt.toString("hex")}:${key.toString("hex")}`;
const [newUser] = await db.insert(schema.user).values({
id: uuid(),
name: acct.email.split("@")[0]!,
email: acct.email,
emailVerified: true,
}).returning();
userId = newUser!.id;
await db.insert(schema.account).values({
id: uuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
});
console.log(`✓ Created Better Auth credential account for '${acct.email}'`);
}
// Link staff record to Better Auth user if staff exists and has no userId yet
if (acct.staffId) {
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.id, acct.staffId))
.limit(1);
if (existingStaff && !existingStaff.userId) {
await db.update(schema.staff)
.set({ userId })
.where(eq(schema.staff.id, acct.staffId));
console.log(` ↳ Linked staff '${acct.email}' to Better Auth user`);
}
}
}
// ── Client: Demo Client ──
const [existingClient] = await db
.select()
+3
View File
@@ -32,6 +32,8 @@ export interface Pet {
name: string;
species: string;
breed: string | null;
sizeCategory: string | null;
coatType: string | null;
weightKg: number | null;
dateOfBirth: string | null;
healthAlerts: string | null;
@@ -115,6 +117,7 @@ export interface Appointment {
cancelledAt: string | null;
confirmationToken: string | null;
customerNotes: string | null;
bufferMinutes: number;
createdAt: string;
updatedAt: string;
}