diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a772fc1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git/ +.gitea/ +bruno/ +target/ +.dockerignore +.env +.gitignore +BACKEND_BLUEPRINT.md +Dockerfile +LICENSE +compose.prod.yaml +compose.yaml diff --git a/.gitignore b/.gitignore index c236aea..f656bea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env /target /logs +postgres-data diff --git a/BACKEND_BLUEPRINT.md b/BACKEND_BLUEPRINT.md index e84f04c..fcbb054 100644 --- a/BACKEND_BLUEPRINT.md +++ b/BACKEND_BLUEPRINT.md @@ -1,51 +1,137 @@ -# Go Backend Blueprint (Iteration Draft) +# Rhythm Backend Blueprint (Rust/Axum) ## IDEAS -- instead of elastic search use ts vector and ts query to allow easy iseeu searching for smooth experience (pg vector?) +- instead of elastic search use ts vector and ts query to allow easy issue searching for smooth experience +- YouTrack competitor: issue tracker with organizations, projects, issues, and project-level RBAC + +--- ## Chosen Stack -- **Framework:** Gin -- **Dependency Injection:** Uber fx (runtime DI, Spring-like autowiring) -- **Migrations:** Goose (SQL-only) -- **DB Access:** SQLC (no ORM), package per bounded context -- **PostgreSQL Driver/Pool:** pgx/v5 + pgxpool -- **Logging:** Uber Zap (structured logs) +- **Language:** Rust (edition 2024) +- **Framework:** Axum +- **Runtime:** Tokio +- **Migrations:** sqlx built-in migrations +- **DB Access:** sqlx (compile-time checked queries, hand-written repositories) +- **PostgreSQL Driver/Pool:** sqlx::PgPool +- **Logging:** tracing + tracing-subscriber + tracing-tree (structured, hierarchical) +- **Auth:** jsonwebtoken (HS256) + argon2 (password hashing) +- **API Docs:** utoipa (code-first OpenAPI generation) - **Health Probes:** liveness/readiness endpoints -- **API Docs:** OpenAPI (for frontend TypeScript type generation) - **Deployment:** Docker Compose on self-hosted hardware --- +## Decisions Made + +1. **JWT signing:** HS256 (shared secret from env, can migrate to RS256 later) +2. **Token model:** Access + Refresh (short-lived access ~15min, long-lived refresh ~7d, refresh tokens stored hashed in DB) +3. **Roles:** Project-level roles (`admin`, `developer`, `reporter`) + Org-level roles (`owner`, `admin`, `member`) +4. **OpenAPI workflow:** Code-first with utoipa (auto-generate spec from Rust handlers/models) +5. **DB access:** sqlx with hand-written repositories (compile-time checked, no ORM) +6. **First entities:** Users → Organizations → Projects → Issues +7. **Sprint stages:** Custom per project (project admin defines board columns like Todo → In Review → Done) +8. **Assignees:** Multiple assignees per issue (join table) +9. **Issue content features:** Tags, comments, issue relations, time tracking +10. **Plugin system:** Lua scripting with cron + event-driven triggers (Phase 7) + +--- + ## Architecture Direction -### Layering +### Project Structure -- `cmd/api` - application entrypoint and fx DI wiring -- `internal/http` - Gin Server provider, `router.go` for fx mapping -- `internal/http/api/...` - Domain handlers structured by route hierarchy (e.g., `api/health`, `api/protected/users`) -- `internal/service` - business logic + transaction boundaries -- `internal/db/` - SQLC-generated code by bounded context -- `internal/store` - shared DB/Tx helpers -- `internal/auth` - JWT validation + role guards -- `internal/config` - env configuration loading -- `migrations/` - Goose SQL migration files -- `api/openapi/` - OpenAPI spec + generated artifacts +``` +src/ +├── main.rs # Entrypoint, server, graceful shutdown +├── config.rs # Config (exists, extend) +├── logging.rs # Logging (exists) +├── errors.rs # Unified error types → Axum responses +├── state.rs # AppState (PgPool, config, etc.) +├── routes.rs # Router composition (/api/v1/...) +├── auth/ +│ ├── jwt.rs # HS256 token creation/validation +│ ├── hash.rs # argon2 password hashing +│ ├── handlers.rs # register, login, refresh +│ ├── models.rs # auth DTOs +│ └── service.rs # auth business logic +├── middleware/ +│ ├── auth.rs # JWT extraction layer +│ └── rbac.rs # Project-level role guard +├── models/ +│ ├── user.rs +│ ├── org.rs +│ ├── project.rs +│ ├── issue.rs +│ ├── comment.rs +│ ├── tag.rs +│ ├── sprint.rs +│ ├── stage.rs +│ ├── time_entry.rs +│ └── role.rs # OrgRole, ProjectRole enums +├── handlers/ +│ ├── health.rs +│ ├── orgs.rs +│ ├── projects.rs +│ ├── issues.rs +│ ├── comments.rs +│ ├── tags.rs +│ ├── sprints.rs +│ ├── stages.rs +│ └── time_entries.rs +├── services/ +│ ├── org.rs +│ ├── project.rs +│ ├── issue.rs +│ ├── comment.rs +│ ├── tag.rs +│ ├── sprint.rs +│ ├── stage.rs +│ └── time_entry.rs +└── db/ + ├── mod.rs # Pool setup, migration runner + └── repos/ + ├── users.rs + ├── orgs.rs + ├── projects.rs + ├── issues.rs + ├── comments.rs + ├── tags.rs + ├── sprints.rs + ├── stages.rs + ├── time_entries.rs + ├── memberships.rs + └── refresh_tokens.rs +migrations/ +├── 001_create_users.sql +├── 002_create_organizations.sql +├── 003_create_memberships.sql +├── 004_create_projects.sql +├── 005_create_stages.sql +├── 006_create_issues.sql +├── 007_create_issue_assignees.sql +├── 008_create_tags.sql +├── 009_create_comments.sql +├── 010_create_issue_relations.sql +├── 011_create_time_entries.sql +├── 012_create_sprints.sql +├── 013_create_refresh_tokens.sql +└── 014_add_tsvector_search.sql +``` + +### Layering Principles + +- Handlers stay thin — they extract, validate, call service, respond +- Service layer owns business logic + DB transactions +- Repositories own SQL queries (sqlx compile-time checked) +- No transaction logic in handlers +- AppState shared via Axum's State extractor ### Transaction Strategy -- Handlers stay thin. -- Service layer owns DB transactions. -- SQLC queries are called with either pool or tx using `DBTX` interfaces. -- No transaction logic in handlers. - -### Dependency Injection (fx) - -- Use `fx.Provide` to register constructors (config, logger, db pool, server, handlers). -- Use `fx.Invoke` in `main.go` to orchestrate route mapping and application startup. -- `internal/http/router.go` acts as the central route mapping orchestrator injected via `fx.Invoke`. -- Each domain handler (`internal/http/api/*/handler.go`) receives its dependencies via constructor injection. -- Lifecycle hooks (`fx.Lifecycle`) are used for graceful startup/shutdown of the HTTP server and DB pool. +- Service layer owns DB transactions +- Repositories accept either `&PgPool` or `&mut Transaction` +- sqlx compile-time query checking ensures SQL correctness at build time --- @@ -55,131 +141,588 @@ - REST JSON API - Possible future WebSocket support for interactive features -- Suggested versioning: `/api/v1` +- Versioning: `/api/v1` ### Health Endpoints -- `GET /api/health/live` - process is alive -- `GET /api/health/ready` - DB ping succeeds (and optionally migration version check) +- `GET /api/v1/health/live` - process is alive +- `GET /api/v1/health/ready` - DB ping succeeds (and optionally migration version check) + +### Auth Endpoints + +- `POST /api/v1/auth/register` - create account (email + password) +- `POST /api/v1/auth/login` - get access + refresh tokens +- `POST /api/v1/auth/refresh` - rotate refresh token + +### Organization Endpoints + +- `POST /api/v1/orgs` - create org (creator becomes owner) +- `GET /api/v1/orgs` - list user's orgs +- `GET /api/v1/orgs/{org_slug}` - get org +- `PATCH /api/v1/orgs/{org_slug}` - update org (owner/admin) +- `DELETE /api/v1/orgs/{org_slug}` - delete org (owner) + +### Project Endpoints + +- `POST /api/v1/orgs/{org_slug}/projects` - create project (org owner/admin) +- `GET /api/v1/orgs/{org_slug}/projects` - list projects +- `GET /api/v1/orgs/{org_slug}/projects/{project_slug}` - get project +- `PATCH /api/v1/orgs/{org_slug}/projects/{project_slug}` - update project (project admin) +- `DELETE /api/v1/orgs/{org_slug}/projects/{project_slug}` - delete project (project admin) + +### Issue Endpoints + +- `POST /api/v1/orgs/{org_slug}/projects/{project_slug}/issues` - create issue (reporter+) +- `GET /api/v1/orgs/{org_slug}/projects/{project_slug}/issues` - list issues (with `?q=` search, `?tag=`, `?assignee=`, `?status=`) +- `GET /api/v1/orgs/{org_slug}/projects/{project_slug}/issues/{number}` - get issue +- `PATCH /api/v1/orgs/{org_slug}/projects/{project_slug}/issues/{number}` - update issue (developer+) +- `DELETE /api/v1/orgs/{org_slug}/projects/{project_slug}/issues/{number}` - delete issue (admin) + +### Issue Sub-resource Endpoints + +**Tags:** +- `GET /api/v1/orgs/{org}/projects/{proj}/tags` - list project tags +- `POST /api/v1/orgs/{org}/projects/{proj}/tags` - create tag (developer+) +- `DELETE /api/v1/orgs/{org}/projects/{proj}/tags/{tag_id}` - delete tag (admin) +- `PUT /api/v1/orgs/{org}/projects/{proj}/issues/{num}/tags` - set issue tags (developer+) + +**Comments:** +- `GET /api/v1/orgs/{org}/projects/{proj}/issues/{num}/comments` - list comments +- `POST /api/v1/orgs/{org}/projects/{proj}/issues/{num}/comments` - add comment (reporter+) +- `PATCH /api/v1/orgs/{org}/projects/{proj}/issues/{num}/comments/{id}` - edit comment (author only) +- `DELETE /api/v1/orgs/{org}/projects/{proj}/issues/{num}/comments/{id}` - delete comment (author or admin) + +**Assignees:** +- `GET /api/v1/orgs/{org}/projects/{proj}/issues/{num}/assignees` - list assignees +- `PUT /api/v1/orgs/{org}/projects/{proj}/issues/{num}/assignees` - set assignees (developer+) + +**Relations:** +- `GET /api/v1/orgs/{org}/projects/{proj}/issues/{num}/relations` - list relations +- `POST /api/v1/orgs/{org}/projects/{proj}/issues/{num}/relations` - add relation (developer+) +- `DELETE /api/v1/orgs/{org}/projects/{proj}/issues/{num}/relations/{id}` - remove relation (developer+) + +**Time Tracking:** +- `GET /api/v1/orgs/{org}/projects/{proj}/issues/{num}/time-entries` - list time entries +- `POST /api/v1/orgs/{org}/projects/{proj}/issues/{num}/time-entries` - log time (developer+) +- `DELETE /api/v1/orgs/{org}/projects/{proj}/issues/{num}/time-entries/{id}` - delete entry (author or admin) + +### Sprint Endpoints + +- `POST /api/v1/orgs/{org}/projects/{proj}/sprints` - create sprint (developer+) +- `GET /api/v1/orgs/{org}/projects/{proj}/sprints` - list sprints +- `GET /api/v1/orgs/{org}/projects/{proj}/sprints/{id}` - get sprint +- `PATCH /api/v1/orgs/{org}/projects/{proj}/sprints/{id}` - update sprint (developer+) +- `PATCH /api/v1/orgs/{org}/projects/{proj}/sprints/{id}/start` - start sprint (admin) +- `PATCH /api/v1/orgs/{org}/projects/{proj}/sprints/{id}/complete` - complete sprint (admin) +- `GET /api/v1/orgs/{org}/projects/{proj}/sprints/{id}/board` - sprint board (issues grouped by stage) + +### Stage Endpoints (project board columns) + +- `POST /api/v1/orgs/{org}/projects/{proj}/stages` - create stage (admin) +- `PATCH /api/v1/orgs/{org}/projects/{proj}/stages` - reorder stages (admin) +- `DELETE /api/v1/orgs/{org}/projects/{proj}/stages/{id}` - delete stage (admin) ### Logging -- Zap JSON logs -- Uber Zap logger will be initialized in `cmd/api/main.go` and injected into services/middleware (no global loggers). -- Logs will be written directly to a file from the application, with log rotation implemented via `lumberjack`. +- tracing with hierarchical layer (dev: pretty console, prod: JSON + file with rotation) - Correlation/request ID in middleware - Structured error logging from middleware and service boundaries --- -## Database and Migrations +## Database Schema -### Goose +```sql +-- Enums +CREATE TYPE org_role AS ENUM ('owner', 'admin', 'member'); +CREATE TYPE project_role AS ENUM ('admin', 'developer', 'reporter'); +CREATE TYPE issue_status AS ENUM ('open', 'in_progress', 'resolved', 'closed'); +CREATE TYPE issue_priority AS ENUM ('critical', 'major', 'normal', 'minor'); +CREATE TYPE issue_type AS ENUM ('bug', 'feature', 'task', 'improvement'); +CREATE TYPE issue_relation_type AS ENUM ('blocks', 'is_blocked_by', 'duplicates', 'is_duplicated_by', 'relates_to', 'clones', 'is_cloned_by'); +CREATE TYPE sprint_status AS ENUM ('planned', 'active', 'completed'); +CREATE TYPE trigger_type AS ENUM ('event', 'schedule', 'manual'); -- SQL-only migrations -- Keep up/down migration scripts -- Run on startup in non-prod optional, required in CI/CD/deploy step -- Migrations are securely bundled into the binary using Go's `embed.FS` from a dedicated `migrations` package to isolate them from `internal` db logic. +-- Users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); -### SQLC +-- Organizations +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); -- Generate one package per bounded context (similar to Spring repository modules) -- Keep SQL in context directories (`query.sql`, `models.sql` style) -- Service layer composes multiple repositories when needed +-- Org memberships (owner/admin/member) +CREATE TABLE org_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + role org_role NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, org_id) +); -### Work in Progress Snapshot +-- Projects +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(org_id, slug) +); -- `sqlc.yaml` is now configured for PostgreSQL with schema from `migrations/*.sql` -- First bounded context added: `internal/db/users` -- Current users SQLC artifacts generated: - - `db.go`, `models.go`, `queries.sql.go`, `querier.go` -- Current SQLC generation options in use: - - `sql_package: pgx/v5` - - `emit_interface: true` (generated `Querier` interface) - - `emit_json_tags: false` (can be revisited if API structs are returned directly) -- Initial queries implemented for users: `GetUser`, `CreateUser`, `DeleteUser` -- **Goose startup migrations** wired into `db.ProvidePool`, utilizing the `embed.FS` strategy. -- **fx DI fully implemented.** All dependencies are provided via `fx.Provide` constructors: - - `config.Provide` → `*Config` - - `logger.NewFromConfig` → `*zap.SugaredLogger` - - `db.ProvidePool` → `*pgxpool.Pool` (lifecycle hooks for graceful shutdown) - - `http.NewServer` → `*gin.Engine` (lifecycle hooks for startup/shutdown) - - `health.NewHandler` → `*health.Handler` -- **HTTP route hierarchy** via `internal/http/router.go` (`GlueRoutes`): - - `GET /api/health/live` - process is alive - - `GET /api/health/ready` - DB ping succeeds -- **Next Planned:** Auth handler (`/api/auth/login`, `/api/auth/register`), JWT middleware, service layer. +-- Project memberships (admin/developer/reporter) +CREATE TABLE project_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + role project_role NOT NULL DEFAULT 'reporter', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, project_id) +); + +-- Stages (custom board columns per project) +CREATE TABLE stages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + position INT NOT NULL, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(project_id, position) +); + +-- Issues +CREATE TABLE issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + number BIGINT NOT NULL, + title TEXT NOT NULL, + description TEXT, + status issue_status NOT NULL DEFAULT 'open', + priority issue_priority NOT NULL DEFAULT 'normal', + issue_type issue_type NOT NULL DEFAULT 'task', + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + reporter_id UUID NOT NULL REFERENCES users(id), + stage_id UUID REFERENCES stages(id) ON DELETE SET NULL, + sprint_id UUID REFERENCES sprints(id) ON DELETE SET NULL, + search_vector TSVECTOR GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(description, '')), 'B') + ) STORED, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(project_id, number) +); + +-- Multiple assignees per issue +CREATE TABLE issue_assignees ( + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (issue_id, user_id) +); + +-- Tags (per project) +CREATE TABLE tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + color TEXT, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(project_id, name) +); + +CREATE TABLE issue_tags ( + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (issue_id, tag_id) +); + +-- Comments +CREATE TABLE comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Related issues +CREATE TABLE issue_relations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + from_issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + to_issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + relation_type issue_relation_type NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(from_issue_id, to_issue_id, relation_type) +); + +-- Time tracking +CREATE TABLE time_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + seconds_spent INTEGER NOT NULL CHECK (seconds_spent > 0), + description TEXT, + logged_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Sprints +CREATE TABLE sprints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + status sprint_status NOT NULL DEFAULT 'planned', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Refresh tokens +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Scripts (for Lua plugin system) +CREATE TABLE scripts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + source TEXT NOT NULL, + trigger_type trigger_type NOT NULL, + trigger_config JSONB, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE script_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, + source TEXT NOT NULL, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE script_execution_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, + status TEXT NOT NULL, + output TEXT, + error TEXT, + duration_ms INTEGER, + triggered_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_issues_search ON issues USING GIN(search_vector); +CREATE INDEX idx_issues_project_sprint ON issues(project_id, sprint_id); +CREATE INDEX idx_issues_project_stage ON issues(project_id, stage_id); +CREATE INDEX idx_time_entries_issue ON time_entries(issue_id); +CREATE INDEX idx_comments_issue ON comments(issue_id); +``` + +**Note:** The `issues` table references `sprints(id)` via `sprint_id`. In practice, the `sprints` table migration must run before `issues` (or `sprint_id` is added in a later migration). The migration files are ordered to handle this. --- -## Testing Approach (Beginner-Friendly) +## RBAC Model + +### Org-Level Roles + +| Role | Capabilities | +|---------|-------------| +| owner | Delete org, transfer ownership, manage members, create/delete projects | +| admin | Manage members, create/delete projects, update org settings | +| member | View org and its projects, join projects | + +### Project-Level Roles + +| Role | Capabilities | +|-----------|-------------| +| admin | Update/delete project, manage project members, delete issues, manage stages, start/complete sprints | +| developer | Create issues, assign issues, update issue status/priority/stage, log time, add tags, add relations | +| reporter | Create issues, comment, view issues | + +### RBAC Implementation + +- `RequireRole` and `RequireRole` Axum extractors +- Extract `CurrentUser` from JWT middleware +- Query membership for the org/project (from path params) +- Reject if not a member or insufficient role (401/403) +- Org members automatically get `reporter` role in org's projects unless explicitly assigned + +--- + +## Implementation Phases + +### Phase 1: Foundation (Axum + DB + Health) + +**Dependencies:** `axum`, `tokio`, `sqlx` (postgres, runtime-tokio, tls-rustls, migrate, uuid, chrono), `serde`/`serde_json`, `uuid`, `chrono` + +**Tasks:** +1. Extend `Config` with `jwt_secret`, `db_url` parsing, `server_port` +2. Create `AppState` struct holding `PgPool` and `Config` +3. Set up `sqlx::PgPool` with `sqlx::migrate!().run()` on startup +4. Create health endpoints: `GET /api/v1/health/live` and `GET /api/v1/health/ready` +5. Wire up Axum router with `routes()`, shared state, and graceful shutdown via `tokio::signal` +6. Create first migration: `users` table + +### Phase 2: Auth (JWT + Register/Login/Refresh) + +**Additional dependencies:** `jsonwebtoken`, `argon2`, `validator` + +**Tasks:** +1. Create `users` migration (if not done in Phase 1) +2. Create `refresh_tokens` migration +3. Implement `auth::hash` — argon2 password hashing/verification +4. Implement `auth::jwt` — HS256 access + refresh token creation/validation +5. Implement `db::repos::users` — create, get by email, get by id +6. Implement `db::repos::refresh_tokens` — store, find, delete +7. Implement `auth::service` — register, login, refresh logic +8. Implement `auth::handlers` — `POST /api/v1/auth/register`, `/login`, `/refresh` +9. Implement `auth::models` — request/response DTOs with `validator` checks +10. Create `errors.rs` — unified `AppError` enum → `IntoResponse` +11. Create `middleware::auth` — JWT extraction, inject `CurrentUser` +12. Protect routes with auth layer + +### Phase 3: RBAC Layer + +**Tasks:** +1. Create `organization_memberships` and `project_memberships` migrations +2. Define `OrgRole` and `ProjectRole` as SQL enums + Rust enums +3. Implement `db::repos::memberships` — add/remove/check, get role +4. Create `RequireRole` and `RequireRole` Axum extractors +5. Wire role guards into project/issue routes + +### Phase 4: Core Domain (Organizations → Projects → Issues) + +**Tasks:** +1. **Organizations** — CRUD at `POST/GET/PATCH/DELETE /api/v1/orgs` + - Only org owner/admin can update/delete + - Org creator becomes owner automatically + - Slug auto-generated from name +2. **Projects** — CRUD at `/api/v1/orgs/{org_slug}/projects` + - Project membership inherits from org membership + - Org owner/admin can create projects + - Project admin can update project settings +3. **Issues** — CRUD at `/api/v1/orgs/{org_slug}/projects/{project_slug}/issues` + - reporter+ can create issues + - developer+ can assign/update status + - admin can delete issues + - `tsvector` column for full-text search on title + description + - `GET .../issues?q=search+terms` uses `tsquery` +4. **Pagination** — cursor-based pagination on all list endpoints +5. **utoipa** — add `#[derive(OpenApi)]` annotations, expose `GET /api/v1/docs/openapi.json` + +### Phase 5: Issue Rich Content (Tags, Comments, Relations, Time Tracking, Assignees) + +**Tasks:** +1. **Tags** — `tags` + `issue_tags` tables + - Create/list/delete tags per project (developer+ to create, admin to delete) + - Set issue tags (developer+) + - Filter issues by tag: `GET .../issues?tag=bug` +2. **Comments** — `comments` table + - List/create comments on issues (reporter+) + - Edit own comment, delete own comment or admin +3. **Issue Relations** — `issue_relations` table with relation types + - Add/remove relations (developer+) + - Types: blocks, is_blocked_by, duplicates, is_duplicated_by, relates_to, clones, is_cloned_by +4. **Time Tracking** — `time_entries` table + - Log time on issues (developer+) + - Delete own entry or admin + - Aggregate total time spent on issue (computed from entries) +5. **Multiple Assignees** — `issue_assignees` join table + - Replace single `assignee_id` with join table + - Set/replace assignees on issue (developer+) + - Filter issues by assignee: `GET .../issues?assignee={user_id}` + +### Phase 6: Sprints & Stages + +**Tasks:** +1. **Stages** — `stages` table (custom board columns per project) + - Create/reorder/delete stages (project admin) + - Default stages created with new project (e.g. Todo, In Progress, Done) + - Issue `stage_id` tracks which column it's in +2. **Sprints** — `sprints` table + - Sprint CRUD endpoints + - Sprint state transitions: `planned` → `active` → `completed` + - Issue-sprint assignment via `sprint_id` on issues (developer+) +3. **Sprint Board** — `GET .../sprints/{id}/board` + - Returns issues grouped by stage within the sprint + - Moving an issue between stages updates `stage_id` + +### Phase 7: Polish & Hardening + +**Tasks:** +1. CORS middleware (configurable origins from env) +2. Request ID middleware (propagate through logging) +3. Rate limiting (e.g., `tower-governor`) +4. Comprehensive error responses (validation → 422, auth → 401/403) +5. Integration tests with test DB containers (`testcontainers`) +6. Update Dockerfile and compose.yaml for production-ready build + +### Phase 8: Plugin System (Lua Scripting) + +**Additional dependencies:** `mlua` (lua54, vendored, async, serialize), `cron` (cron expression parsing) + +**Concept:** Users write Lua scripts triggered by events or schedules. Scripts get a sandboxed API to automate workflows (e.g., auto-migrate tasks when a sprint ends, auto-assign issues, send notifications). + +**Example user scripts:** +```lua +-- Auto-resolve in-progress tasks when sprint ends +on_event("sprint.ended", function(ctx) + local issues = ctx.project:issues({ status = "in_progress", sprint = ctx.sprint.id }) + for _, issue in ipairs(issues) do + issue:update({ status = "resolved", comment = "Auto-resolved: sprint ended" }) + end +end) +``` + +```lua +-- Every Friday at 6pm, notify about stale issues +on_schedule("0 18 * * 5", function(ctx) + local stale = ctx.project:issues({ status = "open", updated_before = "7d" }) + for _, issue in ipairs(stale) do + issue:add_comment("This issue has been inactive for 7 days.") + end +end) +``` + +**Trigger models (cron + event-driven only):** + +| Trigger | Example | Implementation | +|---------|---------|----------------| +| Event-driven | `on_event("issue.created", ...)` | Hook into service layer, emit events after mutations | +| Scheduled (cron) | `on_schedule("0 18 * * 5", ...)` | Cron expressions, background tokio task | + +**Available events:** +- `issue.created`, `issue.updated`, `issue.deleted` +- `issue.status_changed`, `issue.assigned`, `issue.unassigned` +- `comment.created`, `comment.deleted` +- `sprint.started`, `sprint.ended`, `sprint.completed` +- `tag.created`, `tag.removed` + +**Sandboxed Lua API surface:** +- `ctx.project:issues(filter)` — query issues +- `ctx.project:sprints()` — list sprints +- `issue:update(fields)` — mutate issue +- `issue:add_comment(body)` — add comment +- `ctx.user` — current user info + +**Safety model:** +- No filesystem, no network access +- Execution timeout (e.g., 5s max per script) +- Memory limit via `mlua` Lua state options +- Scripts run in separate tokio tasks, never block the API + +**Script storage:** +- `scripts` table in Postgres (org_id, project_id, name, source, trigger_type, trigger_config, enabled) +- `script_revisions` table for revision history / audit +- `script_execution_logs` table for past runs, errors, output + +**Codebase location:** +``` +src/ +├── scripting/ +│ ├── mod.rs # Script engine, mlua setup, sandbox config +│ ├── api.rs # Lua API bindings (ctx.project, ctx.issue, etc.) +│ ├── scheduler.rs # Cron-based trigger runner +│ ├── hooks.rs # Event emission from service layer +│ └── models.rs # Script DB model + DTOs +``` + +**Tasks:** +1. Add `scripts`, `script_revisions`, and `script_execution_logs` tables to DB +2. Implement `scripting::mod` — mlua sandbox setup, script compilation/validation +3. Implement `scripting::api` — expose safe Lua API (ctx.project, ctx.issue, ctx.sprint) +4. Implement `scripting::hooks` — event emission from service layer +5. Implement `scripting::scheduler` — cron expression parsing + background tokio task runner +6. Script CRUD endpoints at `/api/v1/orgs/{org_slug}/projects/{project_slug}/scripts` +7. Script execution endpoint: `POST .../scripts/{id}/run` (for manual trigger / testing) +8. Script execution log endpoint: `GET .../scripts/{id}/logs` +9. Admin controls: enable/disable scripts per org/project, execution timeout config + +--- + +## Implementation Order (within each feature) + +1. **Migration** → write and run the SQL migration +2. **Repository** → write the sqlx queries + Rust repo struct +3. **Model** → define the Rust domain model + DTOs +4. **Service** → business logic (validation, authorization rules) +5. **Handler** → thin Axum handler calling service +6. **Route** → wire into the router with appropriate middleware +7. **Test** → write a test for the endpoint + +--- + +## Testing Approach ### Phase 1 (Recommended Start) -- Unit tests for pure service logic (no DB) -- Integration tests for SQLC repositories with real Postgres via Docker +- Unit tests for pure service logic (no DB, mock repositories) +- Integration tests for repositories with real Postgres via Docker -### DB Interface Testing (via SQLC `Querier`) +### Repository Testing -- Treat generated SQLC interfaces (e.g. `usersdb.Querier`) as service dependencies -- For service unit tests, provide a fake/mock implementation of `Querier` -- Focus unit tests on business rules, branching, and error mapping (not SQL behavior) +- Define trait interfaces for repositories +- For service unit tests, provide mock implementations +- Focus unit tests on business rules, branching, and error mapping - Keep SQL correctness in integration tests against real Postgres - This split gives fast unit tests plus high-confidence DB integration coverage ### Phase 2 -- HTTP handler tests with `httptest` +- HTTP handler tests with Axum test helpers - Auth middleware tests +- RBAC extractor tests ### Phase 3 - Minimal end-to-end happy path tests +- Load/stress testing --- ## OpenAPI + Frontend Type Generation -- Keep spec in repo at `api/openapi/openapi.yaml` -- Generate frontend TypeScript types from OpenAPI (e.g. `openapi-typescript`) -- Optionally serve Swagger UI from backend +- utoipa annotations on handlers and models +- Auto-generate OpenAPI spec at build time +- Expose at `GET /api/v1/docs/openapi.json` +- Generate frontend TypeScript types from OpenAPI (e.g., `openapi-typescript`) +- Optionally serve Swagger UI from backend via `utoipa-swagger-ui` --- ## Deployment - Docker Compose for app + postgres -- Healthcheck in compose should target readiness endpoint +- Healthcheck in compose targets readiness endpoint - Env-based configuration (`.env`, `.env.example`) - ---- - -## Pending Decisions - -1. JWT signing: - - HS256 shared secret (simple) - - RS256 keypair (better long-term) - -2. Token model: - - Access token only - - Access + refresh token - -3. Initial roles: - - USER / ADMIN - - USER / MODERATOR / ADMIN - -4. OpenAPI workflow: - - Contract-first (spec first) - - Code-first annotations - -5. CORS policy: - - Allowed frontend origins in dev/prod - -6. Schema strategy: - - Single schema (`public`) confirmation - -7. Initial bounded contexts: - - e.g. `auth`, `users`, `rooms` (or your domain names) +- Multi-stage Dockerfile (rust:alpine builder → alpine runtime) --- @@ -187,3 +730,4 @@ - This file is intentionally a working draft. - We will refine decisions and turn this into a concrete implementation checklist. +- Originally started as Go/Gin, pivoted to Rust/Axum for the Rust learning journey. diff --git a/Dockerfile b/Dockerfile index 2b2ef23..ac73c9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,15 @@ -FROM golang:1.26-bookworm AS builder -WORKDIR /src +FROM rust:1.95.0-alpine3.22 AS builder +WORKDIR /app -# Cache deps first -COPY go.mod go.sum ./ -RUN go mod download +ARG DB_URL +ARG APP_ENV -# Copy source and build COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api +RUN cargo build --release # Small runtime image -FROM gcr.io/distroless/static-debian12 +FROM alpine:3.22.4 WORKDIR /app -COPY --from=builder /out/api /app/api -EXPOSE 8080 -USER nonroot:nonroot -ENTRYPOINT ["/app/api"] +COPY --from=builder /app/target/release/rhythm-backend /app/executable + +ENTRYPOINT ["/app/executable"] diff --git a/bruno/.gitignore b/bruno/.gitignore deleted file mode 100644 index e19311f..0000000 --- a/bruno/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# Secrets -.env* - -# Dependencies -node_modules - -# OS files -.DS_Store -Thumbs.db \ No newline at end of file diff --git a/bruno/login user.yml b/bruno/login user.yml deleted file mode 100644 index 2996080..0000000 --- a/bruno/login user.yml +++ /dev/null @@ -1,27 +0,0 @@ -info: - name: login user - type: http - seq: 1 - -http: - method: POST - url: "{{host}}/api/v1/auth/login" - body: - type: json - data: |- - { - "email": "dmo@dmo.dmo", - "password": "password12345" - } - auth: inherit - -runtime: - variables: - - name: host - value: http://localhost:8080 - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/bruno/opencollection.yml b/bruno/opencollection.yml deleted file mode 100644 index 3d96ac5..0000000 --- a/bruno/opencollection.yml +++ /dev/null @@ -1,10 +0,0 @@ -opencollection: 1.0.0 - -info: - name: rhythm -bundled: false -extensions: - bruno: - ignore: - - node_modules - - .git diff --git a/bruno/register user.yml b/bruno/register user.yml deleted file mode 100644 index d7c0dc8..0000000 --- a/bruno/register user.yml +++ /dev/null @@ -1,27 +0,0 @@ -info: - name: register user - type: http - seq: 2 - -http: - method: POST - url: "{{host}}/api/v1/auth/register" - body: - type: json - data: |- - { - "email": "dmo@dmo.dmo", - "password": "password12345" - } - auth: inherit - -runtime: - variables: - - name: host - value: http://localhost:8080 - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/compose.prod.yaml b/compose.prod.yaml deleted file mode 100644 index e1ed9bd..0000000 --- a/compose.prod.yaml +++ /dev/null @@ -1,36 +0,0 @@ -services: - api-prod: - image: git.kanopo.dev/rhythm/rhythm-backend:latest - restart: unless-stopped - container_name: rhythm-api-prod - ports: - - "8080:8080" - environment: - DB_HOST: db-prod - env_file: - - ".env" - depends_on: - db-prod: - condition: service_healthy - profiles: - - prod - - db-prod: - image: postgres:18.0-alpine - restart: unless-stopped - container_name: rhythm-db-prod - ports: - - "${DB_PORT_PROD:-5433}:5432" - environment: - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} - volumes: - - ./.data/postgres-prod:/var/lib/postgresql/data - profiles: - - prod - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"] - interval: 5s - timeout: 3s - retries: 10 diff --git a/compose.yaml b/compose.yaml index d94e0c9..9e48953 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,40 +1,4 @@ services: - api-prod: - build: . - restart: unless-stopped - container_name: rhythm-api-prod - ports: - - "8080:8080" - environment: - DB_HOST: db-prod - env_file: - - ".env" - depends_on: - db-prod: - condition: service_healthy - profiles: - - prod - - db-prod: - image: postgres:18.0-alpine - restart: unless-stopped - container_name: rhythm-db-prod - ports: - - "${DB_PORT_PROD:-5433}:5432" - environment: - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} - volumes: - - ./.data/postgres-prod:/var/lib/postgresql/data - profiles: - - prod - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"] - interval: 5s - timeout: 3s - retries: 10 - db-dev: image: postgres:18.0-alpine restart: unless-stopped @@ -53,5 +17,34 @@ services: timeout: 3s retries: 10 -volumes: - postgres_data: + api-prod: + image: git.kanopo.dev/rhythm/rhythm-backend:latest + container_name: rhythm-api-prod + restart: unless-stopped + environment: + DB_HOST: db-prod + env_file: + - ".env" + depends_on: + db-prod: + condition: service_healthy + profiles: + - prod + + db-prod: + image: postgres:18.0-alpine + restart: unless-stopped + container_name: rhythm-db-prod + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: rhythm-prod + volumes: + - ./postgres-data:/var/lib/postgresql/data + profiles: + - prod + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d rhythm-prod"] + interval: 5s + timeout: 3s + retries: 10