Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8bbb12edb | |||
| ba95088653 | |||
| dd83f29736 | |||
| 185fce8e17 | |||
| 081379c189 | |||
| e01c12a316 |
@@ -1 +0,0 @@
|
|||||||
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
|
|
||||||
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev, uat]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev, uat]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
@@ -96,6 +96,7 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: runner
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||||
@@ -110,6 +111,7 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: migrate
|
target: migrate
|
||||||
push: true
|
push: true
|
||||||
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
@@ -124,6 +126,7 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: seed
|
target: seed
|
||||||
push: true
|
push: true
|
||||||
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
@@ -138,6 +141,7 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: reset
|
target: reset
|
||||||
push: true
|
push: true
|
||||||
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.farh.net/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,26 +48,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
|
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
|
||||||
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
|
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
|
||||||
|
|
||||||
#### SSO Login Journey (Authentik OIDC end-to-end)
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
|
||||||
|---|----------|-------|---------------|---------------|
|
|
||||||
| TC-API-1.17 | SSO redirect to Authentik | Navigate to app → sign-in page shown → click "Sign in with SSO" | Redirected to Authentik at auth.farh.net | 403 error, redirect loop, no SSO button |
|
|
||||||
| TC-API-1.18 | Authenticate with valid OIDC credentials | At Authentik login page, enter valid credentials and authenticate | Redirected back to app with valid session | Redirect loop, 403, missing session cookie |
|
|
||||||
| TC-API-1.19 | SSO user auto-provisioned as groomer | Complete SSO login as a user with no pre-existing staff record | 200 response; groomer staff record auto-created; session active | 403 Forbidden, staff record not created |
|
|
||||||
| TC-API-1.20 | Existing staff record resolves correctly | Complete SSO login as uat-groomer (pre-existing staff) | 200 OK, correct staff identity resolved, no duplicate record created | 403, duplicate record, wrong staff data |
|
|
||||||
| TC-API-1.21 | SSO session grants dashboard access | After TC-API-1.18 SSO login, GET /api/staff/me | 200 OK, valid staff record returned, correct role displayed | 401/403, missing session, wrong identity |
|
|
||||||
|
|
||||||
#### OOBE Flow Post-Login
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
|
||||||
|---|----------|-------|---------------|---------------|
|
|
||||||
| TC-API-1.22 | Fresh DB reports needsSetup | On a fresh DB (no super user), GET /api/setup/status | needsSetup: true returned | needsSetup: false when it should be true |
|
|
||||||
| TC-API-1.23 | Configure OIDC via auth-provider endpoint | POST /api/setup/auth-provider with valid OIDC config | 200 OK, auth provider configured, no 403 | 403, setup blocked, invalid config rejected |
|
|
||||||
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
|
||||||
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
|
||||||
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const UAT_CLIENT = {
|
|||||||
|
|
||||||
const UAT_PETS = [
|
const UAT_PETS = [
|
||||||
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
|
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
|
||||||
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
|
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short" as const, weightKg: "30.00" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEMO_SERVICES = [
|
const DEMO_SERVICES = [
|
||||||
|
|||||||
+1
-131
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -283,133 +283,3 @@ petsRouter.get("/:petId/photo", async (c) => {
|
|||||||
const url = await getPresignedGetUrl(pet.photoKey);
|
const url = await getPresignedGetUrl(pet.photoKey);
|
||||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Profile Summary ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function groomerLinkageCheck(
|
|
||||||
db: ReturnType<typeof getDb>,
|
|
||||||
clientId: string,
|
|
||||||
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
|
|
||||||
): Promise<boolean> {
|
|
||||||
const [linkage] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.clientId, clientId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
return !!linkage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /:id/profile-summary
|
|
||||||
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
|
|
||||||
* Groomer RBAC: same visibility rules as GET /:id.
|
|
||||||
*/
|
|
||||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const petId = c.req.param("id");
|
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
|
|
||||||
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent grooming history: last 10, with staff name join
|
|
||||||
const historyRows = await db
|
|
||||||
.select({
|
|
||||||
id: groomingVisitLogs.id,
|
|
||||||
petId: groomingVisitLogs.petId,
|
|
||||||
appointmentId: groomingVisitLogs.appointmentId,
|
|
||||||
staffId: groomingVisitLogs.staffId,
|
|
||||||
staffName: staff.name,
|
|
||||||
cutStyle: groomingVisitLogs.cutStyle,
|
|
||||||
productsUsed: groomingVisitLogs.productsUsed,
|
|
||||||
notes: groomingVisitLogs.notes,
|
|
||||||
groomedAt: groomingVisitLogs.groomedAt,
|
|
||||||
createdAt: groomingVisitLogs.createdAt,
|
|
||||||
})
|
|
||||||
.from(groomingVisitLogs)
|
|
||||||
.leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId))
|
|
||||||
.where(eq(groomingVisitLogs.petId, petId))
|
|
||||||
.orderBy(desc(groomingVisitLogs.groomedAt))
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
const recentGroomingHistory = historyRows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
petId: r.petId,
|
|
||||||
appointmentId: r.appointmentId,
|
|
||||||
staffId: r.staffId,
|
|
||||||
staffName: r.staffName,
|
|
||||||
cutStyle: r.cutStyle,
|
|
||||||
productsUsed: r.productsUsed,
|
|
||||||
notes: r.notes,
|
|
||||||
groomedAt: r.groomedAt?.toISOString() ?? null,
|
|
||||||
createdAt: r.createdAt?.toISOString() ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
|
|
||||||
|
|
||||||
// Completed appointment count for this pet
|
|
||||||
const [{ count: visitCount }] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(appointments)
|
|
||||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
|
||||||
|
|
||||||
// Upcoming appointment: next scheduled or confirmed
|
|
||||||
const [nextAppt] = await db
|
|
||||||
.select({
|
|
||||||
id: appointments.id,
|
|
||||||
serviceId: appointments.serviceId,
|
|
||||||
staffId: appointments.staffId,
|
|
||||||
startTime: appointments.startTime,
|
|
||||||
endTime: appointments.endTime,
|
|
||||||
status: appointments.status,
|
|
||||||
serviceName: services.name,
|
|
||||||
staffName: staff.name,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.leftJoin(services, eq(services.id, appointments.serviceId))
|
|
||||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, petId),
|
|
||||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
|
|
||||||
gte(appointments.startTime, new Date())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(appointments.startTime)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const upcomingAppointment = nextAppt
|
|
||||||
? {
|
|
||||||
id: nextAppt.id,
|
|
||||||
serviceId: nextAppt.serviceId,
|
|
||||||
serviceName: nextAppt.serviceName,
|
|
||||||
staffId: nextAppt.staffId,
|
|
||||||
staffName: nextAppt.staffName,
|
|
||||||
startTime: nextAppt.startTime?.toISOString() ?? null,
|
|
||||||
endTime: nextAppt.endTime?.toISOString() ?? null,
|
|
||||||
status: nextAppt.status,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
...row,
|
|
||||||
recentGroomingHistory,
|
|
||||||
lastVisitDate,
|
|
||||||
visitCount,
|
|
||||||
upcomingAppointment,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-46
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
|
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -110,51 +110,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
|
|
||||||
// (e.g. authentik). If so, create a groomer staff record on the fly.
|
|
||||||
if (jwt.email) {
|
|
||||||
const [oidcAccount] = await db
|
|
||||||
.select({ id: account.id })
|
|
||||||
.from(account)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(account.userId, jwt.sub),
|
|
||||||
sql`${account.providerId} IN ('authentik', 'google', 'github')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (oidcAccount) {
|
|
||||||
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
|
|
||||||
const emailPrefix = jwt.email.split("@")[0] ?? "Unknown";
|
|
||||||
const name = jwt.name?.trim() || emailPrefix;
|
|
||||||
|
|
||||||
const [newStaff] = await db
|
|
||||||
.insert(staff)
|
|
||||||
.values({
|
|
||||||
userId: jwt.sub,
|
|
||||||
email: jwt.email,
|
|
||||||
name,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!newStaff) {
|
|
||||||
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
|
|
||||||
);
|
|
||||||
c.set("staff", newStaff);
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const UAT_CLIENT = {
|
|||||||
|
|
||||||
const UAT_PETS = [
|
const UAT_PETS = [
|
||||||
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
|
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
|
||||||
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth", weightKg: "30.00" },
|
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short", weightKg: "30.00" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEMO_SERVICES = [
|
const DEMO_SERVICES = [
|
||||||
|
|||||||
Reference in New Issue
Block a user