191 lines
7.9 KiB
Markdown
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.
|