6.4 KiB
6.4 KiB
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).
Repository layout
- Create the repo skeleton (Phase 0 subset;
internal/domain/*,internal/events,internal/jobs,internal/search,internal/mail,internal/auth,internal/filesare added by the phases that need them):
/ (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/configreading these variables (fail fast at startup on missing required ones):DATABASE_URL(required) — pgx connection stringHTTP_PORT(default8080)BASE_URL(required) — external URL, used in emails/OAuth callbacks/webhook payloadsSESSION_SECRET(required) — 32+ bytesUPLOAD_DIR(default./uploads) — attachment storage rootMAX_UPLOAD_MB(default25)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(default30)LOG_LEVEL(defaultinfo)
Cross-cutting conventions
- IDs:
bigint GENERATED ALWAYS AS IDENTITYprimary keys everywhere. - Timestamps:
timestamptz;created_at NOT NULL DEFAULT now();updated_atnullable, set by the service on update; never trust client clocks. - Soft delete: nullable
deleted_aton user-facing content tables. Every list/get query filtersdeleted_at IS NULL(enforce via sqlc query text). Hard deletion happens only in the culling job (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.Emitis 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_datearedate(no time component);completed_atistimestamptzset by status automation. - Graceful shutdown: on SIGINT/SIGTERM stop accepting requests, close SSE hub, drain dispatcher + job workers (10s cap), close pgx pool. (SSE hub/dispatcher/job workers land in later phases; server shutdown + pool close done in Phase 0)
- SPA serving:
go:embed web/dist; any non-/api, non-/webhooks, non-/filesGET servesindex.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. (make devruns both servers concurrently; no file-watcher/hot-reload for the Go side yet) - 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 userRequireAdmin— 403 if notis_adminRequireProjectMember/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).