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
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@groombook/db",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.38.4",
"postgres": "^3.4.5"
},
"devDependencies": {
"drizzle-kit": "^0.30.4",
"typescript": "^5.7.3"
}
}
+18
View File
@@ -0,0 +1,18 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";
export * from "./schema.js";
let _db: ReturnType<typeof drizzle> | null = null;
export function getDb() {
if (_db) return _db;
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
const client = postgres(url, { max: 10 });
_db = drizzle(client, { schema });
return _db;
}
export type Db = ReturnType<typeof getDb>;
+102
View File
@@ -0,0 +1,102 @@
import {
boolean,
integer,
numeric,
pgEnum,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
// ─── Enums ────────────────────────────────────────────────────────────────────
export const appointmentStatusEnum = pgEnum("appointment_status", [
"scheduled",
"confirmed",
"in_progress",
"completed",
"cancelled",
"no_show",
]);
export const staffRoleEnum = pgEnum("staff_role", [
"groomer",
"receptionist",
"manager",
]);
// ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable("clients", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
address: text("address"),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pets = pgTable("pets", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
groomingNotes: text("grooming_notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
description: text("description"),
basePriceCents: integer("base_price_cents").notNull(),
durationMinutes: integer("duration_minutes").notNull(),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const staff = pgTable("staff", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
// oidcSub links to the Authentik OIDC subject claim
oidcSub: text("oidc_sub").unique(),
role: staffRoleEnum("role").notNull().default("groomer"),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const appointments = pgTable("appointments", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "restrict" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "restrict" }),
staffId: uuid("staff_id").references(() => staff.id, {
onDelete: "set null",
}),
status: appointmentStatusEnum("status").notNull().default("scheduled"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src", "drizzle.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@groombook/types",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
+77
View File
@@ -0,0 +1,77 @@
// Shared domain types for Groom Book
export type AppointmentStatus =
| "scheduled"
| "confirmed"
| "in_progress"
| "completed"
| "cancelled"
| "no_show";
export interface Client {
id: string;
name: string;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface Pet {
id: string;
clientId: string;
name: string;
species: string;
breed: string | null;
weightKg: number | null;
dateOfBirth: string | null;
groomingNotes: string | null;
createdAt: string;
updatedAt: string;
}
export interface Service {
id: string;
name: string;
description: string | null;
basePriceCents: number;
durationMinutes: number;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface Staff {
id: string;
name: string;
email: string;
role: "groomer" | "receptionist" | "manager";
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface Appointment {
id: string;
clientId: string;
petId: string;
serviceId: string;
staffId: string | null;
status: AppointmentStatus;
startTime: string;
endTime: string;
notes: string | null;
priceCents: number | null;
createdAt: string;
updatedAt: string;
}
// Paginated list response
export interface PaginatedList<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
},
"include": ["src"]
}