Merge pull request #14 from groombook/bootstrap/initial-scaffold
Bootstrap monorepo: Hono API, React PWA, Drizzle DB, CI/CD
This commit was merged in pull request #14.
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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!,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
Reference in New Issue
Block a user