Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 964c63bbdf | |||
| 4ec2885b09 | |||
| 82f1e3856f | |||
| 526251b63a |
+11
-6
@@ -35,12 +35,17 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
|||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | 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.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 | 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.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 | 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.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 | 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.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 | 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.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 | 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.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
|
### 4.2 Setup Wizard / OOBE
|
||||||
|
|
||||||
|
|||||||
@@ -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 { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
|
||||||
import { resolveBufferMinutes } from "./buffer.js";
|
import { resolveBufferMinutes } from "./buffer.js";
|
||||||
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
|
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
|
||||||
@@ -53,12 +53,12 @@ export async function detectAndCascadeOverrun({
|
|||||||
db,
|
db,
|
||||||
overrunningAppointmentId,
|
overrunningAppointmentId,
|
||||||
newEndTime,
|
newEndTime,
|
||||||
originalEndTime,
|
_originalEndTime,
|
||||||
}: {
|
}: {
|
||||||
db: Db;
|
db: Db;
|
||||||
overrunningAppointmentId: string;
|
overrunningAppointmentId: string;
|
||||||
newEndTime: Date;
|
newEndTime: Date;
|
||||||
originalEndTime: Date;
|
_originalEndTime: Date;
|
||||||
}): Promise<CascadeResult> {
|
}): Promise<CascadeResult> {
|
||||||
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
|
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
|
||||||
|
|
||||||
@@ -178,16 +178,16 @@ export async function detectAndCascadeOverrun({
|
|||||||
export function isOverrun({
|
export function isOverrun({
|
||||||
originalEndTime,
|
originalEndTime,
|
||||||
newEndTime,
|
newEndTime,
|
||||||
originalStartTime,
|
_originalStartTime,
|
||||||
newStartTime,
|
_newStartTime,
|
||||||
status,
|
status,
|
||||||
currentTime,
|
currentTime,
|
||||||
bufferMinutes,
|
bufferMinutes,
|
||||||
}: {
|
}: {
|
||||||
originalEndTime: Date;
|
originalEndTime: Date;
|
||||||
newEndTime: Date;
|
newEndTime: Date;
|
||||||
originalStartTime: Date;
|
_originalStartTime: Date;
|
||||||
newStartTime?: Date;
|
_newStartTime?: Date;
|
||||||
status: string;
|
status: string;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
bufferMinutes: number;
|
bufferMinutes: number;
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ appointmentsRouter.patch(
|
|||||||
isOverrun({
|
isOverrun({
|
||||||
originalEndTime,
|
originalEndTime,
|
||||||
newEndTime: new Date(updateFields.endTime),
|
newEndTime: new Date(updateFields.endTime),
|
||||||
originalStartTime: row.startTime,
|
_originalStartTime: row.startTime,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
currentTime: new Date(),
|
currentTime: new Date(),
|
||||||
bufferMinutes: row.bufferMinutes ?? 0,
|
bufferMinutes: row.bufferMinutes ?? 0,
|
||||||
@@ -710,7 +710,7 @@ appointmentsRouter.patch(
|
|||||||
db,
|
db,
|
||||||
overrunningAppointmentId: id,
|
overrunningAppointmentId: id,
|
||||||
newEndTime: new Date(updateFields.endTime),
|
newEndTime: new Date(updateFields.endTime),
|
||||||
originalEndTime,
|
_originalEndTime: originalEndTime,
|
||||||
});
|
});
|
||||||
return c.json({ ...row, cascade: cascadeResult });
|
return c.json({ ...row, cascade: cascadeResult });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
const serviceId = c.req.query("serviceId");
|
const serviceId = c.req.query("serviceId");
|
||||||
const dateStr = c.req.query("date");
|
const dateStr = c.req.query("date");
|
||||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
||||||
const petCoatType = c.req.query("petCoatType") ?? undefined;
|
|
||||||
|
|
||||||
if (!serviceId || !dateStr) {
|
if (!serviceId || !dateStr) {
|
||||||
return c.json({ error: "serviceId and date are required" }, 400);
|
return c.json({ error: "serviceId and date are required" }, 400);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:8080",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ export function BookPage() {
|
|||||||
<option value="small">Small (under 15 lbs)</option>
|
<option value="small">Small (under 15 lbs)</option>
|
||||||
<option value="medium">Medium (15–40 lbs)</option>
|
<option value="medium">Medium (15–40 lbs)</option>
|
||||||
<option value="large">Large (40–80 lbs)</option>
|
<option value="large">Large (40–80 lbs)</option>
|
||||||
<option value="x-large">X-Large (over 80 lbs)</option>
|
<option value="xlarge">X-Large (over 80 lbs)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -568,7 +568,7 @@ export function BookPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</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>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ services:
|
|||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
import { randomBytes, scrypt } from "node:crypto";
|
||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -509,6 +510,81 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
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 ──
|
// ── Client: Demo Client ──
|
||||||
const [existingClient] = await db
|
const [existingClient] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
Reference in New Issue
Block a user