76 lines
6.0 KiB
Markdown
76 lines
6.0 KiB
Markdown
# 01 — Architecture & Conventions
|
|
|
|
Backend is a single Go module `solopm.com/solopm-server`, one binary, serving the JSON API and the embedded Vue SPA. Layering is strict: **HTTP handler → domain service → sqlc-generated store**. Handlers never touch the database; services never parse HTTP. All mutations run inside a transaction and emit domain events ([03-domain-events.md](03-domain-events.md)).
|
|
|
|
## Repository layout
|
|
|
|
- [ ] Create the repo skeleton:
|
|
|
|
```
|
|
/ (repo root; /app in this workspace)
|
|
├── cmd/solopm/main.go — flag/env parsing, wiring, serve + CLI subcommands (migrate, reindex, cull)
|
|
├── internal/
|
|
│ ├── config/ — env config loader + validation
|
|
│ ├── httpserver/ — chi router, middleware, handler registration, SPA fallback
|
|
│ ├── domain/
|
|
│ │ ├── users/ — service + handlers per domain (users, projects, issues, epics,
|
|
│ │ ├── projects/ comments, wiki, notifications, search, webhooks, export, retention)
|
|
│ │ └── ...
|
|
│ ├── db/
|
|
│ │ ├── migrations/ — golang-migrate SQL files (NNNN_name.up.sql / .down.sql)
|
|
│ │ ├── queries/ — sqlc input .sql files, one file per domain
|
|
│ │ └── sqlcgen/ — generated code (checked in)
|
|
│ ├── events/ — Emit(), outbox dispatcher, consumer registry
|
|
│ ├── jobs/ — job queue workers (email, webhook delivery, search sync, digest, cull)
|
|
│ ├── search/ — Meilisearch client + index definitions
|
|
│ ├── mail/ — SMTP sender + templates
|
|
│ ├── auth/ — session/PAT middleware, OAuth clients, bcrypt helpers
|
|
│ └── files/ — attachment storage on disk under UPLOAD_DIR
|
|
├── web/ — Vue app (see 13-frontend.md); web/dist embedded via go:embed
|
|
├── plans/ — these specs
|
|
├── Makefile — fmt, vet, test, sqlc, migrate, build (frontend then backend)
|
|
├── sqlc.yaml
|
|
├── docker-compose.yml
|
|
└── .env.example — every env var with sane defaults (never commit .env)
|
|
```
|
|
|
|
## Configuration (env vars)
|
|
|
|
- [ ] Implement `internal/config` reading these variables (fail fast at startup on missing required ones):
|
|
- [ ] `DATABASE_URL` (required) — pgx connection string
|
|
- [ ] `HTTP_PORT` (default `4080`)
|
|
- [ ] `BASE_URL` (required) — external URL, used in emails/OAuth callbacks/webhook payloads
|
|
- [ ] `SESSION_SECRET` (required) — 32+ bytes
|
|
- [ ] `UPLOAD_DIR` (default `./uploads`) — attachment storage root
|
|
- [ ] `MAX_UPLOAD_MB` (default `25`)
|
|
- [ ] `MEILI_URL`, `MEILI_KEY` (required for search; app runs degraded without them — search endpoints return 503)
|
|
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` (optional; email disabled when unset)
|
|
- [ ] `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (optional — hides the button when unset)
|
|
- [ ] `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` (optional — same)
|
|
- [ ] `CULL_AFTER_DAYS` (default `30`)
|
|
- [ ] `LOG_LEVEL` (default `info`)
|
|
|
|
## Cross-cutting conventions
|
|
|
|
- [ ] **IDs**: `bigint GENERATED ALWAYS AS IDENTITY` primary keys everywhere.
|
|
- [ ] **Timestamps**: `timestamptz`; `created_at NOT NULL DEFAULT now()`; `updated_at` nullable, set by the service on update; never trust client clocks.
|
|
- [ ] **Soft delete**: nullable `deleted_at` on user-facing content tables. Every list/get query filters `deleted_at IS NULL` (enforce via sqlc query text). Hard deletion happens only in the culling job ([12-retention.md](12-retention.md)).
|
|
- [ ] **Foreign keys**: declared with `ON DELETE RESTRICT`. Cascades are the culling job's responsibility (it must also delete disk files — a DB cascade can't).
|
|
- [ ] **Transactions**: one per request mutation; service receives `pgx.Tx`; `events.Emit` is called inside the same tx.
|
|
- [ ] **Errors**: JSON `{"error": {"code": "<machine_code>", "message": "<human>", "fields": {"title": "required"}}}`. Codes: `validation_failed`, `not_found`, `forbidden`, `unauthorized`, `conflict`, `cycle_detected`, `rate_limited`, `internal`. Non-members receive **404, not 403**, for projects they can't see (don't leak existence).
|
|
- [ ] **Logging**: `log/slog`, JSON in production, request-id middleware; log every request (method, path, status, duration, user id).
|
|
- [ ] **Validation**: in services, not handlers; titles trimmed/non-empty; markdown bodies size-capped (64 KB).
|
|
- [ ] **Time/date fields**: `start_date`/`target_date` are `date` (no time component); `completed_at` is `timestamptz` set by status automation.
|
|
- [ ] **Graceful shutdown**: on SIGINT/SIGTERM stop accepting requests, close SSE hub, drain dispatcher + job workers (10s cap), close pgx pool.
|
|
- [ ] **SPA serving**: `go:embed web/dist`; any non-`/api`, non-`/webhooks`, non-`/files` GET serves `index.html` (deep links work).
|
|
- [ ] **Makefile targets**: `make dev` (Vite + Go with reload), `make build` (pnpm build → go build), `make test`, `make sqlc`, `make migrate-up/down`, `make lint`.
|
|
- [ ] **Tests**: table-driven unit tests for services (cycle detection, numbering, fan-out rules); integration tests against a real Postgres (dockertest or `TEST_DATABASE_URL`); no mocking of sqlc stores.
|
|
|
|
## Middleware chain (chi)
|
|
|
|
- [ ] Order: RequestID → RealIP → slog logger → Recoverer → CORS (same-origin default; permissive only in dev) → session loader (scs) → auth resolver (session cookie **or** `Authorization: Bearer solopm_…` PAT → sets user in context) → per-route guards:
|
|
- [ ] `RequireAuth` — 401 if no user
|
|
- [ ] `RequireAdmin` — 403 if not `is_admin`
|
|
- [ ] `RequireProjectMember` / `RequireProjectOwner` — loads project from URL, 404 if absent/soft-deleted/not a member, 403 if member-but-not-owner where owner is required
|
|
- [ ] Rate-limit login/register/password endpoints (per-IP token bucket, e.g. 10/min).
|