diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b775d10 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-typecheck: + name: Lint & Typecheck + runs-on: groombook-runners + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + test: + name: Test + runs-on: groombook-runners + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + build: + name: Build + runs-on: groombook-runners + needs: [lint-typecheck, test] + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm build + + docker: + name: Build Docker Images + runs-on: groombook-runners + needs: [build] + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build API image + uses: docker/build-push-action@v6 + with: + context: . + file: apps/api/Dockerfile + push: false + tags: groombook/api:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build Web image + uses: docker/build-push-action@v6 + with: + context: . + file: apps/web/Dockerfile + push: false + tags: groombook/web:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b826df6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +.env.local +*.local +.DS_Store +*.log +.turbo/ +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4af8d04 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Groom Book + +Open source, self-hostable pet grooming business management and customer relationship platform. + +## Features + +- **Appointment scheduling** — calendar management for single or multiple groomers +- **Client & pet records** — detailed profiles with grooming history and preferences +- **Service management** — pricing, duration, and service catalog +- **Online booking portal** — customer-facing self-service booking +- **POS & invoicing** — payments, tips, and receipt generation +- **Automated reminders** — SMS and email notifications +- **Reporting dashboard** — revenue, utilization, and trend analytics +- **PWA** — installable on mobile devices, works offline + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend | [Hono](https://hono.dev/) (TypeScript, Node.js) | +| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) | +| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) | +| Auth | OIDC via [Authentik](https://goauthentik.io/) | +| Infra | Kubernetes (namespace: `groombook`), Flux GitOps | +| CI | GitHub Actions (self-hosted `groombook-runners`) | + +## Repository Structure + +``` +groombook/ +├── apps/ +│ ├── api/ # Hono REST API +│ └── web/ # React PWA +├── packages/ +│ ├── db/ # Drizzle schema + migrations +│ └── types/ # Shared TypeScript types +├── .github/ +│ └── workflows/ # CI/CD pipelines +└── docker-compose.yml +``` + +## Getting Started + +### Prerequisites + +- Node.js >= 20 +- pnpm >= 9 (`npm install -g pnpm`) +- Docker & Docker Compose (for local Postgres) + +### Local Development + +```bash +# Clone the repo +git clone https://github.com/groombook/groombook.git +cd groombook + +# Install dependencies +pnpm install + +# Start local Postgres +docker compose up postgres -d + +# Run database migrations +DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook pnpm db:migrate + +# Start API and Web in parallel +pnpm dev +``` + +API will be available at http://localhost:3000 +Web will be available at http://localhost:5173 + +### Environment Variables + +#### API (`apps/api/.env`) + +```env +DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook +OIDC_ISSUER=https://authentik.example.com +OIDC_AUDIENCE=groombook +CORS_ORIGIN=http://localhost:5173 +PORT=3000 +``` + +### Running Tests + +```bash +pnpm test +``` + +### Building + +```bash +pnpm build +``` + +## Self-Hosting + +See the [groombook/infra](https://github.com/groombook/infra) repository for Kubernetes manifests and Flux configuration. + +Groom Book is deployed in the `groombook` Kubernetes namespace using: +- **CNPG** for PostgreSQL +- **Authentik** for OIDC authentication +- **Flux** for GitOps-managed deployments + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/my-feature`) +3. Commit your changes +4. Open a pull request + +All PRs require CI to pass before merge. + +## License + +MIT diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..be7d949 --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..071a656 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..93e317d --- /dev/null +++ b/apps/api/src/index.ts @@ -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; diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..7444b18 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -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 | 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); + } +}; diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts new file mode 100644 index 0000000..ef55b83 --- /dev/null +++ b/apps/api/src/routes/appointments.ts @@ -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 = { ...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); + } +); diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts new file mode 100644 index 0000000..b1d9f7c --- /dev/null +++ b/apps/api/src/routes/clients.ts @@ -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 }); +}); diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts new file mode 100644 index 0000000..73cb42a --- /dev/null +++ b/apps/api/src/routes/pets.ts @@ -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 }); +}); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts new file mode 100644 index 0000000..1f9315a --- /dev/null +++ b/apps/api/src/routes/services.ts @@ -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 }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..3b421a7 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..8433fdb --- /dev/null +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..998338f --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Groom Book + + +
+ + + diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..d09ed8d --- /dev/null +++ b/apps/web/nginx.conf @@ -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; + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..5b65440 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..90cc38b --- /dev/null +++ b/apps/web/src/App.tsx @@ -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 ( +
+ +
+ + } /> + } /> + } /> + +
+
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..83e7286 --- /dev/null +++ b/apps/web/src/index.css @@ -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; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..b683e11 --- /dev/null +++ b/apps/web/src/main.tsx @@ -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( + + + + + +); diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx new file mode 100644 index 0000000..f90250a --- /dev/null +++ b/apps/web/src/pages/Appointments.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { Appointment } from "@groombook/types"; + +export function AppointmentsPage() { + const [appointments, setAppointments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/appointments") + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then(setAppointments) + .catch((e: unknown) => + setError(e instanceof Error ? e.message : "Unknown error") + ) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

Loading appointments…

; + if (error) return

Error: {error}

; + + return ( +
+

Appointments

+ {appointments.length === 0 ? ( +

No appointments yet.

+ ) : ( +
    + {appointments.map((a) => ( +
  • + {new Date(a.startTime).toLocaleString()} — {a.status} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx new file mode 100644 index 0000000..c8a5104 --- /dev/null +++ b/apps/web/src/pages/Clients.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { Client } from "@groombook/types"; + +export function ClientsPage() { + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/clients") + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then(setClients) + .catch((e: unknown) => + setError(e instanceof Error ? e.message : "Unknown error") + ) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

Loading clients…

; + if (error) return

Error: {error}

; + + return ( +
+

Clients

+ {clients.length === 0 ? ( +

No clients yet.

+ ) : ( +
    + {clients.map((c) => ( +
  • + {c.name} {c.email ? `— ${c.email}` : ""} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx new file mode 100644 index 0000000..901bd2e --- /dev/null +++ b/apps/web/src/pages/Services.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import type { Service } from "@groombook/types"; + +export function ServicesPage() { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/services") + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then(setServices) + .catch((e: unknown) => + setError(e instanceof Error ? e.message : "Unknown error") + ) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

Loading services…

; + if (error) return

Error: {error}

; + + return ( +
+

Services

+ {services.length === 0 ? ( +

No services configured yet.

+ ) : ( +
    + {services.map((s) => ( +
  • + {s.name} — ${(s.basePriceCents / 100).toFixed(2)} /{" "} + {s.durationMinutes} min +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..c7a855a --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..a810603 --- /dev/null +++ b/apps/web/vite.config.ts @@ -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, + }, + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9afdb10 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: groombook + POSTGRES_USER: groombook + POSTGRES_PASSWORD: groombook + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U groombook"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: . + dockerfile: apps/api/Dockerfile + ports: + - "3000:3000" + environment: + DATABASE_URL: postgres://groombook:groombook@postgres:5432/groombook + OIDC_ISSUER: http://authentik:9000 + OIDC_AUDIENCE: groombook + CORS_ORIGIN: http://localhost:5173 + depends_on: + postgres: + condition: service_healthy + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + ports: + - "8080:80" + depends_on: + - api + +volumes: + postgres_data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..76eb426 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "groombook", + "private": true, + "version": "0.0.1", + "description": "Open source, self-hostable pet grooming business management platform", + "scripts": { + "dev": "pnpm --parallel -r dev", + "build": "pnpm -r build", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "db:migrate": "pnpm --filter @groombook/db migrate" + }, + "engines": { + "node": ">=20", + "pnpm": ">=9" + }, + "packageManager": "pnpm@9.15.4" +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..16a96b5 --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -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!, + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..e4af333 --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..d161520 --- /dev/null +++ b/packages/db/src/index.ts @@ -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 | 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; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..17023a5 --- /dev/null +++ b/packages/db/src/schema.ts @@ -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(), +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..0623b1a --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "./dist" + }, + "include": ["src", "drizzle.config.ts"] +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..3cb32fc --- /dev/null +++ b/packages/types/package.json @@ -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" + } +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..f92424e --- /dev/null +++ b/packages/types/src/index.ts @@ -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 { + items: T[]; + total: number; + page: number; + pageSize: number; +} diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..4df90ff --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*"