Files
2026-07-06 08:50:52 +02:00

191 lines
7.9 KiB
Markdown

# Car Control — API Server
The central API Server for the Car Control project. Written in Go (standard
library only, module `carcontrol/api`). It is the **single gateway** between all
clients (web app, phone app, Home Assistant plugin, ESP32 device) and the
PocketBase database — **clients never talk to PocketBase directly**. The API
Server authenticates to PocketBase as superuser and all collection access rules
are left null, so data is only reachable through this server.
## Data model (from `Car Service.xlsx`)
| Collection | Purpose | Key fields |
|---|---|---|
| `cars` | one per car (was: one spreadsheet sheet) | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
| `service_records` | the service log | car, date, km, changed_oil, changed_engine_air_filter, changed_cabin_air_filter, notes |
| `parts` | per-car parts catalog (cols M/N) | car, name, part_number, category |
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
| `sessions` | active login sessions (device/IP/expiry/revoked) | user, label, ip, user_agent, expires, revoked |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin), bio, theme, locale, date_format, font_size, deletion_requested_at |
**Spreadsheet formulas**, reproduced by the API on read:
```
Next Service Date = service date + serviceIntervalDays (Excel: =A+365)
Next Service Km = service km + serviceIntervalKm (Excel: =B+15000)
```
These come back on each service record as `nextServiceDate` / `nextServiceKm`.
## Auth & access control
All endpoints except `/api/health` and `/api/auth/login` require a bearer token.
Login verifies credentials against the PocketBase `users` collection, then the
API Server issues its own HS256 JWT (valid 7 days).
- **Sessions** — each login also creates a `sessions` record (device label, IP,
user-agent, expiry) whose id is embedded as the JWT `jti`. `withAuth` rejects
any token whose session is missing or revoked, which powers the "active
sessions" list and remote logout in Settings. (Any JWT minted before sessions
were introduced has no `jti` and is treated as revoked.)
- **Per-user car ownership + sharing** — cars are not a global list. `cars.owner`
marks ownership and `car_shares` grants other users `read` or `write` access.
Every car/service/part handler is gated by `requireCarAccess`:
- **read** — view the car, its service records and parts.
- **write** — edit the car and full service/part CRUD.
- **owner only** — delete the car and manage its shares.
- **Admin role** — `users.role` (`user` | `admin`), embedded in the JWT and
re-checked from PocketBase on each admin call (so demotion is immediate).
Admins manage users under `/api/admin/*`. Guards prevent deleting your own
account or removing/demoting the last admin.
```
POST /api/auth/login { "email": "...", "password": "..." } -> { token, user }
GET /api/auth/me (Authorization: Bearer <token>) -> { id, email, name, role }
```
## Endpoints
```
GET /api/health
POST /api/auth/login
GET /api/auth/me
# current user (profile / appearance / avatar / data / account lifecycle)
GET /api/me PATCH /api/me
POST /api/me/password
POST /api/me/avatar GET /api/me/avatar DELETE /api/me/avatar
POST /api/me/verify/request
GET /api/me/export POST /api/me/import
POST /api/me/delete POST /api/me/delete/cancel DELETE /api/me
# active sessions
GET /api/sessions
DELETE /api/sessions/{id} DELETE /api/sessions (revoke all others)
# admin (admin role required)
GET /api/admin/users POST /api/admin/users
PATCH /api/admin/users/{id} POST /api/admin/users/{id}/password
DELETE /api/admin/users/{id}
# cars + sharing
GET /api/cars POST /api/cars
GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
GET /api/cars/{id}/service-records
GET /api/cars/{id}/parts
GET /api/cars/{id}/shares POST /api/cars/{id}/shares
DELETE /api/cars/{id}/shares/{userId}
# service records + parts
GET /api/service-records POST /api/service-records
GET /api/service-records/{id} PATCH /api/service-records/{id} DELETE /api/service-records/{id}
GET /api/parts POST /api/parts
GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
```
`GET /api/cars` returns the caller's owned cars plus any shared with them, each
annotated with an `access` field. `GET /api/service-records?car={id}` and
`GET /api/parts?car={id}` filter by car.
> **Gotcha:** `updateCar` rewrites **all** car columns from the payload, so a
> `PATCH /api/cars/{id}` must send the **full** car object — omitted spec fields
> get blanked. (The phone's odometer quick-edit sends the whole car for this
> reason.)
## Layout
```
main.go
internal/
├── api/ # HTTP handlers + router (server.go)
│ ├── auth.go services.go records.go parts.go cars.go
│ ├── me.go sessions.go shares.go admin.go
├── auth/jwt.go # HS256 JWT mint/verify
├── config/config.go
├── models/models.go
└── pb/client.go # PocketBase superuser client
scripts/ # Node/Python maintenance scripts (see below)
bin/api-server.exe # prebuilt binary the deployment runs
```
## Configuration
Copy `.env.example` to `.env` and fill in:
```
PORT=8080
PB_URL=http://10.2.1.10:8027
PB_ADMIN_EMAIL=...
PB_ADMIN_PASSWORD=...
AUTH_SECRET=<long random value>
CORS_ORIGINS=http://localhost:5173
```
`CORS_ORIGINS` only matters for **browser** clients (the web app). Native mobile
apps are not subject to CORS. Set `AUTH_SECRET` to a long random value — the
server warns and falls back to an insecure dev secret if it is unset.
## Scripts (`scripts/`)
```powershell
node scripts/setup-pocketbase.mjs # create/reconcile collections (idempotent)
node scripts/create-user.mjs <email> <pw> "Name" # create an app login
node scripts/set-role.mjs <email> user|admin # promote/demote
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
python scripts/seed_from_excel.py "C:/Users/jania/Desktop/Car Service.xlsx"
```
The Node scripts read `PB_URL` / `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` from the
environment (or `.env`).
> **PocketBase note:** collections created by the setup script do **not** get
> automatic `created`/`updated` autodate fields in this PocketBase version —
> sorting on `created` fails unless an explicit `F.autodate(...)` is added. When
> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`,
> add it to `models.Car` + the record mapping in `records.go`, then rebuild.
## First-time setup
1. **Create the PocketBase collections** (idempotent):
```powershell
$env:PB_URL="http://10.2.1.10:8027"
$env:PB_ADMIN_EMAIL="you@example.com"
$env:PB_ADMIN_PASSWORD="secret"
node scripts/setup-pocketbase.mjs
```
2. **Run the server** — `go run .`, or build and run the binary (below).
3. **(Optional) Seed from the spreadsheet** with the server running (see scripts).
## Build & run
```powershell
go build -o bin/api-server.exe .
```
The deployment runs the **prebuilt binary** `bin/api-server.exe` (not `go run`),
started detached so it survives the shell:
```powershell
Start-Process -FilePath ".\bin\api-server.exe" -WorkingDirectory "." `
-WindowStyle Hidden -RedirectStandardOutput api-server.out.log `
-RedirectStandardError api-server.err.log
```
Go's `log` package writes to **stderr**, so check `api-server.err.log` for
request logs and errors. After editing any Go source, rebuild and restart the
process — editing source alone does nothing until the binary is rebuilt.