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
+36
View File
@@ -0,0 +1,36 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Install deps
FROM base AS deps
COPY package.json pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
COPY packages/db/package.json packages/db/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
COPY packages/ packages/
COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/api build
# Runtime
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
COPY packages/db/package.json packages/db/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile --prod
COPY --from=builder /app/apps/api/dist apps/api/dist
COPY --from=builder /app/packages/db packages/db
COPY --from=builder /app/packages/types packages/types
EXPOSE 3000
CMD ["node", "apps/api/dist/index.js"]
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@groombook/api",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@groombook/db": "workspace:*",
"@groombook/types": "workspace:*",
"@hono/node-server": "^1.13.7",
"hono": "^4.6.17",
"openid-client": "^6.1.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.7",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^3.0.4"
}
}
+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 });
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Install deps
FROM base AS deps
COPY package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
COPY packages/types/ packages/types/
COPY apps/web/ apps/web/
RUN pnpm --filter @groombook/web build
# Serve with nginx
FROM nginx:alpine AS runner
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4f8a6f" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Groom Book</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@groombook/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@groombook/types": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.2"
},
"devDependencies": {
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.18.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.4"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Routes, Route, Link } from "react-router-dom";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ServicesPage } from "./pages/Services.js";
export function App() {
return (
<div>
<nav style={{ padding: "1rem", borderBottom: "1px solid #e2e8f0" }}>
<strong style={{ marginRight: "1.5rem" }}>Groom Book</strong>
<Link to="/" style={{ marginRight: "1rem" }}>
Appointments
</Link>
<Link to="/clients" style={{ marginRight: "1rem" }}>
Clients
</Link>
<Link to="/services">Services</Link>
</nav>
<main style={{ padding: "1rem" }}>
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/services" element={<ServicesPage />} />
</Routes>
</main>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #1a202c;
background: #f7fafc;
}
a {
color: #4f8a6f;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 1.5rem;
margin-top: 0;
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import type { Appointment } from "@groombook/types";
export function AppointmentsPage() {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/appointments")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Appointment[]>;
})
.then(setAppointments)
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Unknown error")
)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading appointments</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<div>
<h1>Appointments</h1>
{appointments.length === 0 ? (
<p>No appointments yet.</p>
) : (
<ul>
{appointments.map((a) => (
<li key={a.id}>
{new Date(a.startTime).toLocaleString()} {a.status}
</li>
))}
</ul>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import type { Client } from "@groombook/types";
export function ClientsPage() {
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/clients")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Client[]>;
})
.then(setClients)
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Unknown error")
)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading clients</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<div>
<h1>Clients</h1>
{clients.length === 0 ? (
<p>No clients yet.</p>
) : (
<ul>
{clients.map((c) => (
<li key={c.id}>
{c.name} {c.email ? `${c.email}` : ""}
</li>
))}
</ul>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import type { Service } from "@groombook/types";
export function ServicesPage() {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/services")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Service[]>;
})
.then(setServices)
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Unknown error")
)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading services</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<div>
<h1>Services</h1>
{services.length === 0 ? (
<p>No services configured yet.</p>
) : (
<ul>
{services.map((s) => (
<li key={s.id}>
{s.name} ${(s.basePriceCents / 100).toFixed(2)} /{" "}
{s.durationMinutes} min
</li>
))}
</ul>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
},
"include": ["src"]
}
+66
View File
@@ -0,0 +1,66 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg", "apple-touch-icon.png"],
manifest: {
name: "Groom Book",
short_name: "GroomBook",
description: "Pet grooming business management",
theme_color: "#4f8a6f",
background_color: "#ffffff",
display: "standalone",
scope: "/",
start_url: "/",
icons: [
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
runtimeCaching: [
{
urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
},
},
],
},
}),
],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
});