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:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user