diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..812d2b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Groom Book — Environment Variables +# Copy this file to .env and adjust values for your deployment. + +# ── Database ────────────────────────────────────────────────────────────────── +DATABASE_URL=postgres://groombook:groombook@postgres:5432/groombook + +# ── Authentication ──────────────────────────────────────────────────────────── +# Set AUTH_DISABLED=true to skip OIDC validation (useful for local dev/Docker). +# In production, configure an Authentik instance and set these values. +AUTH_DISABLED=false +OIDC_ISSUER=https://authentik.example.com +OIDC_AUDIENCE=groombook + +# ── API ─────────────────────────────────────────────────────────────────────── +PORT=3000 +CORS_ORIGIN=http://localhost:8080 diff --git a/README.md b/README.md index 4af8d04..4f8852e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,49 @@ pnpm build ## Self-Hosting +### Docker Compose (recommended for single-server deployments) + +The fastest way to run Groom Book is with Docker Compose. This starts PostgreSQL, runs database migrations, and serves both the API and web frontend. + +```bash +git clone https://github.com/groombook/groombook.git +cd groombook + +# Start everything (Postgres + migrate + API + web) +docker compose up --build +``` + +- **Web UI**: http://localhost:8080 +- **API**: http://localhost:3000 + +The default `docker-compose.yml` sets `AUTH_DISABLED=true` so you can explore the app without configuring an OIDC provider. **Disable this in any internet-facing deployment.** + +#### Production configuration + +Copy `.env.example` to `.env` and configure: + +```bash +cp .env.example .env +``` + +Key variables to update for production: + +| Variable | Description | +|---|---| +| `DATABASE_URL` | PostgreSQL connection string | +| `AUTH_DISABLED` | Set to `false` in production | +| `OIDC_ISSUER` | Authentik issuer URL | +| `OIDC_AUDIENCE` | OAuth2 audience (default: `groombook`) | +| `CORS_ORIGIN` | Public URL of the web frontend | + +To use your `.env` file with Docker Compose: + +```bash +docker compose --env-file .env up --build +``` + +### Kubernetes (production-grade deployments) + 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: diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index be7d949..6d8d03d 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app # Install deps FROM base AS deps -COPY package.json pnpm-workspace.yaml ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/api/package.json apps/api/ COPY packages/db/package.json packages/db/ COPY packages/types/package.json packages/types/ @@ -22,7 +22,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR /app ENV NODE_ENV=production -COPY package.json pnpm-workspace.yaml ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/api/package.json apps/api/ COPY packages/db/package.json packages/db/ COPY packages/types/package.json packages/types/ @@ -34,3 +34,7 @@ COPY --from=builder /app/packages/types packages/types EXPOSE 3000 CMD ["node", "apps/api/dist/index.js"] + +# Migrate stage — runs drizzle-kit migrate against the database +FROM builder AS migrate +CMD ["pnpm", "db:migrate"] diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 7444b18..82d7683 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -24,6 +24,12 @@ export interface JwtPayload { } export const authMiddleware: MiddlewareHandler = async (c, next) => { + if (process.env.AUTH_DISABLED === "true") { + c.set("jwtPayload", { sub: "dev-user" } as JwtPayload); + await next(); + return; + } + const authorization = c.req.header("Authorization"); if (!authorization?.startsWith("Bearer ")) { return c.json({ error: "Unauthorized" }, 401); diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8433fdb..704730b 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app # Install deps FROM base AS deps -COPY package.json pnpm-workspace.yaml ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/web/package.json apps/web/ COPY packages/types/package.json packages/types/ RUN pnpm install --frozen-lockfile diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index d09ed8d..89955f0 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -9,6 +9,15 @@ server { add_header Cache-Control "public, immutable"; } + # Proxy API calls to the API service + location /api/ { + proxy_pass http://api:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # SPA fallback — serve index.html for all routes location / { try_files $uri $uri/ /index.html; diff --git a/docker-compose.yml b/docker-compose.yml index 9afdb10..43ba637 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,18 @@ services: timeout: 5s retries: 5 + migrate: + build: + context: . + dockerfile: apps/api/Dockerfile + target: migrate + environment: + DATABASE_URL: postgres://groombook:groombook@postgres:5432/groombook + depends_on: + postgres: + condition: service_healthy + restart: "no" + api: build: context: . @@ -23,12 +35,14 @@ services: - "3000:3000" environment: DATABASE_URL: postgres://groombook:groombook@postgres:5432/groombook - OIDC_ISSUER: http://authentik:9000 - OIDC_AUDIENCE: groombook - CORS_ORIGIN: http://localhost:5173 + AUTH_DISABLED: "true" + CORS_ORIGIN: http://localhost:8080 + PORT: "3000" depends_on: postgres: condition: service_healthy + migrate: + condition: service_completed_successfully web: build: diff --git a/packages/db/migrations/0000_colossal_colossus.sql b/packages/db/migrations/0000_colossal_colossus.sql new file mode 100644 index 0000000..c89c3b4 --- /dev/null +++ b/packages/db/migrations/0000_colossal_colossus.sql @@ -0,0 +1,70 @@ +CREATE TYPE "public"."appointment_status" AS ENUM('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show');--> statement-breakpoint +CREATE TYPE "public"."staff_role" AS ENUM('groomer', 'receptionist', 'manager');--> statement-breakpoint +CREATE TABLE "appointments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "pet_id" uuid NOT NULL, + "service_id" uuid NOT NULL, + "staff_id" uuid, + "status" "appointment_status" DEFAULT 'scheduled' NOT NULL, + "start_time" timestamp NOT NULL, + "end_time" timestamp NOT NULL, + "notes" text, + "price_cents" integer, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "email" text, + "phone" text, + "address" text, + "notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "name" text NOT NULL, + "species" text NOT NULL, + "breed" text, + "weight_kg" numeric(5, 2), + "date_of_birth" timestamp, + "grooming_notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "services" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "base_price_cents" integer NOT NULL, + "duration_minutes" integer NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "staff" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "oidc_sub" text, + "role" "staff_role" DEFAULT 'groomer' NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "staff_email_unique" UNIQUE("email"), + CONSTRAINT "staff_oidc_sub_unique" UNIQUE("oidc_sub") +); +--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pets" ADD CONSTRAINT "pets_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..c77f50a --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,485 @@ +{ + "id": "477cddf9-970f-41c5-9cad-c1ed48c2bedf", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "species": { + "name": "species", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "breed": { + "name": "breed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "grooming_notes": { + "name": "grooming_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pets_client_id_clients_id_fk": { + "name": "pets_client_id_clients_id_fk", + "tableFrom": "pets", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..9078c97 --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1773771452946, + "tag": "0000_colossal_colossus", + "breakpoints": true + } + ] +} \ No newline at end of file