From e824be7e5d8fd80e42910e381749bc8dfaad6278 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Tue, 7 Jul 2026 22:30:18 -0600 Subject: [PATCH] Add Node.js/Express API service for booking backend - New api/ service: Express + better-sqlite3, health check at GET /api/health, POST /api/bookings stores sessions with all required fields - Dockerfile with Alpine build deps for native sqlite3 module - docker-compose.yml: api service with volume mounts for data, signed-contracts, contracts - nginx.conf: proxy /api/ to api:3001; Immich regex locations remain higher priority - .gitignore: exclude SQLite db file and signed PDFs from version control Closes #1 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 +++++ api/.env.example | 21 +++++++++++++++++++++ api/Dockerfile | 16 ++++++++++++++++ api/contracts/.gitkeep | 0 api/data/.gitkeep | 0 api/db.js | 31 +++++++++++++++++++++++++++++++ api/package.json | 15 +++++++++++++++ api/server.js | 35 +++++++++++++++++++++++++++++++++++ api/signed-contracts/.gitkeep | 0 docker-compose.yml | 26 ++++++++++++++++++++------ nginx.conf | 12 ++++++++++++ 11 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/contracts/.gitkeep create mode 100644 api/data/.gitkeep create mode 100644 api/db.js create mode 100644 api/package.json create mode 100644 api/server.js create mode 100644 api/signed-contracts/.gitkeep diff --git a/.gitignore b/.gitignore index 3b40d76..8593341 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ config/local.json # Docker docker-compose.override.yml + +# API runtime data +api/data/*.db +api/signed-contracts/*.pdf +api/.env diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..4ad6850 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,21 @@ +PORT=3001 +CORS_ORIGIN=http://localhost:8080 + +# n8n webhook for email notifications +N8N_WEBHOOK_URL= + +# Google Calendar (issue #3) +GOOGLE_CALENDAR_ID= +GOOGLE_SERVICE_ACCOUNT_JSON= + +# Contract PDF signature placement (issue #6) +CONTRACT_SIG_X=100 +CONTRACT_SIG_Y=700 +CONTRACT_SIG_PAGE=1 + +# OIDC / Authentik (issue #10) +SESSION_SECRET= +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI= diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..41a5bbd --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine + +RUN apk add --no-cache python3 make g++ + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --only=production + +COPY . . + +RUN mkdir -p data signed-contracts contracts + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/api/contracts/.gitkeep b/api/contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/api/data/.gitkeep b/api/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/api/db.js b/api/db.js new file mode 100644 index 0000000..263f3a3 --- /dev/null +++ b/api/db.js @@ -0,0 +1,31 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const dataDir = path.join(__dirname, 'data'); +if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + +const db = new Database(path.join(dataDir, 'bookings.db')); + +db.exec(` + CREATE TABLE IF NOT EXISTS bookings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT DEFAULT (datetime('now')), + client_name TEXT, + client_email TEXT, + client_phone TEXT, + client_sub TEXT, + session_date TEXT, + session_type TEXT, + session_length TEXT, + location TEXT, + contract_signed_at TEXT, + contract_pdf_path TEXT, + payment_status TEXT DEFAULT 'pending', + payment_notified_at TEXT, + status TEXT DEFAULT 'pending', + notes TEXT + ) +`); + +module.exports = db; diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..aad12e0 --- /dev/null +++ b/api/package.json @@ -0,0 +1,15 @@ +{ + "name": "lisilou-api", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "better-sqlite3": "^9.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.3" + } +} diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..17cd692 --- /dev/null +++ b/api/server.js @@ -0,0 +1,35 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const db = require('./db'); + +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); +app.use(express.json({ limit: '10mb' })); + +app.get('/api/health', (req, res) => { + res.json({ ok: true }); +}); + +app.post('/api/bookings', (req, res) => { + const { + client_name, client_email, client_phone, client_sub, + session_date, session_type, session_length, location, + } = req.body; + + if (!client_email || !session_date || !session_type || !session_length) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const result = db.prepare(` + INSERT INTO bookings + (client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(client_name, client_email, client_phone, client_sub, session_date, session_type, session_length, location); + + res.status(201).json({ id: result.lastInsertRowid }); +}); + +app.listen(PORT, () => console.log(`API listening on port ${PORT}`)); diff --git a/api/signed-contracts/.gitkeep b/api/signed-contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index e449d8b..1e08173 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,12 +25,26 @@ services: networks: - web - # Optional: Watchtower for automatic updates - # watchtower: - # image: containrrr/watchtower - # volumes: - # - /var/run/docker.sock:/var/run/docker.sock - # command: --interval 300 lisilou-portfolio + api: + build: + context: ./api + dockerfile: Dockerfile + container_name: lisilou-api + restart: unless-stopped + env_file: + - ./api/.env + volumes: + - ./api/data:/app/data + - ./api/signed-contracts:/app/signed-contracts + - ./api/contracts:/app/contracts:ro + networks: + - web + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s networks: web: diff --git a/nginx.conf b/nginx.conf index ccd8e22..a82b5c9 100644 --- a/nginx.conf +++ b/nginx.conf @@ -86,6 +86,18 @@ http { proxy_ssl_server_name on; } + # Booking API — proxy to Node.js service + # Immich regex locations above take priority for /api/immich-proxy and /api/fetch-immich/ + location /api/ { + proxy_pass http://api:3001; + 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; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } + # Proxy endpoint for Immich share pages and API location ~ ^/api/fetch-immich/(.+)$ { resolver 8.8.8.8 1.1.1.1 valid=300s;