diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..efd3372 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,194 @@ +# Installationsvejledning — produktionsopsætning + +Denne vejledning dækker opsætning af Vinindkøb-backend'en (API + +offentlig side + admin-UI) på en rigtig server, fra en frisk +installation til en kørende løsning. Se [README.md](README.md) for +lokal udvikling. + +Vejledningen forudsætter at der allerede findes en kørende +Postal-instans (selvhostet mailserver — se CLAUDE.md's "Tech stack") +med DKIM sat op for det domæne der sendes mails fra. Selve +Postal-opsætningen er uden for denne vejlednings omfang. + +## 1. Forudsætninger på serveren + +- Python 3.13 (se `.python-version`) +- [`uv`](https://docs.astral.sh/uv/) (Python-pakkehåndtering) +- Node.js + npm (til at bygge admin-UI'et, `admin-ui/`) +- PostgreSQL +- git + +## 2. Hent koden + +```bash +git clone /sti/til/vinindkoeb +cd /sti/til/vinindkoeb +uv sync +``` + +## 3. Database + +Opret en Postgres-bruger og -database der matcher formatet i +`DATABASE_URL` (`postgresql+psycopg://:@:5432/`): + +```sql +CREATE USER vinindkoeb WITH PASSWORD '...'; +CREATE DATABASE vinindkoeb OWNER vinindkoeb; +``` + +## 4. Miljøvariabler + +```bash +cp .env.example .env +``` + +Udfyld hvert felt i `.env`: + +| Variabel | Hvor den kommer fra | +|---|---| +| `DATABASE_URL` | Fra trin 3 | +| `ENVIRONMENT` | `production` | +| `SECRET_KEY` | Generér med `openssl rand -hex 32` | +| `ACCESS_TOKEN_EXPIRE_MINUTES` / `ELEVATION_EXPIRE_MINUTES` | Standardværdierne er fine, med mindre andet ønskes | +| `POSTAL_BASE_URL` / `POSTAL_API_KEY` | Fra Postal-instansens admin-UI (opret en API-nøgle til denne server) | +| `POSTAL_WEBHOOK_PUBLIC_KEY_B64` | DKIM-nøglen for afsenderdomænet — find med `dig +short TXT ._domainkey.`, brug værdien af `p=` | +| `ADMIN_DOMAIN` | Domænet admin-UI'et skal serveres på, fx `admin.vinindkoeb.dk` | + +## 5. Migrations + +```bash +uv run alembic upgrade head +``` + +Dette opretter alle tabeller og seeder `WineCategory` automatisk +(vinkategorierne er globale, delt af alle organisationer/ruter). + +## 6. Første organisation, rute og admin-bruger + +Der findes ingen selvbetjenings-registrering — uden en bruger i +databasen kan ingen logge ind. Brug bootstrap-scriptet: + +```bash +uv run python scripts/create_admin_user.py \ + --org "Din Organisation" \ + --route "Din Rute" \ + --email admin@example.com \ + --name "Dit Navn" \ + --password "et-sikkert-kodeord" +``` + +Scriptet genbruger en organisation/rute med samme navn, hvis den +allerede findes — sikkert at køre igen for at oprette endnu en +admin-bruger på en eksisterende organisation. + +## 7. Byg admin-UI'et + +```bash +cd admin-ui +npm install +npm run build # skriver til ../app/admin_dist +cd .. +``` + +Uden dette trin kører API'et og den offentlige side fint, men +`ADMIN_DOMAIN` viser ingenting (se `app/admin_site.py`s +sikkerhedsnet). + +## 8. Kør backend'en som en vedvarende service + +Eksempel på en `systemd`-unit (`/etc/systemd/system/vinindkoeb.service`), +uafhængig af hvilken reverse proxy der bruges foran den: + +```ini +[Unit] +Description=Vinindkøb backend +After=network.target postgresql.service + +[Service] +Type=simple +User=vinindkoeb +WorkingDirectory=/sti/til/vinindkoeb +ExecStart=/sti/til/vinindkoeb/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always +EnvironmentFile=/sti/til/vinindkoeb/.env + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now vinindkoeb +``` + +Backend'en lytter kun på `127.0.0.1` — den skal ikke være direkte +tilgængelig udefra, kun via reverse proxy'en i næste trin. + +## 9. Domæner + reverse proxy + +Løsningen afgør selv hvilken rute/hvilket indhold der skal vises ud +fra requestens `Host`-header (se CLAUDE.md's "Route" og +"Admin-UI-arkitektur") — det betyder at **alle** domæner nedenfor +bare skal pege på samme kørende backend (samme port, fra trin 8), +uanset hvilken reverse proxy-løsning der bruges: + +- Organisationens/rutens `public_domain`-felt(er) i databasen, fx + `vinindkoeb.dk` og `test.vinindkoeb.dk` +- `ADMIN_DOMAIN` fra `.env`, fx `admin.vinindkoeb.dk` + +**Med CloudPanel:** opret ét "Reverse Proxy"-site pr. domæne, der +peger på `127.0.0.1:8000` (porten fra trin 8), og aktivér +Let's Encrypt-certifikat for hvert site. Bruges en anden løsning +(nginx, Caddy, Traefik, ...), er princippet det samme: én +vhost/site pr. domæne, alle proxyet til samme port. + +## 10. DNS + +A-records (eller CNAME, alt efter opsætning) for hvert domæne fra +trin 9, der peger på serverens IP-adresse. + +## 11. Postal-webhook + +I Postal-instansens konfiguration: sæt webhook-URL'en til +`https:///webhooks/postal` (fx +`https://vinindkoeb.dk/webhooks/postal`) — det er sådan +leveringsstatus (sendt/leveret/afvist) opdateres asynkront på +`MailLog` (se opgave 7c). + +## 12. Sæt rutens felter + +For hver rute (den rigtige, og evt. en test-rute) skal disse felter +sættes direkte i databasen — der findes ingen rute-indstillings-skærm +i admin-UI'et endnu: + +```sql +UPDATE route SET + public_domain = 'vinindkoeb.dk', + sender_name = 'Afsendernavn', + sender_email = 'afsender@vinindkoeb.dk' +WHERE id = ; +``` + +## 13. Verifikation + +1. `curl https:///health` → `{"status":"ok","database":"ok"}`. +2. Besøg det rigtige domæne i en browser → bestillings-/tilmeldingssiden. +3. Besøg `ADMIN_DOMAIN` → login-siden, log ind med brugeren fra trin 6. +4. Afprøv en tilmelding/bestilling på test-ruten (hvis en sådan er + sat op) og bekræft at en rigtig mail bliver sendt og logget i + `MailLog`. + +## 14. Fremtidige opdateringer + +```bash +git pull +uv sync +uv run alembic upgrade head +cd admin-ui && npm install && npm run build && cd .. +sudo systemctl restart vinindkoeb +``` + +## Backup + +Ikke uddybet her, men en simpel `pg_dump`-baseret cron-opgave af +databasen anbefales som minimum. diff --git a/README.md b/README.md index 12ff2bd..50adc41 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Vinindkøb v2 Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL + -Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel. +Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel, og +[INSTALL.md](INSTALL.md) for produktionsopsætning på en server. ## Kom i gang diff --git a/scripts/create_admin_user.py b/scripts/create_admin_user.py new file mode 100644 index 0000000..6dbeab4 --- /dev/null +++ b/scripts/create_admin_user.py @@ -0,0 +1,83 @@ +"""One-off bootstrap: opretter den første organisation + rute + superadmin-bruger. + +Der findes ingen selvbetjenings-registrering i systemet — uden en +bruger i databasen kan ingen logge ind overhovedet. Dette script +dækker det på en frisk installation. + +Genbruger en organisation/rute med samme navn, hvis de allerede +findes (sikkert at køre flere gange, fx for at oprette endnu en +admin-bruger på en eksisterende organisation). Fejler tydeligt hvis +en bruger med samme email allerede findes, i stedet for at duplikere. + +Brug: + uv run python scripts/create_admin_user.py \ + --org "Fælles Vinindkøb" --route "Fælles Vinindkøb" \ + --email admin@example.com --name "Dit Navn" --password "..." +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sqlmodel import Session, select + +from app.core.security import hash_password +from app.db import engine +from app.models.organization import Organization +from app.models.route import Route +from app.models.user import User + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--org", required=True, help="Organisationens navn (genbruges hvis den allerede findes)") + parser.add_argument("--route", required=True, help="Rutens navn (genbruges hvis den allerede findes)") + parser.add_argument("--email", required=True, help="Email til den nye admin-bruger") + parser.add_argument("--name", required=True, help="Navn på den nye admin-bruger") + parser.add_argument("--password", required=True, help="Adgangskode til den nye admin-bruger") + args = parser.parse_args() + + with Session(engine) as session: + existing_user = session.exec(select(User).where(User.email == args.email)).first() + if existing_user is not None: + print(f"Fejl: en bruger med email {args.email!r} findes allerede (id={existing_user.id}).") + sys.exit(1) + + organization = session.exec(select(Organization).where(Organization.name == args.org)).first() + if organization is None: + organization = Organization(name=args.org) + session.add(organization) + session.flush() + print(f"Oprettede organisation {args.org!r} (id={organization.id}).") + else: + print(f"Genbruger eksisterende organisation {args.org!r} (id={organization.id}).") + + route = session.exec( + select(Route).where(Route.name == args.route, Route.organization_id == organization.id) + ).first() + if route is None: + route = Route(name=args.route, organization_id=organization.id) + session.add(route) + session.flush() + print(f"Oprettede rute {args.route!r} (id={route.id}).") + else: + print(f"Genbruger eksisterende rute {args.route!r} (id={route.id}).") + + user = User( + email=args.email, + name=args.name, + hashed_password=hash_password(args.password), + is_active=True, + is_superadmin=True, + organization_id=organization.id, + ) + session.add(user) + session.commit() + session.refresh(user) + print(f"Oprettede superadmin-bruger {args.email!r} (id={user.id}).") + + +if __name__ == "__main__": + main()