28 KiB
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) + argon2 (password hashing)
- API Docs: utoipa (code-first OpenAPI generation)
- Health Probes: liveness/readiness endpoints
- Deployment: Docker Compose on self-hosted hardware
Decisions Made
- JWT signing: HS256 (shared secret from env, can migrate to RS256 later)
- Token model: Access + Refresh (short-lived access ~15min, long-lived refresh ~7d, refresh tokens stored hashed in DB)
- Roles: Project-level roles (
admin,developer,reporter) + Org-level roles (owner,admin,member) - OpenAPI workflow: Code-first with utoipa (auto-generate spec from Rust handlers/models)
- DB access: sqlx with hand-written repositories (compile-time checked, no ORM)
- First entities: Users → Organizations → Projects → Issues
- Sprint stages: Custom per project (project admin defines board columns like Todo → In Review → Done)
- Assignees: Multiple assignees per issue (join table)
- Issue content features: Tags, comments, issue relations, time tracking
- 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 (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
- Service layer owns DB transactions
- Repositories accept either
&PgPoolor&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 aliveGET /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 tokensPOST /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 orgsGET /api/v1/orgs/{org_slug}- get orgPATCH /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 projectsGET /api/v1/orgs/{org_slug}/projects/{project_slug}- get projectPATCH /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 issuePATCH /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 tagsPOST /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 commentsPOST /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 assigneesPUT /api/v1/orgs/{org}/projects/{proj}/issues/{num}/assignees- set assignees (developer+)
Relations:
GET /api/v1/orgs/{org}/projects/{proj}/issues/{num}/relations- list relationsPOST /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 entriesPOST /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 sprintsGET /api/v1/orgs/{org}/projects/{proj}/sprints/{id}- get sprintPATCH /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
-- 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>andRequireRole<OrgRole>Axum extractors- Extract
CurrentUserfrom 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
reporterrole 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:
- Extend
Configwithjwt_secret,db_urlparsing,server_port - Create
AppStatestruct holdingPgPoolandConfig - Set up
sqlx::PgPoolwithsqlx::migrate!().run()on startup - Create health endpoints:
GET /api/v1/health/liveandGET /api/v1/health/ready - Wire up Axum router with
routes(), shared state, and graceful shutdown viatokio::signal - Create first migration:
userstable
Phase 2: Auth (JWT + Register/Login/Refresh)
Additional dependencies: jsonwebtoken, argon2, validator
Tasks:
- Create
usersmigration (if not done in Phase 1) - Create
refresh_tokensmigration - Implement
auth::hash— argon2 password hashing/verification - Implement
auth::jwt— HS256 access + refresh token creation/validation - Implement
db::repos::users— create, get by email, get by id - Implement
db::repos::refresh_tokens— store, find, delete - Implement
auth::service— register, login, refresh logic - Implement
auth::handlers—POST /api/v1/auth/register,/login,/refresh - Implement
auth::models— request/response DTOs withvalidatorchecks - Create
errors.rs— unifiedAppErrorenum →IntoResponse - Create
middleware::auth— JWT extraction, injectCurrentUser - Protect routes with auth layer
Phase 3: RBAC Layer
Tasks:
- Create
organization_membershipsandproject_membershipsmigrations - Define
OrgRoleandProjectRoleas SQL enums + Rust enums - Implement
db::repos::memberships— add/remove/check, get role - Create
RequireRole<ProjectRole>andRequireRole<OrgRole>Axum extractors - Wire role guards into project/issue routes
Phase 4: Core Domain (Organizations → Projects → Issues)
Tasks:
- 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
- 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
- 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
tsvectorcolumn for full-text search on title + descriptionGET .../issues?q=search+termsusestsquery
- Pagination — cursor-based pagination on all list endpoints
- utoipa — add
#[derive(OpenApi)]annotations, exposeGET /api/v1/docs/openapi.json
Phase 5: Issue Rich Content (Tags, Comments, Relations, Time Tracking, Assignees)
Tasks:
- Tags —
tags+issue_tagstables- Create/list/delete tags per project (developer+ to create, admin to delete)
- Set issue tags (developer+)
- Filter issues by tag:
GET .../issues?tag=bug
- Comments —
commentstable- List/create comments on issues (reporter+)
- Edit own comment, delete own comment or admin
- Issue Relations —
issue_relationstable with relation types- Add/remove relations (developer+)
- Types: blocks, is_blocked_by, duplicates, is_duplicated_by, relates_to, clones, is_cloned_by
- Time Tracking —
time_entriestable- Log time on issues (developer+)
- Delete own entry or admin
- Aggregate total time spent on issue (computed from entries)
- Multiple Assignees —
issue_assigneesjoin table- Replace single
assignee_idwith join table - Set/replace assignees on issue (developer+)
- Filter issues by assignee:
GET .../issues?assignee={user_id}
- Replace single
Phase 6: Sprints & Stages
Tasks:
- Stages —
stagestable (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_idtracks which column it's in
- Sprints —
sprintstable- Sprint CRUD endpoints
- Sprint state transitions:
planned→active→completed - Issue-sprint assignment via
sprint_idon issues (developer+)
- 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:
- CORS middleware (configurable origins from env)
- Request ID middleware (propagate through logging)
- Rate limiting (e.g.,
tower-governor) - Comprehensive error responses (validation → 422, auth → 401/403)
- Integration tests with test DB containers (
testcontainers) - 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:
-- 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)
-- 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.deletedissue.status_changed,issue.assigned,issue.unassignedcomment.created,comment.deletedsprint.started,sprint.ended,sprint.completedtag.created,tag.removed
Sandboxed Lua API surface:
ctx.project:issues(filter)— query issuesctx.project:sprints()— list sprintsissue:update(fields)— mutate issueissue:add_comment(body)— add commentctx.user— current user info
Safety model:
- No filesystem, no network access
- Execution timeout (e.g., 5s max per script)
- Memory limit via
mluaLua state options - Scripts run in separate tokio tasks, never block the API
Script storage:
scriptstable in Postgres (org_id, project_id, name, source, trigger_type, trigger_config, enabled)script_revisionstable for revision history / auditscript_execution_logstable 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:
- Add
scripts,script_revisions, andscript_execution_logstables to DB - Implement
scripting::mod— mlua sandbox setup, script compilation/validation - Implement
scripting::api— expose safe Lua API (ctx.project, ctx.issue, ctx.sprint) - Implement
scripting::hooks— event emission from service layer - Implement
scripting::scheduler— cron expression parsing + background tokio task runner - Script CRUD endpoints at
/api/v1/orgs/{org_slug}/projects/{project_slug}/scripts - Script execution endpoint:
POST .../scripts/{id}/run(for manual trigger / testing) - Script execution log endpoint:
GET .../scripts/{id}/logs - Admin controls: enable/disable scripts per org/project, execution timeout config
Implementation Order (within each feature)
- Migration → write and run the SQL migration
- Repository → write the sqlx queries + Rust repo struct
- Model → define the Rust domain model + DTOs
- Service → business logic (validation, authorization rules)
- Handler → thin Axum handler calling service
- Route → wire into the router with appropriate middleware
- 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.