diff --git a/.env.alpha b/.env.alpha index 255f919..f353be5 100644 --- a/.env.alpha +++ b/.env.alpha @@ -12,4 +12,6 @@ PUBLIC_API_BASE_URL=https://clients.lean-101.com.au INTERNAL_API_BASE_URL=http://backend:8000 CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au CLIENTS_APP_PORT=808 +PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false +PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false DATABASE_URL=sqlite:////data/data_entry_app.db diff --git a/.env.alpha.example b/.env.alpha.example index a230ba5..b828df4 100644 --- a/.env.alpha.example +++ b/.env.alpha.example @@ -12,4 +12,6 @@ PUBLIC_API_BASE_URL=https://clients.lean-101.com.au INTERNAL_API_BASE_URL=http://backend:8000 CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au CLIENTS_APP_PORT=8081 +PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false +PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false DATABASE_URL=sqlite:////data/data_entry_app.db diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..d47a5e0 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,26 @@ +APP_NAME=Lean 101 Clients API +CLIENT_NAME=Hunter Premium Produce +CLIENT_EMAIL=operator@example.com +CLIENT_PASSWORD=replace-with-strong-password +CLIENT_TENANT_ID=hunter-premium-produce +ADMIN_NAME=Lean 101 +ADMIN_EMAIL=admin@lean101.local +ADMIN_PASSWORD=replace-with-strong-password +AUTH_SECRET=replace-with-a-long-random-secret + +# Postgres credentials. The compose file builds DATABASE_URL from these +# so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL +# only if you want to point at a managed Postgres outside the compose stack. +POSTGRES_USER=lean101 +POSTGRES_PASSWORD=replace-with-a-long-random-password +POSTGRES_DB=lean101 +# DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME + +ORIGIN=https://clients.lean-101.com.au +PUBLIC_API_BASE_URL=https://clients.lean-101.com.au +INTERNAL_API_BASE_URL=http://backend:8000 +CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au +CLIENTS_APP_PORT=8081 + +PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false +PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false diff --git a/.gitignore b/.gitignore index 0025d09..3975dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ frontend/node_modules/ *.pyd *.sqlite3 *.db +.env.production +.env.alpha diff --git a/README.md b/README.md index 968bbba..f45a4d8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,40 @@ This follows the same external pattern as your website container: If your server already has a host-level nginx handling domains and TLS, use `deploy/nginx/clients.lean-101.proxy.conf` as the upstream template and point the domain at `http://127.0.0.1:8081`. +## Production deployment (Postgres + Digital Ocean) + +`docker-compose.production.yml` provisions a managed-style stack with a containerised Postgres 16 service replacing the SQLite file used in alpha. The PowerShell script `deploy/Deploy.ps1` drives both first-time bootstrap and incremental updates from a Windows workstation against a Digital Ocean droplet. + +1. **One-time droplet prep**: install Docker Engine + the compose plugin (`apt install docker.io docker-compose-plugin` or the official Docker repo). Open inbound 80/443 on your reverse proxy and forward to `127.0.0.1:${CLIENTS_APP_PORT}` (default 8081). +2. **Local secrets**: copy the example env and fill in real secrets — strong `POSTGRES_PASSWORD`, `AUTH_SECRET`, `CLIENT_PASSWORD`, `ADMIN_PASSWORD`. The compose file refuses to start without them. + + ```powershell + Copy-Item .env.production.example .env.production + notepad .env.production + ``` + +3. **First deploy** (clones the repo on the droplet, uploads the env file, brings the stack up): + + ```powershell + ./deploy/Deploy.ps1 ` + -RemoteHost 203.0.113.10 ` + -Bootstrap ` + -RepoUrl git@github.com:ponzischeme89/data-entry-app.git ` + -Seed + ``` + +4. **Subsequent updates** (the same script — pulls latest `main`, rebuilds, and rolls containers without touching the Postgres volume): + + ```powershell + ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 + ``` + + Useful flags: `-Branch ` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key. + +5. **Database**: the backend reads `DATABASE_URL`. The production compose file synthesises it as `postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}` so you only need to set the three `POSTGRES_*` vars. Override `DATABASE_URL` directly if you point at a managed Postgres (e.g. DigitalOcean managed databases). + +The schema is auto-managed — `app/db/migrations.py` runs at backend startup and is idempotent across SQLite and Postgres. To migrate alpha SQLite data into the new Postgres instance, dump tables to CSV from the alpha container and import via `\copy` in `psql`; there is no automatic SQLite → Postgres path. + ## Backend Create a virtual environment, install dependencies, then run: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f08a14c..f163d99 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data-entry-app-backend" -version = "0.1.4" +version = "0.1.5" description = "Costing platform MVP backend" requires-python = ">=3.11" dependencies = [ @@ -14,6 +14,7 @@ dependencies = [ "sqlalchemy>=2.0,<3.0", "pydantic>=2.8,<3.0", "pytest>=8.0,<9.0", + "psycopg[binary]>=3.2,<4.0", ] [tool.setuptools] diff --git a/deploy/Deploy.ps1 b/deploy/Deploy.ps1 new file mode 100644 index 0000000..368076a --- /dev/null +++ b/deploy/Deploy.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH. + +.DESCRIPTION + Runs `docker compose` against `docker-compose.production.yml` on the remote + host. The same script handles first-time bootstrap and subsequent updates: + + * On bootstrap (-Bootstrap): creates the remote directory, clones the + repo (or updates if already present), uploads the local env file, and + brings the stack up with `docker compose ... up -d --build`. + + * On update (default): SSHes to the host, fetches the requested branch, + uploads a refreshed env file (if changed), then runs + `docker compose ... up -d --build` followed by a healthcheck. + + The script never executes destructive commands without asking, except for + recreating containers (which preserves the named Postgres volume). + +.PARAMETER RemoteHost + Hostname or IP of the Digital Ocean droplet. Required. + +.PARAMETER RemoteUser + SSH user on the droplet. Defaults to `root`. + +.PARAMETER RemotePath + Absolute path on the droplet where the repo lives. Defaults to + `/srv/lean101-clients`. + +.PARAMETER Branch + Git branch to deploy. Defaults to `main`. + +.PARAMETER RepoUrl + Git URL used during bootstrap when the remote directory is empty. + Required only with -Bootstrap. + +.PARAMETER EnvFile + Local path to the env file that should land on the droplet as + `/.env.production`. Defaults to `.env.production`. + +.PARAMETER SshKey + Optional path to an SSH private key. If omitted, the script relies on + ssh-agent / default keys. + +.PARAMETER ComposeFile + Compose file name on the remote host. Defaults to + `docker-compose.production.yml`. + +.PARAMETER Bootstrap + Run first-time setup (clone, upload env, build, up). + +.PARAMETER SkipBuild + Pass `--no-build` to docker compose (use when only env changed). + +.PARAMETER Seed + Run `python -m app.seed` inside the backend container after the stack is up. + +.PARAMETER Logs + After deploy, tail logs for ~20 lines so you can verify the stack came up. + +.EXAMPLE + ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git + +.EXAMPLE + ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string] $RemoteHost, + [string] $RemoteUser = "root", + [string] $RemotePath = "/srv/lean101-clients", + [string] $Branch = "main", + [string] $RepoUrl, + [string] $EnvFile = ".env.production", + [string] $SshKey, + [string] $ComposeFile = "docker-compose.production.yml", + [switch] $Bootstrap, + [switch] $SkipBuild, + [switch] $Seed, + [switch] $Logs +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Write-Step($message) { + Write-Host "==> $message" -ForegroundColor Cyan +} + +function Write-Warn($message) { + Write-Host "!! $message" -ForegroundColor Yellow +} + +function Resolve-RepoRoot { + $scriptDir = Split-Path -Parent $MyInvocation.ScriptName + if (-not $scriptDir) { $scriptDir = $PSScriptRoot } + return (Resolve-Path (Join-Path $scriptDir "..")).Path +} + +$RepoRoot = Resolve-RepoRoot +Push-Location $RepoRoot +try { + $envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile } + if (-not (Test-Path $envPath)) { + throw "Env file not found at '$envPath'. Copy .env.production.example to $EnvFile and fill in production secrets first." + } + + $sshTarget = "$RemoteUser@$RemoteHost" + $sshOpts = @("-o", "StrictHostKeyChecking=accept-new") + if ($SshKey) { $sshOpts += @("-i", $SshKey) } + + function Invoke-Ssh([string] $remoteCommand) { + & ssh @sshOpts $sshTarget $remoteCommand + if ($LASTEXITCODE -ne 0) { + throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand" + } + } + + function Invoke-Scp([string] $localPath, [string] $remoteDest) { + & scp @sshOpts $localPath "$($sshTarget):$remoteDest" + if ($LASTEXITCODE -ne 0) { + throw "scp failed for $localPath -> $remoteDest" + } + } + + Write-Step "Verifying SSH connectivity to $sshTarget" + Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" + + Write-Step "Verifying Docker is installed on the droplet" + Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version" + + if ($Bootstrap) { + if (-not $RepoUrl) { + throw "-RepoUrl is required when using -Bootstrap." + } + Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)" + $bootstrapScript = @" +set -euo pipefail +mkdir -p '$RemotePath' +cd '$RemotePath' +if [ ! -d .git ]; then + git clone --branch '$Branch' '$RepoUrl' . +else + git remote set-url origin '$RepoUrl' + git fetch origin '$Branch' + git checkout '$Branch' + git reset --hard 'origin/$Branch' +fi +"@ + Invoke-Ssh $bootstrapScript + } else { + Write-Step "Updating $RemotePath to latest $Branch" + $updateScript = @" +set -euo pipefail +cd '$RemotePath' +git fetch origin '$Branch' +git checkout '$Branch' +git reset --hard 'origin/$Branch' +"@ + Invoke-Ssh $updateScript + } + + Write-Step "Uploading $EnvFile to $RemotePath/.env.production" + Invoke-Scp $envPath "$RemotePath/.env.production" + Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" + + $composeArgs = @( + "--env-file", ".env.production", + "-f", $ComposeFile + ) -join " " + + $buildFlag = if ($SkipBuild) { "" } else { "--build" } + + Write-Step "Pulling base images" + Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true" + + Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))" + Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans" + + Write-Step "Waiting for backend healthcheck" + $healthScript = @" +set -e +cd '$RemotePath' +for attempt in `$(seq 1 30); do + status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing) + case "`$status" in + healthy|running) + echo "backend is `$status" + exit 0;; + *) + printf '.' + sleep 4;; + esac +done +echo +echo 'backend did not become healthy in time' >&2 +docker compose $composeArgs ps backend +exit 1 +"@ + Invoke-Ssh $healthScript + + if ($Seed) { + Write-Step "Seeding reference data" + Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed" + } + + Write-Step "Final container status" + Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps" + + if ($Logs) { + Write-Step "Recent logs (last 60 lines)" + Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs logs --tail=60" + } + + Write-Host "Deployment complete." -ForegroundColor Green +} +finally { + Pop-Location +} diff --git a/docker-compose.alpha.yml b/docker-compose.alpha.yml index bb283fb..d8019b8 100644 --- a/docker-compose.alpha.yml +++ b/docker-compose.alpha.yml @@ -39,6 +39,8 @@ services: PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au} INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000} PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000} + PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false} + PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false} depends_on: backend: condition: service_healthy diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..c7a3adc --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,81 @@ +services: + db: + container_name: lean101-clients-db + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-lean101} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:-lean101} + volumes: + - clients_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lean101} -d ${POSTGRES_DB:-lean101}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + + backend: + container_name: lean101-clients-backend + build: + context: . + dockerfile: backend/Dockerfile + restart: unless-stopped + environment: + APP_NAME: ${APP_NAME:-Lean 101 Clients API} + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}} + CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} + CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} + CLIENT_PASSWORD: ${CLIENT_PASSWORD:?CLIENT_PASSWORD is required} + CLIENT_TENANT_ID: ${CLIENT_TENANT_ID:-hunter-premium-produce} + ADMIN_NAME: ${ADMIN_NAME:-Lean 101} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@lean101.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} + AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required} + CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 25s + + frontend: + container_name: lean101-clients-frontend + build: + context: . + dockerfile: frontend/Dockerfile + restart: unless-stopped + environment: + ORIGIN: ${ORIGIN:-https://clients.lean-101.com.au} + PORT: 3000 + HOST: 0.0.0.0 + PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au} + INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000} + PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000} + PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false} + PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false} + depends_on: + backend: + condition: service_healthy + + nginx: + container_name: lean101-clients + image: nginx:1.27-alpine + restart: unless-stopped + depends_on: + frontend: + condition: service_started + backend: + condition: service_healthy + ports: + - "${CLIENTS_APP_PORT:-8081}:80" + volumes: + - ./deploy/nginx/clients.lean-101.conf:/etc/nginx/conf.d/default.conf:ro + +volumes: + clients_db_data: diff --git a/docker-compose.yml b/docker-compose.yml index bb283fb..d8019b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ services: PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au} INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000} PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000} + PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false} + PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false} depends_on: backend: condition: service_healthy diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f90791d..2d75eff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "data-entry-app-frontend", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "data-entry-app-frontend", - "version": "0.1.4", + "version": "0.1.5", "devDependencies": { "@sveltejs/adapter-auto": "^3.2.0", "@sveltejs/adapter-node": "^5.2.12", diff --git a/frontend/package.json b/frontend/package.json index cc0fcde..2a658e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "data-entry-app-frontend", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index afe3af4..1c60750 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -3,8 +3,8 @@ import { invalidateAll } from '$app/navigation'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import Lean101Logo from '$lib/components/Lean101Logo.svelte'; import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session'; + import { featureFlags } from '$lib/features'; import { onMount, tick } from 'svelte'; import packageInfo from '../../../package.json'; @@ -24,7 +24,12 @@ }; const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' }; - const mixCalculatorItem: NavItem = { href: '/mix-calculator', label: 'Mix Calculator', shortLabel: 'MC', moduleKey: 'mix_calculator' }; + const mixCalculatorItem: NavItem = { + href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new', + label: 'Mix Calculator', + shortLabel: 'MC', + moduleKey: 'mix_calculator' + }; const workingDocumentItems: NavItem[] = [ { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' }, { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' }, @@ -64,12 +69,16 @@ description: 'Start a new costing worksheet for Hunter Premium Produce.', keywords: 'new mix create worksheet hunter premium produce formula' }, - { - href: '/mix-calculator', - label: 'Open Mix Calculator', - description: 'Review saved production sessions and batch calculations.', - keywords: 'mix calculator production sessions batch bags client product' - }, + ...(featureFlags.mixCalculatorSessionHistory + ? [ + { + href: '/mix-calculator', + label: 'Open Mix Calculator', + description: 'Review saved production sessions and batch calculations.', + keywords: 'mix calculator production sessions batch bags client product' + } + ] + : []), { href: '/mix-calculator/new', label: 'Create Mix Calculation', @@ -281,7 +290,9 @@ Promise.all([ hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]), hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]), - hasModuleAccess(session, 'mix_calculator') ? api.mixCalculatorSessions() : Promise.resolve([]) + featureFlags.mixCalculatorSessionHistory && hasModuleAccess(session, 'mix_calculator') + ? api.mixCalculatorSessions() + : Promise.resolve([]) ]) .then(([products, mixes, sessions]) => { if (seededSearchToken !== token) { @@ -382,7 +393,7 @@