rhythm-backend/BACKEND_BLUEPRINT.md
Dmitri e4c582dabe
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 9m41s
initial db interactions
2026-04-27 23:05:27 +02:00

720 lines
30 KiB
Markdown

# Rhythm Backend Blueprint (Rust/Axum)
## IDEAS
- 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
- **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 access tokens, 15min) + opaque refresh tokens (7d, stored hashed in DB via SHA-256) + argon2 (password hashing)
- **API Docs:** utoipa (code-first OpenAPI generation)
- **Health Probes:** liveness/readiness endpoints
- **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 = JWT (HS256, 15min, stateless verification). Refresh = opaque UUID v4 string, stored SHA-256 hashed in DB (7 days, revocable). Logout deletes refresh token from DB; access token still valid until expiry.
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
### Project Structure
```
src/
├── main.rs # Entrypoint, server, graceful shutdown
├── config.rs # Config (db_url, jwt_secret, socket_address, app_env)
├── database.rs # PgPool setup, migration runner
├── logging.rs # tracing config (dev: pretty console, prod: JSON + file)
├── errors.rs # AppError enum → IntoResponse (BadRequest/Unauthorized/Internal)
├── state.rs # AppState (PgPool, jwt_secret)
├── server.rs # Axum server init + graceful shutdown
├── auth/
│ ├── mod.rs
│ ├── jwt.rs # HS256 JWT creation/validation (access tokens, 15min)
│ ├── hash.rs # argon2 password hashing/verification
│ └── token.rs # Refresh token generation (UUID v4) + SHA-256 hashing for DB storage
├── db/
│ ├── mod.rs
│ ├── user_repo.rs # find_by_email, create
│ └── token_repo.rs # store, find_by_hash, delete_by_hash, delete_all_for_user
├── service/
│ ├── mod.rs
│ └── auth_service.rs # login, register, refresh, logout business logic
├── controller/
│ ├── mod.rs # Router composition (/api/v1/...)
│ ├── model/
│ │ ├── mod.rs
│ │ └── auth_model.rs # LoginRequest, RegisterRequest, AuthResponse DTOs
│ └── v1/
│ ├── mod.rs # v1 router (nests /auth)
│ └── auth_controller.rs # POST /login, /register, /refresh, /logout handlers
├── middleware/
│ ├── auth.rs # JWT extraction from Authorization header, inject CurrentUser
│ └── rbac.rs # Project-level role guard
├── models/ # (future: org, project, issue, etc.)
└── handlers/ # (future: orgs, projects, issues, etc.)
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
- Service layer owns DB transactions
- Repositories accept either `&PgPool` or `&mut Transaction<Postgres>`
- sqlx compile-time query checking ensures SQL correctness at build time
---
## API and Runtime
### API Shape
- REST JSON API
- Possible future WebSocket support for interactive features
- Versioning: `/api/v1`
### Health Endpoints
- `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
- 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 Schema
```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');
-- 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()
);
-- 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()
);
-- 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)
);
-- 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)
);
-- 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.
---
## 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<ProjectRole>` and `RequireRole<OrgRole>` 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 (Hybrid JWT Access + DB-stored Refresh Tokens)
**Token model:** Access tokens are signed JWTs (15min, no DB lookup). Refresh tokens are opaque strings stored hashed in DB (7 days, revocable).
**Additional dependencies:** `jsonwebtoken`, `argon2`, `sha2`, `uuid` (v4), `chrono`
**Current progress:**
- ✅ Config, AppState (PgPool), DB migrations runner, router setup, users migration
- ✅ Error types (`AppError` with `IntoResponse` — BadRequest/Unauthorized/Internal)
- ✅ Auth controller routes wired (`POST /api/v1/auth/login`, `/register`)
- ✅ Auth DTOs (`LoginRequest`, `RegisterRequest`, `AuthResponse`) in `controller/model/auth_model.rs`
- ✅ Service stubs (`auth_service.rs``login()`, `register()` with `todo!()`)
- ⚠️ Handler parameter order needs fix (State before Json)
**Tasks:**
1. ~~Create `users` migration~~ (done — `0001_create_users_table.sql`)
2. Fix handler parameter order — `State` before `Json` in `auth_controller.rs`
3. Create `tokens` migration — `0002_create_tokens_table.sql` (id, user_id, token_hash, expires_at, created_at)
4. Add dependencies to `Cargo.toml``argon2`, `jsonwebtoken`, `sha2`, `uuid` (v4), `chrono`
5. Create `src/db/` module — `user_repo.rs` (find_by_email, create) + `token_repo.rs` (store, find_by_hash, delete_by_hash, delete_all_for_user)
6. Create `src/auth/hash.rs` — argon2 password hashing (`hash_password`, `verify_password`)
7. Create `src/auth/jwt.rs` — HS256 JWT access token creation/validation with 15min expiry (`create_access_token`, `verify_access_token`, `Claims` struct)
8. Create `src/auth/token.rs` — generate random refresh token (UUID v4), SHA-256 hash for DB storage
9. Add `jwt_secret` to `AppState` / `Config` (loaded from `JWT_SECRET` env var)
10. Implement `auth_service.rs` — login (find user → verify password → create JWT access token + generate/store refresh token) and register (check email → hash password → create user → create tokens)
11. Add input validation — email/password not empty → `AppError::Validation`
12. Create `middleware::auth` — JWT extraction from `Authorization: Bearer <token>`, inject `CurrentUser`
13. Add `POST /api/v1/auth/refresh` — accepts refresh token → hash → look up in DB → if valid, delete old + create new token pair
14. Add `POST /api/v1/auth/logout` — accepts refresh token → hash → delete from DB
15. 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<ProjectRole>` and `RequireRole<OrgRole>` 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, mock repositories)
- Integration tests for repositories with real Postgres via Docker
### Repository Testing
- 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 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
- 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 targets readiness endpoint
- Env-based configuration (`.env`, `.env.example`)
- Multi-stage Dockerfile (rust:alpine builder → alpine runtime)
---
## Iteration Notes
- 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.