Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32156e9a45 | |||
| ed3d7df1c9 | |||
| 385ed10211 | |||
| 8e8a87767c | |||
| d9ba6045ad | |||
| 2f17b1ab85 | |||
| b83a793de4 | |||
| a610ef9d39 |
@@ -0,0 +1 @@
|
|||||||
|
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
|
||||||
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main, dev, uat]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev, uat]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
@@ -96,7 +96,6 @@ 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' || '' }}
|
||||||
@@ -111,7 +110,6 @@ 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' || '' }}
|
||||||
@@ -126,7 +124,6 @@ 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' || '' }}
|
||||||
@@ -141,7 +138,6 @@ 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' || '' }}
|
||||||
|
|||||||
+131
-1
@@ -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, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
|
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -283,3 +283,133 @@ 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -127,15 +127,14 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
|
|
||||||
if (oidcAccount) {
|
if (oidcAccount) {
|
||||||
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
|
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
|
||||||
const name =
|
const emailPrefix = jwt.email.split("@")[0] ?? "Unknown";
|
||||||
jwt.name?.trim() ||
|
const name = jwt.name?.trim() || emailPrefix;
|
||||||
(jwt.email ? jwt.email.split("@")[0] : "Unknown");
|
|
||||||
|
|
||||||
const [newStaff] = await db
|
const [newStaff] = await db
|
||||||
.insert(staff)
|
.insert(staff)
|
||||||
.values({
|
.values({
|
||||||
userId: jwt.sub,
|
userId: jwt.sub,
|
||||||
email: jwt.email ?? "",
|
email: jwt.email,
|
||||||
name,
|
name,
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
@@ -143,6 +142,10 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (!newStaff) {
|
||||||
|
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
|
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user