Bootstrap monorepo: Hono API, React PWA, Drizzle DB, CI/CD

Sets up the initial project structure for groombook/groombook:

- pnpm monorepo with apps/api (Hono + TypeScript), apps/web (React + Vite + PWA), packages/db (Drizzle ORM), packages/types (shared types)
- Core DB schema: clients, pets, services, appointments, staff with CNPG-compatible Postgres
- REST API routes for clients, pets, services, appointments with Zod validation
- OIDC auth middleware for Authentik integration
- React PWA with vite-plugin-pwa, service worker, offline caching, installable manifest
- GitHub Actions CI: lint, typecheck, test, build, Docker image build (groombook-runners)
- Dockerfiles for API (Node.js) and Web (nginx)
- docker-compose.yml for local development

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Groom Book CTO
2026-03-17 16:09:55 +00:00
parent 00876d13af
commit a36436d128
36 changed files with 1419 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { authMiddleware } from "./middleware/auth.js";
const app = new Hono();
// Global middleware
app.use("*", logger());
app.use(
"/api/*",
cors({
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
credentials: true,
})
);
// Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" }));
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
api.route("/clients", clientsRouter);
api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
api.route("/appointments", appointmentsRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
serve({ fetch: app.fetch, port });
export default app;
+45
View File
@@ -0,0 +1,45 @@
import type { MiddlewareHandler } from "hono";
import { createRemoteJWKSet, jwtVerify } from "jose";
// Authentik OIDC configuration — loaded from env at startup
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE;
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks() {
if (!OIDC_ISSUER) throw new Error("OIDC_ISSUER is not set");
if (!jwks) {
jwks = createRemoteJWKSet(
new URL(`${OIDC_ISSUER}/application/o/groombook/jwks/`)
);
}
return jwks;
}
export interface JwtPayload {
sub: string;
email?: string;
name?: string;
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
const authorization = c.req.header("Authorization");
if (!authorization?.startsWith("Bearer ")) {
return c.json({ error: "Unauthorized" }, 401);
}
const token = authorization.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(), {
issuer: OIDC_ISSUER,
audience: OIDC_AUDIENCE,
});
c.set("jwtPayload", payload as JwtPayload);
await next();
} catch {
return c.json({ error: "Invalid or expired token" }, 401);
}
};
+108
View File
@@ -0,0 +1,108 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { and, eq, gte, lte } from "drizzle-orm";
import { getDb, appointments } from "@groombook/db";
export const appointmentsRouter = new Hono();
const createAppointmentSchema = z.object({
clientId: z.string().uuid(),
petId: z.string().uuid(),
serviceId: z.string().uuid(),
staffId: z.string().uuid().optional(),
startTime: z.string().datetime(),
endTime: z.string().datetime(),
notes: z.string().max(2000).optional(),
priceCents: z.number().int().positive().optional(),
});
const updateAppointmentSchema = z.object({
staffId: z.string().uuid().nullable().optional(),
status: z
.enum([
"scheduled",
"confirmed",
"in_progress",
"completed",
"cancelled",
"no_show",
])
.optional(),
startTime: z.string().datetime().optional(),
endTime: z.string().datetime().optional(),
notes: z.string().max(2000).nullable().optional(),
priceCents: z.number().int().positive().nullable().optional(),
});
// List appointments, optionally filtered by date range
appointmentsRouter.get("/", async (c) => {
const db = getDb();
const from = c.req.query("from");
const to = c.req.query("to");
const conditions = [];
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
const rows =
conditions.length > 0
? await db
.select()
.from(appointments)
.where(and(...conditions))
.orderBy(appointments.startTime)
: await db
.select()
.from(appointments)
.orderBy(appointments.startTime);
return c.json(rows);
});
appointmentsRouter.get("/:id", async (c) => {
const db = getDb();
const [row] = await db
.select()
.from(appointments)
.where(eq(appointments.id, c.req.param("id")));
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
});
appointmentsRouter.post(
"/",
zValidator("json", createAppointmentSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db
.insert(appointments)
.values({
...body,
startTime: new Date(body.startTime),
endTime: new Date(body.endTime),
})
.returning();
return c.json(row, 201);
}
);
appointmentsRouter.patch(
"/:id",
zValidator("json", updateAppointmentSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
if (body.startTime) update.startTime = new Date(body.startTime);
if (body.endTime) update.endTime = new Date(body.endTime);
const [row] = await db
.update(appointments)
.set(update)
.where(eq(appointments.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
+71
View File
@@ -0,0 +1,71 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { getDb, clients } from "@groombook/db";
export const clientsRouter = new Hono();
const createClientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(),
});
const updateClientSchema = createClientSchema.partial();
// List all clients
clientsRouter.get("/", async (c) => {
const db = getDb();
const rows = await db.select().from(clients).orderBy(clients.name);
return c.json(rows);
});
// Get a single client
clientsRouter.get("/:id", async (c) => {
const db = getDb();
const [row] = await db
.select()
.from(clients)
.where(eq(clients.id, c.req.param("id")));
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
});
// Create a client
clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db.insert(clients).values(body).returning();
return c.json(row, 201);
});
// Update a client
clientsRouter.patch(
"/:id",
zValidator("json", updateClientSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db
.update(clients)
.set({ ...body, updatedAt: new Date() })
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
// Delete a client
clientsRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
.delete(clients)
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+74
View File
@@ -0,0 +1,74 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { getDb, pets } from "@groombook/db";
export const petsRouter = new Hono();
const createPetSchema = z.object({
clientId: z.string().uuid(),
name: z.string().min(1).max(200),
species: z.string().min(1).max(100),
breed: z.string().max(200).optional(),
weightKg: z.number().positive().optional(),
dateOfBirth: z.string().datetime().optional(),
groomingNotes: z.string().max(2000).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
petsRouter.get("/", async (c) => {
const db = getDb();
const clientId = c.req.query("clientId");
const query = db.select().from(pets);
if (clientId) {
const rows = await query.where(eq(pets.clientId, clientId));
return c.json(rows);
}
const rows = await query;
return c.json(rows);
});
petsRouter.get("/:id", async (c) => {
const db = getDb();
const [row] = await db
.select()
.from(pets)
.where(eq(pets.id, c.req.param("id")));
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
});
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db.insert(pets).values(body).returning();
return c.json(row, 201);
});
petsRouter.patch(
"/:id",
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db
.update(pets)
.set({ ...body, updatedAt: new Date() })
.where(eq(pets.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
petsRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
.delete(pets)
.where(eq(pets.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+74
View File
@@ -0,0 +1,74 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { getDb, services } from "@groombook/db";
export const servicesRouter = new Hono();
const createServiceSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive(),
active: z.boolean().default(true),
});
const updateServiceSchema = createServiceSchema.partial();
servicesRouter.get("/", async (c) => {
const db = getDb();
const includeInactive = c.req.query("includeInactive") === "true";
const query = db.select().from(services).orderBy(services.name);
const rows = includeInactive
? await query
: await query.where(eq(services.active, true));
return c.json(rows);
});
servicesRouter.get("/:id", async (c) => {
const db = getDb();
const [row] = await db
.select()
.from(services)
.where(eq(services.id, c.req.param("id")));
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
});
servicesRouter.post(
"/",
zValidator("json", createServiceSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db.insert(services).values(body).returning();
return c.json(row, 201);
}
);
servicesRouter.patch(
"/:id",
zValidator("json", updateServiceSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db
.update(services)
.set({ ...body, updatedAt: new Date() })
.where(eq(services.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
servicesRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
.delete(services)
.where(eq(services.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});